Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1f9139e
feat(compression): add compressMetadata and decompressMetadata
woahwhattheheck Sep 6, 2026
ee9d813
feat(compression): export the metadata helpers from the package root
woahwhattheheck Sep 6, 2026
30bf94e
test(compression): cover metadata encode/decode round trip and reject…
woahwhattheheck Sep 6, 2026
bc177ec
fix(compression): drop the Node Buffer global from the metadata helpers
woahwhattheheck Sep 7, 2026
6498c49
test(compression): prove the metadata helpers work with no Buffer global
woahwhattheheck Sep 7, 2026
04e0145
ci: restore executable integration workflow
woahwhattheheck Sep 13, 2026
6fc03c7
ci: cancel superseded conflict checks (#5)
woahwhattheheck Sep 13, 2026
9c2125a
fix(anomaly): reject NaN sensitivity thresholds (#12)
woahwhattheheck Sep 13, 2026
cb058ca
fix(amm): keep liquidity ratio checks BigInt-safe (#2)
woahwhattheheck Sep 13, 2026
66c5ac1
ci: add full offline PR test gate (#14)
woahwhattheheck Sep 13, 2026
713009e
fix(amm): compose price-impact normalization on current main
woahwhattheheck Sep 13, 2026
b74f8ad
fix(batch): rejoin concurrency bounds on current main
woahwhattheheck Sep 13, 2026
df36f5a
ci: validate full publish suite on current main
woahwhattheheck Sep 13, 2026
0da230e
Merge current main into invoice concurrency carrier
woahwhattheheck Sep 13, 2026
e701d5c
fix(compression): normalize malformed base64url errors
woahwhattheheck Sep 13, 2026
bc9946d
test(compression): cover malformed base64url decoder failures
woahwhattheheck Sep 13, 2026
a9e5e67
Merge current main into metadata encoding carrier
woahwhattheheck Sep 13, 2026
4059828
feat(compression): add metadata base64url helpers
woahwhattheheck Sep 13, 2026
de43225
Restore upstream-only #619 review tree
woahwhattheheck Sep 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions src/compression.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { SdkError, SdkErrorCode } from "./errors.js";
import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js";

export type CompressionAlgorithm = "gzip" | "deflate";
Expand Down Expand Up @@ -141,3 +142,159 @@ export function createCompressionResponseInterceptor(_config: CompressionConfig)
};
};
}

// ---------------------------------------------------------------------------
// Invoice metadata encoding (#619)
// ---------------------------------------------------------------------------

/** Default byte ceiling for an encoded metadata string. */
export const DEFAULT_METADATA_MAX_BYTES = 512;

/**
* base64url alphabet. Trailing padding is tolerated on decode even though it
* is never produced, so a caller that padded the value elsewhere still round
* trips.
*/
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*={0,2}$/;

/**
* base64url encode, without depending on the Node `Buffer` global.
*
* This module is isomorphic - it feature-detects `CompressionStream` and falls
* back to `node:zlib` - so the metadata helpers must not reach for a Node-only
* global either. `TextEncoder`/`TextDecoder` and `btoa`/`atob` exist in both
* browsers and Node >= 16.
*/
function toBase64Url(json: string): string {
const bytes = new TextEncoder().encode(json);

// Chunked rather than String.fromCharCode(...bytes): spreading a large array
// overflows the call stack, and the size limit is only checked after encoding.
const CHUNK_SIZE = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK_SIZE));
}

return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

/** base64url decode, tolerating optional padding. Mirror of {@link toBase64Url}. */
function fromBase64Url(encoded: string): string {
const base64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return new TextDecoder().decode(bytes);
}

/**
* Encode an invoice metadata object as a compact base64url string.
*
* The value is JSON-serialised then base64url encoded without padding, so the
* result is safe to place in a Stellar transaction memo or an IPFS payload.
*
* @param metadata - Any JSON-serialisable plain object.
* @param maxBytes - Ceiling for the encoded string, in bytes.
* @returns The encoded metadata.
* @throws {SdkError} With {@link SdkErrorCode.CONTRACT_REJECTED} when the
* input is not a serialisable plain object, or when the encoded result
* exceeds `maxBytes`.
*/
export function compressMetadata(
metadata: Record<string, unknown>,
maxBytes: number = DEFAULT_METADATA_MAX_BYTES,
): string {
if (typeof metadata !== "object" || metadata === null || Array.isArray(metadata)) {
throw new SdkError(
"Metadata must be a plain object",
SdkErrorCode.CONTRACT_REJECTED,
{ received: metadata === null ? "null" : typeof metadata },
);
}

if (!Number.isFinite(maxBytes) || maxBytes <= 0) {
throw new SdkError(
"maxBytes must be a positive, finite number",
SdkErrorCode.CONTRACT_REJECTED,
{ maxBytes },
);
}

let json: string;
try {
json = JSON.stringify(metadata);
} catch (err) {
// Circular references and BigInt values both reach here.
throw new SdkError(
"Metadata is not JSON-serialisable",
SdkErrorCode.CONTRACT_REJECTED,
{ reason: err instanceof Error ? err.message : String(err) },
);
}

const encoded = toBase64Url(json);
// base64url is ASCII-only, so the character count is the byte count.
const bytes = encoded.length;

if (bytes > maxBytes) {
throw new SdkError(
`Encoded metadata is ${bytes} bytes, over the ${maxBytes} byte limit`,
SdkErrorCode.CONTRACT_REJECTED,
{ bytes, maxBytes },
);
}

return encoded;
}

/**
* Decode a metadata string produced by {@link compressMetadata}.
*
* @param encoded - base64url-encoded metadata.
* @returns The decoded object.
* @throws {SdkError} With {@link SdkErrorCode.CONTRACT_REJECTED} when the
* input is not base64url, does not contain JSON, or does not decode to a
* plain object.
*/
export function decompressMetadata(encoded: string): Record<string, unknown> {
if (typeof encoded !== "string" || !BASE64URL_PATTERN.test(encoded)) {
throw new SdkError(
"Encoded metadata is not a base64url string",
SdkErrorCode.CONTRACT_REJECTED,
{ received: typeof encoded },
);
}

let json: string;
try {
json = fromBase64Url(encoded);
} catch (err) {
throw new SdkError(
"Encoded metadata is not valid base64url",
SdkErrorCode.CONTRACT_REJECTED,
{ reason: err instanceof Error ? err.message : String(err) },
);
}

let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch (err) {
throw new SdkError(
"Encoded metadata does not contain valid JSON",
SdkErrorCode.CONTRACT_REJECTED,
{ reason: err instanceof Error ? err.message : String(err) },
);
}

if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
throw new SdkError(
"Encoded metadata did not decode to an object",
SdkErrorCode.CONTRACT_REJECTED,
{ decodedType: parsed === null ? "null" : Array.isArray(parsed) ? "array" : typeof parsed },
);
}

return parsed as Record<string, unknown>;
}
10 changes: 10 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1427,3 +1427,13 @@ export type {
SubmitTransactionOptions,
SubmitServer,
} from "./transaction/submit.js";

// ---------------------------------------------------------------------------
// #619 - Invoice metadata encoding
// ---------------------------------------------------------------------------

export {
compressMetadata,
decompressMetadata,
DEFAULT_METADATA_MAX_BYTES,
} from "./compression.js";
19 changes: 19 additions & 0 deletions test/compression.metadata.invalid-base64url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";

import { decompressMetadata } from "../src/compression.js";
import { SdkError, SdkErrorCode } from "../src/errors.js";

describe("decompressMetadata malformed base64url", () => {
it.each(["A", "A=", "=="])(
"normalizes decoder failure for %j to CONTRACT_REJECTED",
(encoded) => {
try {
decompressMetadata(encoded);
throw new Error("expected malformed base64url to be rejected");
} catch (error) {
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED);
}
},
);
});
175 changes: 175 additions & 0 deletions test/compression.metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* Tests for invoice metadata encoding (Issue #619).
* Pure functions — no network, no filesystem.
*/

import { describe, it, expect } from "vitest";

import {
DEFAULT_METADATA_MAX_BYTES,
compressMetadata,
decompressMetadata,
} from "../src/compression.js";
import { SdkError, SdkErrorCode } from "../src/errors.js";

const expectRejected = (fn: () => unknown) => {
try {
fn();
throw new Error("expected the call to throw an SdkError");
} catch (error) {
expect(error).toBeInstanceOf(SdkError);
expect((error as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED);
return error as SdkError;
}
};

describe("DEFAULT_METADATA_MAX_BYTES", () => {
it("is 512", () => {
expect(DEFAULT_METADATA_MAX_BYTES).toBe(512);
});
});

describe("compressMetadata", () => {
it("encodes without base64 padding", () => {
// "{}" is 2 bytes, which is the case standard base64 would pad.
const encoded = compressMetadata({});

expect(encoded).not.toContain("=");
expect(encoded).toMatch(/^[A-Za-z0-9_-]+$/);
});

it("uses the base64url alphabet, never + or /", () => {
// Bytes that encode to '+' and '/' under standard base64.
const encoded = compressMetadata({ v: "ÿÿÿ????>>>" });

expect(encoded).not.toContain("+");
expect(encoded).not.toContain("/");
});

it("rejects a payload over the default limit", () => {
const error = expectRejected(() =>
compressMetadata({ blob: "x".repeat(1000) }),
);

expect(error.message).toContain(String(DEFAULT_METADATA_MAX_BYTES));
});

it("honours a custom maxBytes", () => {
expect(() => compressMetadata({ a: 1 }, 4)).toThrow(SdkError);
expect(() => compressMetadata({ a: 1 }, 64)).not.toThrow();
});

it("reports the actual size alongside the limit", () => {
const error = expectRejected(() => compressMetadata({ a: 1 }, 4));
const details = error.details as { bytes: number; maxBytes: number };

expect(details.maxBytes).toBe(4);
expect(details.bytes).toBeGreaterThan(4);
});

it.each([
["null", null],
["an array", [1, 2, 3]],
["a string", "not an object"],
["a number", 42],
])("rejects %s", (_label, value) => {
expectRejected(() => compressMetadata(value as never));
});

it("rejects a circular structure rather than throwing a raw TypeError", () => {
const circular: Record<string, unknown> = {};
circular["self"] = circular;

expectRejected(() => compressMetadata(circular));
});

it("rejects a BigInt value, which JSON cannot serialise", () => {
expectRejected(() => compressMetadata({ amount: 1n as unknown }));
});

it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
"rejects the invalid maxBytes %s",
(maxBytes) => {
expectRejected(() => compressMetadata({ a: 1 }, maxBytes));
},
);
});

describe("decompressMetadata", () => {
it("rejects a string outside the base64url alphabet", () => {
expectRejected(() => decompressMetadata("not base64!"));
expectRejected(() => decompressMetadata("has+plus/slash"));
});

it("rejects base64url that does not contain JSON", () => {
// "Hello" — valid base64url, not JSON.
expectRejected(() => decompressMetadata("SGVsbG8"));
});

it("rejects a non-string input", () => {
expectRejected(() => decompressMetadata(undefined as never));
expectRejected(() => decompressMetadata(123 as never));
});

it.each([
["an array", "[1,2,3]"],
["a number", "42"],
["a string", '"hello"'],
["null", "null"],
])("rejects encoded JSON that decodes to %s", (_label, json) => {
const encoded = Buffer.from(json, "utf8").toString("base64url");

expectRejected(() => decompressMetadata(encoded));
});

it("accepts padded input even though it never emits padding", () => {
// A caller may have padded the value elsewhere; decoding should still work.
const padded = Buffer.from(JSON.stringify({ p: 1 }), "utf8").toString("base64");

expect(decompressMetadata(padded)).toEqual({ p: 1 });
});
});

describe("browser safety", () => {
it("encodes and decodes with no Node Buffer global present", () => {
// compression.ts is isomorphic - it feature-detects CompressionStream and
// falls back to node:zlib - so these helpers must not need a Node global.
// A browser bundle without a Buffer polyfill is exactly this shape.
const originalBuffer = globalThis.Buffer;

try {
// @ts-expect-error deliberately simulating an environment with no Buffer
delete globalThis.Buffer;

const value = { id: "evt_1", note: "héllo ✓ 日本語" };
const encoded = compressMetadata(value);

expect(encoded).not.toContain("=");
expect(decompressMetadata(encoded)).toEqual(value);
} finally {
globalThis.Buffer = originalBuffer;
}
});
});

describe("round trip", () => {
const cases: Array<[string, Record<string, unknown>]> = [
["an empty object", {}],
["a flat object", { invoiceId: "inv_1", amount: 1000 }],
["nested objects and arrays", { a: { b: { c: [1, 2, { d: true }] } } }],
["null and boolean values", { n: null, t: true, f: false }],
["unicode", { note: "héllo ✓ 日本語" }],
["keys needing escaping", { 'quote"key': 'value with "quotes"' }],
];

it.each(cases)("round-trips %s", (_label, value) => {
expect(decompressMetadata(compressMetadata(value))).toEqual(value);
});

it("survives a second round trip unchanged", () => {
const value = { invoiceId: "inv_2", tags: ["a", "b"] };
const once = compressMetadata(value);

expect(compressMetadata(decompressMetadata(once))).toBe(once);
});
});