From 1f9139ee5f1984bb5af2f31e660e9f867fe9c2e1 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 6 Sep 2026 19:04:11 -0400 Subject: [PATCH 01/16] feat(compression): add compressMetadata and decompressMetadata Adds a JSON + base64url round trip for invoice metadata small enough to sit in a Stellar transaction memo or an IPFS payload, with a size ceiling so an oversized object fails at encode time rather than at submission. Both directions reject bad input with SdkError CONTRACT_REJECTED: a non-serialisable object, an encoded string outside the base64url alphabet, one that does not contain JSON, and one that decodes to something other than an object. --- src/compression.ts | 116 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/src/compression.ts b/src/compression.ts index 4373003..ef4fc59 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,3 +1,4 @@ +import { SdkError, SdkErrorCode } from "./errors.js"; import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js"; export type CompressionAlgorithm = "gzip" | "deflate"; @@ -141,3 +142,118 @@ 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}$/; + +/** + * 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, + 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 = Buffer.from(json, "utf8").toString("base64url"); + const bytes = Buffer.byteLength(encoded, "utf8"); + + 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 { + if (typeof encoded !== "string" || !BASE64URL_PATTERN.test(encoded)) { + throw new SdkError( + "Encoded metadata is not a base64url string", + SdkErrorCode.CONTRACT_REJECTED, + { received: typeof encoded }, + ); + } + + const json = Buffer.from(encoded, "base64url").toString("utf8"); + + 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; +} From ee9d813c971a701758468d22eb1badaaa73f262e Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 6 Sep 2026 19:04:23 -0400 Subject: [PATCH 02/16] feat(compression): export the metadata helpers from the package root --- src/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/index.ts b/src/index.ts index f9cb56b..73e4775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; From 30bf94e187b673c8c245b3a072bb1c4672d8c5ba Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 6 Sep 2026 19:04:36 -0400 Subject: [PATCH 03/16] test(compression): cover metadata encode/decode round trip and rejections --- test/compression.metadata.test.ts | 153 ++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 test/compression.metadata.test.ts diff --git a/test/compression.metadata.test.ts b/test/compression.metadata.test.ts new file mode 100644 index 0000000..40cc01c --- /dev/null +++ b/test/compression.metadata.test.ts @@ -0,0 +1,153 @@ +/** + * 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 = {}; + 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("round trip", () => { + const cases: Array<[string, Record]> = [ + ["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); + }); +}); From bc177ec9babc3fb93e0843ff1a258b2448a702b3 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 6 Sep 2026 22:03:45 -0400 Subject: [PATCH 04/16] fix(compression): drop the Node Buffer global from the metadata helpers compression.ts is isomorphic - it feature-detects CompressionStream and falls back to node:zlib - but compressMetadata/decompressMetadata reached for the Node Buffer global, which is undefined in a browser bundle without a polyfill. Both would have thrown 'Buffer is not defined' there, and the Node test process could never surface it. Uses TextEncoder/TextDecoder with btoa/atob instead, which exist in browsers and Node >= 16. Output is byte-identical to the previous encoding. --- src/compression.ts | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/src/compression.ts b/src/compression.ts index ef4fc59..cfc64ee 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -157,6 +157,37 @@ export const DEFAULT_METADATA_MAX_BYTES = 512; */ 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. * @@ -202,8 +233,9 @@ export function compressMetadata( ); } - const encoded = Buffer.from(json, "utf8").toString("base64url"); - const bytes = Buffer.byteLength(encoded, "utf8"); + 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( @@ -234,7 +266,7 @@ export function decompressMetadata(encoded: string): Record { ); } - const json = Buffer.from(encoded, "base64url").toString("utf8"); + const json = fromBase64Url(encoded); let parsed: unknown; try { From 6498c499dcade2230e2d7a39abf69917cc2abc81 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 6 Sep 2026 22:03:50 -0400 Subject: [PATCH 05/16] test(compression): prove the metadata helpers work with no Buffer global --- test/compression.metadata.test.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/compression.metadata.test.ts b/test/compression.metadata.test.ts index 40cc01c..11f9f35 100644 --- a/test/compression.metadata.test.ts +++ b/test/compression.metadata.test.ts @@ -130,6 +130,28 @@ describe("decompressMetadata", () => { }); }); +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]> = [ ["an empty object", {}], From 04e01456ba528b5a684e116415d136cb31027ef6 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 12 Sep 2026 22:01:16 -0400 Subject: [PATCH 06/16] ci: restore executable integration workflow * ci: expose canonical integration test command * ci: make integration workflow executable * docs: align integration test instructions with CI * fix(ci): use Vitest directory filter for integration suite --- .github/workflows/integration-test.yml | 15 +++++++++++++-- package.json | 1 + tests/integration/README.md | 9 +++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 00aacba..4b5a2a4 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -21,10 +21,21 @@ jobs: - name: Install dependencies run: npm ci + - name: Type check + run: npm run lint + + - name: Require testnet contract id + env: + STELLAR_SPLIT_CONTRACT_ID: ${{ secrets.STELLAR_SPLIT_CONTRACT_ID }} + run: | + if [ -z "$STELLAR_SPLIT_CONTRACT_ID" ]; then + echo "::error::STELLAR_SPLIT_CONTRACT_ID must be configured before running integration tests" + exit 1 + fi + - name: Run integration tests (testnet only) env: STELLAR_NETWORK: testnet STELLAR_SPLIT_CONTRACT_ID: ${{ secrets.STELLAR_SPLIT_CONTRACT_ID }} STELLAR_SPLIT_TOKEN_CONTRACT_ID: ${{ secrets.STELLAR_SPLIT_TOKEN_CONTRACT_ID }} - run: vitest run tests/integration/**/*.test.ts - + run: npm run test:integration diff --git a/package.json b/package.json index 32ad21d..81e6fbd 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "test:ui": "vitest run test/ui/", "test:all": "vitest run", "test:e2e": "vitest run test/e2e", + "test:integration": "vitest run tests/integration", "test:watch": "vitest", "lint": "tsc --noEmit", "changelog": "vite-node scripts/changelog.ts", diff --git a/tests/integration/README.md b/tests/integration/README.md index 6ccc0c7..cbba328 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -2,11 +2,16 @@ These tests run against the real Stellar Soroban **testnet**. -Run locally (requires `STELLAR_NETWORK=testnet` and a deployed contract id): +The integration suite requires a deployed testnet contract id and refuses to run without `STELLAR_NETWORK=testnet`. + +Run locally from the repository root: ```bash STELLAR_NETWORK=testnet \ STELLAR_SPLIT_CONTRACT_ID=... \ -vitest run tests/integration/**/*.test.ts +npm run test:integration ``` +`STELLAR_SPLIT_TOKEN_CONTRACT_ID` is optional. When omitted, the current integration suite falls back to the split contract id to match its existing test behavior. + +The GitHub Actions integration workflow is label-gated (`integration`) and reads `STELLAR_SPLIT_CONTRACT_ID` and, optionally, `STELLAR_SPLIT_TOKEN_CONTRACT_ID` from repository secrets. It type-checks before contacting testnet and fails early with a clear configuration error when the required contract id secret is missing. From 6fc03c7314fbdd0855276e5eb13b021b7df2f480 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 12 Sep 2026 22:06:28 -0400 Subject: [PATCH 07/16] ci: cancel superseded conflict checks (#5) Cancel obsolete Conflict Check runs when a newer revision of the same pull request supersedes them, reducing runner queue waste without changing SDK or publish behavior. --- .github/workflows/conflict-check.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/conflict-check.yml b/.github/workflows/conflict-check.yml index 95db95b..cbaf3d1 100644 --- a/.github/workflows/conflict-check.yml +++ b/.github/workflows/conflict-check.yml @@ -5,6 +5,10 @@ on: branches: [main] types: [opened, synchronize, reopened] +concurrency: + group: conflict-check-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: check-conflicts: runs-on: ubuntu-latest From 9c2125a74a429216f3566956866f56a3cee39a48 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 01:58:39 -0400 Subject: [PATCH 08/16] fix(anomaly): reject NaN sensitivity thresholds (#12) Fail closed when sensitivityThreshold is non-finite and add a focused regression for Number.NaN while preserving valid finite boundaries. --- src/anomalyDetector.ts | 6 +++++- test/anomalyDetector.nan.test.ts | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 test/anomalyDetector.nan.test.ts diff --git a/src/anomalyDetector.ts b/src/anomalyDetector.ts index 7f69bbb..3df8f5b 100644 --- a/src/anomalyDetector.ts +++ b/src/anomalyDetector.ts @@ -77,7 +77,11 @@ export class AnomalyDetector { this.rapidCycleSeconds = options.rapidCycleSeconds ?? 300; this.maxAmountVariance = options.maxAmountVariance ?? 0.8; this.sensitivityThreshold = options.sensitivityThreshold ?? 0.8; - if (this.sensitivityThreshold <= 0 || this.sensitivityThreshold > 1) { + if ( + !Number.isFinite(this.sensitivityThreshold) || + this.sensitivityThreshold <= 0 || + this.sensitivityThreshold > 1 + ) { throw new RangeError("sensitivityThreshold must be in the range (0, 1]"); } this.now = options.now ?? (() => Math.floor(Date.now() / 1000)); diff --git a/test/anomalyDetector.nan.test.ts b/test/anomalyDetector.nan.test.ts new file mode 100644 index 0000000..7378153 --- /dev/null +++ b/test/anomalyDetector.nan.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; +import { AnomalyDetector } from "../src/anomalyDetector.js"; + +describe("AnomalyDetector sensitivityThreshold validation", () => { + it("rejects NaN instead of silently disabling score alerts", () => { + expect(() => new AnomalyDetector({ sensitivityThreshold: Number.NaN })).toThrow( + RangeError + ); + }); + + it("still accepts finite values at the upper boundary", () => { + expect(() => new AnomalyDetector({ sensitivityThreshold: 1 })).not.toThrow(); + }); +}); From cb058ca198b045fca2c9f9a15fb256c1318cb2ca Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 02:31:19 -0400 Subject: [PATCH 09/16] fix(amm): keep liquidity ratio checks BigInt-safe (#2) --- src/__tests__/ammCalculator.test.ts | 17 +++++++++++++++++ src/ammCalculator.ts | 29 ++++++++++++++++++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/src/__tests__/ammCalculator.test.ts b/src/__tests__/ammCalculator.test.ts index b650da5..a4076da 100644 --- a/src/__tests__/ammCalculator.test.ts +++ b/src/__tests__/ammCalculator.test.ts @@ -64,6 +64,23 @@ describe("constant-product invariant preservation", () => { ); }); + it("enforces maxRatio without overflowing arbitrary-size reserves", () => { + const reserveIn = 10n ** 400n; + const pool = makePool(reserveIn.toString(), reserveIn.toString()); + + expect(() => + estimateSwapOutput(pool, (reserveIn * 2n).toString(), "XLM"), + ).toThrow(InsufficientLiquidityError); + + expect(() => + estimateSwapOutput(pool, (reserveIn / 4n).toString(), "XLM", 0.25), + ).not.toThrow(); + + expect(() => + estimateSwapOutput(pool, (reserveIn / 4n + 1n).toString(), "XLM", 0.25), + ).toThrow(InsufficientLiquidityError); + }); + it("returns 0 received and leaves reserves unchanged for a zero-amount swap", () => { const reserveIn = 1_000_000n; const reserveOut = 2_000_000n; diff --git a/src/ammCalculator.ts b/src/ammCalculator.ts index 4e5af3a..3de92d4 100644 --- a/src/ammCalculator.ts +++ b/src/ammCalculator.ts @@ -83,9 +83,10 @@ export function estimateSwapOutput( }; } - // Check against max ratio threshold - const ratio = Number(amountIn) / Number(reserveIn); - if (ratio > maxRatio) { + // Check against max ratio threshold without coercing arbitrary-size BigInts + // to Number. Both operands can exceed Number.MAX_VALUE, where Infinity / + // Infinity would otherwise become NaN and silently bypass this guard. + if (ratioExceedsLimit(amountIn, reserveIn, maxRatio)) { throw new InsufficientLiquidityError( `Input amount exceeds ${(maxRatio * 100).toFixed(0)}% of pool reserves`, inputReserve.amount, @@ -187,6 +188,28 @@ export function calculatePoolShare( // Internal helpers // --------------------------------------------------------------------------- +function ratioExceedsLimit(amount: bigint, reserve: bigint, limit: number): boolean { + if (!Number.isFinite(limit)) { + // Preserve the prior comparison semantics for non-finite caller values: + // NaN/+Infinity never reject, while -Infinity rejects every positive ratio. + return limit === -Infinity; + } + + const [coefficient, exponentText] = limit.toString().toLowerCase().split("e"); + const [integerPart, fractionalPart = ""] = coefficient!.split("."); + let numerator = BigInt(`${integerPart}${fractionalPart}`); + let denominator = 10n ** BigInt(fractionalPart.length); + const exponent = Number(exponentText ?? "0"); + + if (exponent > 0) { + numerator *= 10n ** BigInt(exponent); + } else if (exponent < 0) { + denominator *= 10n ** BigInt(-exponent); + } + + return amount * denominator > reserve * numerator; +} + function computeSpotPrice(reserveIn: bigint, reserveOut: bigint): string { // spotPrice = reserveOut / reserveIn as a decimal string if (reserveIn === 0n) return "0"; From 66c5ac12901888a1d4978c99f3a861b64f0350ea Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 03:43:33 -0400 Subject: [PATCH 10/16] ci: add full offline PR test gate (#14) Rejoin the exact hosted-green Unit Test workflow blob onto current main after the original carrier branch accumulated unrelated SDK changes. No semantic expansion. --- .github/workflows/unit-test.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 .github/workflows/unit-test.yml diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml new file mode 100644 index 0000000..2b5e430 --- /dev/null +++ b/.github/workflows/unit-test.yml @@ -0,0 +1,29 @@ +name: Unit Test + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: unit-test-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + unit-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Run full test suite + run: npm run test:all From 713009ee1f0a877f50587593ece9cb9d8a0bb76a Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 03:59:32 -0400 Subject: [PATCH 11/16] fix(amm): compose price-impact normalization on current main * fix(amm): compose price-impact scale normalization with BigInt-safe ratio guard * test(amm): cover integer/fractional price-impact scale normalization --- src/ammCalculator.ts | 11 +++++++---- test/ammCalculator.test.ts | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/ammCalculator.ts b/src/ammCalculator.ts index 3de92d4..abff1c1 100644 --- a/src/ammCalculator.ts +++ b/src/ammCalculator.ts @@ -236,16 +236,19 @@ function computePriceImpact(spotPrice: string, effectivePrice: string): string { const spot = parseDecimal(spotPrice); const effective = parseDecimal(effectivePrice); - + const spotScaled = spot.int * spot.scale + spot.frac; const effectiveScaled = effective.int * effective.scale + effective.frac; + const commonScale = spot.scale > effective.scale ? spot.scale : effective.scale; + const spotCommon = spotScaled * (commonScale / spot.scale); + const effectiveCommon = effectiveScaled * (commonScale / effective.scale); - if (spotScaled === 0n) return "0.00"; + if (spotCommon === 0n) return "0.00"; // (spot - effective) / spot * 100 with 4 decimal places of precision const SCALE = 10000n; - const numerator = (spotScaled - effectiveScaled) * SCALE * 100n; - const denominator = spotScaled; + const numerator = (spotCommon - effectiveCommon) * SCALE * 100n; + const denominator = spotCommon; if (numerator <= 0n) return "0.00"; diff --git a/test/ammCalculator.test.ts b/test/ammCalculator.test.ts index 82bde61..4fa72ac 100644 --- a/test/ammCalculator.test.ts +++ b/test/ammCalculator.test.ts @@ -45,6 +45,20 @@ describe("estimateSwapOutput", () => { expect(parseFloat(result.spotPrice)).toBeCloseTo(1.0); }); + it("normalizes integer and fractional prices before computing impact", () => { + const pool = makePool([ + { asset: ASSET_X, amount: "1000" }, + { asset: ASSET_Y, amount: "1000" }, + ]); + + const result = estimateSwapOutput(pool, "100", ASSET_X); + + expect(result.outputAmount).toBe("91"); + expect(result.spotPrice).toBe("1"); + expect(result.effectivePrice).toBe("0.91"); + expect(result.priceImpactPercent).toBe("9.00"); + }); + it("returns zero output and zero price impact for zero input", () => { const result = estimateSwapOutput(POOL, "0", ASSET_X); expect(result.outputAmount).toBe("0"); From b74f8ad7ba3e46f4bc2f2ff27bc7aff8ce44e786 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 04:05:20 -0400 Subject: [PATCH 12/16] fix(batch): rejoin concurrency bounds on current main Preserve the reviewed #4 InvoiceBatchProcessor concurrency-bound repair and its focused regression byte-for-byte on current main so the repository's newly landed Unit Test gate can validate the actual merge topology. --- src/__tests__/invoiceBatchProcessor.test.ts | 46 +++++++++++++++++++++ src/invoiceBatchProcessor.ts | 7 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/__tests__/invoiceBatchProcessor.test.ts b/src/__tests__/invoiceBatchProcessor.test.ts index 872f628..9853b1d 100644 --- a/src/__tests__/invoiceBatchProcessor.test.ts +++ b/src/__tests__/invoiceBatchProcessor.test.ts @@ -142,3 +142,49 @@ describe("InvoiceBatchProcessor – partial-failure handling", () => { expect(failed.every((r) => r.error === "network error")).toBe(true); }); }); + +describe("InvoiceBatchProcessor – concurrency configuration", () => { + it.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "rejects invalid maxConcurrent=%s before submitting", + async (maxConcurrent) => { + const submitPayment = vi.fn().mockResolvedValue({ txHash: "tx-inv1" }); + const processor = new InvoiceBatchProcessor( + { submitPayment } as InvoicePaymentSubmitter, + ); + + await expect( + processor.processAll(["inv1"], { + payer: "GPAYER", + amounts: { inv1: 1n }, + maxConcurrent, + }), + ).rejects.toThrow(new RangeError("maxConcurrent must be a positive integer")); + + expect(submitPayment).not.toHaveBeenCalled(); + }, + ); + + it("caps initial launch attempts at the number of invoices", async () => { + const submitPayment = vi + .fn() + .mockImplementation(async ({ invoiceId }: { invoiceId: string }) => ({ + txHash: `tx-${invoiceId}`, + })); + const processor = new InvoiceBatchProcessor( + { submitPayment } as InvoicePaymentSubmitter, + ); + + const { succeeded, failed } = await processor.processAll( + ["inv1", "inv2"], + { + payer: "GPAYER", + amounts: { inv1: 1n, inv2: 1n }, + maxConcurrent: Number.MAX_SAFE_INTEGER, + }, + ); + + expect(succeeded).toHaveLength(2); + expect(failed).toHaveLength(0); + expect(submitPayment).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/invoiceBatchProcessor.ts b/src/invoiceBatchProcessor.ts index e9c17a0..631eebd 100644 --- a/src/invoiceBatchProcessor.ts +++ b/src/invoiceBatchProcessor.ts @@ -76,6 +76,10 @@ export class InvoiceBatchProcessor { const maxConcurrent = config.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; const rateLimitPauseMs = config.rateLimitPauseMs ?? DEFAULT_RATE_LIMIT_PAUSE_MS; + if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) { + throw new RangeError("maxConcurrent must be a positive integer"); + } + let cursor = 0; let pausedUntil = 0; let slotSeq = 0; @@ -125,7 +129,8 @@ export class InvoiceBatchProcessor { ); }; - for (let i = 0; i < maxConcurrent; i++) launch(); + const initialLaunches = Math.min(maxConcurrent, invoiceIds.length); + for (let i = 0; i < initialLaunches; i++) launch(); while (inFlight.size > 0) { const { slot, result } = await Promise.race(inFlight.values()); From df36f5a5981f695e5d0353f5f4004d28d08d301f Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 04:20:54 -0400 Subject: [PATCH 13/16] ci: validate full publish suite on current main Require the repository's full test suite before release-triggered npm publication. Current-main integration successor to #13; preserves Ariadne-Z's reviewed workflow payload byte-for-byte. --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index fd63ce2..fad1e99 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -26,7 +26,7 @@ jobs: run: npm run build - name: Run tests - run: npm test + run: npm run test:all - name: Type check (strict) run: npx --no-install tsc --noEmit From e701d5c9c22d8ec02406fe3b0b672300b95b5525 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 05:03:10 -0400 Subject: [PATCH 14/16] fix(compression): normalize malformed base64url errors --- src/compression.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/compression.ts b/src/compression.ts index cfc64ee..f996a39 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -266,7 +266,16 @@ export function decompressMetadata(encoded: string): Record { ); } - const json = fromBase64Url(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 { From bc9946d953ea5b2d455ff80492cea0913a2d1da3 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 05:03:23 -0400 Subject: [PATCH 15/16] test(compression): cover malformed base64url decoder failures --- ...ression.metadata.invalid-base64url.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 test/compression.metadata.invalid-base64url.test.ts diff --git a/test/compression.metadata.invalid-base64url.test.ts b/test/compression.metadata.invalid-base64url.test.ts new file mode 100644 index 0000000..9e97952 --- /dev/null +++ b/test/compression.metadata.invalid-base64url.test.ts @@ -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); + } + }, + ); +}); From 405982808710ed487aab0f6350069605e2561a53 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sun, 13 Sep 2026 05:06:48 -0400 Subject: [PATCH 16/16] feat(compression): add metadata base64url helpers Clean successor for upstream #619, built directly from Stellar-split/split-sdk main. Includes the original metadata encoder/decoder, root exports, tests, browser-safe base64url path, and fail-closed normalization of malformed decoder inputs to SdkError(CONTRACT_REJECTED). --- src/compression.ts | 157 ++++++++++++++++ src/index.ts | 10 + ...ression.metadata.invalid-base64url.test.ts | 19 ++ test/compression.metadata.test.ts | 175 ++++++++++++++++++ 4 files changed, 361 insertions(+) create mode 100644 test/compression.metadata.invalid-base64url.test.ts create mode 100644 test/compression.metadata.test.ts diff --git a/src/compression.ts b/src/compression.ts index 4373003..f996a39 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,3 +1,4 @@ +import { SdkError, SdkErrorCode } from "./errors.js"; import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js"; export type CompressionAlgorithm = "gzip" | "deflate"; @@ -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, + 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 { + 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; +} diff --git a/src/index.ts b/src/index.ts index f9cb56b..73e4775 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/test/compression.metadata.invalid-base64url.test.ts b/test/compression.metadata.invalid-base64url.test.ts new file mode 100644 index 0000000..9e97952 --- /dev/null +++ b/test/compression.metadata.invalid-base64url.test.ts @@ -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); + } + }, + ); +}); diff --git a/test/compression.metadata.test.ts b/test/compression.metadata.test.ts new file mode 100644 index 0000000..11f9f35 --- /dev/null +++ b/test/compression.metadata.test.ts @@ -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 = {}; + 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]> = [ + ["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); + }); +});