diff --git a/src/compression.ts b/src/compression.ts index 4373003..91ebd11 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -1,4 +1,5 @@ import type { RequestInterceptor, ResponseInterceptor } from "./interceptors.js"; +import { SdkError, SdkErrorCode } from "./errors.js"; export type CompressionAlgorithm = "gzip" | "deflate"; export type CompressionPayload = string | Uint8Array; @@ -141,3 +142,77 @@ export function createCompressionResponseInterceptor(_config: CompressionConfig) }; }; } + +// --------------------------------------------------------------------------- +// #619 — compressMetadata / decompressMetadata +// +// JSON + base64url round-trip helpers for storing invoice metadata compactly +// (e.g. in a Stellar transaction memo or an IPFS payload). +// --------------------------------------------------------------------------- + +/** Default maximum byte size of the encoded base64url string. */ +const DEFAULT_MAX_METADATA_BYTES = 512; + +/** + * Serializes a metadata object to JSON and encodes it as base64url (no padding). + * + * @throws {SdkError} with code `CONTRACT_REJECTED` if the resulting base64url + * string exceeds `maxBytes` (default 512). + */ +export function compressMetadata( + obj: Record, + maxBytes: number = DEFAULT_MAX_METADATA_BYTES, +): string { + const json = JSON.stringify(obj); + const encoded = Buffer.from(json, "utf8").toString("base64url"); + + if (Buffer.byteLength(encoded, "utf8") > maxBytes) { + throw new SdkError( + `Compressed metadata exceeds maxBytes (${maxBytes}): got ${Buffer.byteLength(encoded, "utf8")} bytes`, + SdkErrorCode.CONTRACT_REJECTED, + { actualBytes: Buffer.byteLength(encoded, "utf8"), maxBytes }, + ); + } + + return encoded; +} + +/** + * Decodes a base64url string and parses the JSON inside it. + * + * @throws {SdkError} with code `CONTRACT_REJECTED` on invalid base64 input + * or invalid JSON. + */ +export function decompressMetadata(encoded: string): Record { + let json: string; + try { + json = Buffer.from(encoded, "base64url").toString("utf8"); + } catch { + throw new SdkError( + "Failed to decode base64url metadata", + SdkErrorCode.CONTRACT_REJECTED, + { encoded }, + ); + } + + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + throw new SdkError( + "Decompressed metadata is not valid JSON", + SdkErrorCode.CONTRACT_REJECTED, + { decoded: json.slice(0, 100) }, + ); + } + + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new SdkError( + "Decompressed metadata is not a JSON object", + SdkErrorCode.CONTRACT_REJECTED, + { parsedType: Array.isArray(parsed) ? "array" : typeof parsed }, + ); + } + + return parsed as Record; +} diff --git a/src/index.ts b/src/index.ts index ebf7e30..3cc9c6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1380,3 +1380,10 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #619 — compressMetadata / decompressMetadata +// JSON + base64url round-trip helpers for compact invoice metadata storage +// --------------------------------------------------------------------------- + +export { compressMetadata, decompressMetadata } from "./compression.js"; diff --git a/test/metadataCompression.test.ts b/test/metadataCompression.test.ts new file mode 100644 index 0000000..8f1394f --- /dev/null +++ b/test/metadataCompression.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from "vitest"; +import { + compressMetadata, + decompressMetadata, +} from "../src/compression.js"; +import { SdkError, SdkErrorCode } from "../src/errors.js"; + +describe("compressMetadata", () => { + it("serializes an object to base64url (no padding)", () => { + const obj = { amount: "100", asset: "USDC" }; + const encoded = compressMetadata(obj); + // base64url should never contain padding '=' + expect(encoded).not.toContain("="); + expect(encoded).not.toContain("+"); + expect(encoded).not.toContain("/"); + }); + + it("round-trips a simple object", () => { + const obj = { amount: "100", asset: "USDC", memo: "test payment" }; + const encoded = compressMetadata(obj); + const decoded = decompressMetadata(encoded); + expect(decoded).toEqual(obj); + }); + + it("round-trips an empty object", () => { + const obj: Record = {}; + const encoded = compressMetadata(obj); + const decoded = decompressMetadata(encoded); + expect(decoded).toEqual({}); + }); + + it("round-trips nested objects and arrays", () => { + const obj = { + level1: { level2: { level3: "deep" } }, + items: [1, 2, 3], + flag: true, + nil: null, + }; + const encoded = compressMetadata(obj); + const decoded = decompressMetadata(encoded); + expect(decoded).toEqual(obj); + }); + + it("throws SdkError CONTRACT_REJECTED when exceeding maxBytes", () => { + const obj = { data: "x".repeat(1000) }; + expect(() => compressMetadata(obj, 100)).toThrow(SdkError); + try { + compressMetadata(obj, 100); + } catch (e) { + expect(e).toBeInstanceOf(SdkError); + expect((e as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED); + } + }); + + it("respects custom maxBytes argument", () => { + const obj = { short: "ok" }; + // Should fit within a generous limit + expect(() => compressMetadata(obj, 512)).not.toThrow(); + // Should fail with a tiny limit + expect(() => compressMetadata(obj, 1)).toThrow(SdkError); + }); +}); + +describe("decompressMetadata", () => { + it("throws SdkError CONTRACT_REJECTED on invalid base64 input", () => { + // Not valid base64url — contains characters outside the alphabet + expect(() => decompressMetadata("!!!notbase64!!!")).toThrow(SdkError); + try { + decompressMetadata("!!!notbase64!!!"); + } catch (e) { + expect(e).toBeInstanceOf(SdkError); + expect((e as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED); + } + }); + + it("throws SdkError CONTRACT_REJECTED on valid base64 but invalid JSON", () => { + // "not json" encoded as base64url + const encoded = Buffer.from("not json").toString("base64url"); + expect(() => decompressMetadata(encoded)).toThrow(SdkError); + try { + decompressMetadata(encoded); + } catch (e) { + expect(e).toBeInstanceOf(SdkError); + expect((e as SdkError).code).toBe(SdkErrorCode.CONTRACT_REJECTED); + } + }); + + it("throws SdkError CONTRACT_REJECTED when decoded JSON is not an object", () => { + // JSON array + const encoded = Buffer.from("[1, 2, 3]").toString("base64url"); + expect(() => decompressMetadata(encoded)).toThrow(SdkError); + // JSON string + const strEncoded = Buffer.from('"hello"').toString("base64url"); + expect(() => decompressMetadata(strEncoded)).toThrow(SdkError); + // JSON number + const numEncoded = Buffer.from("42").toString("base64url"); + expect(() => decompressMetadata(numEncoded)).toThrow(SdkError); + }); + + it("decodes a previously compressed object correctly", () => { + const original = { invoiceId: "inv_123", total: "500.00", currency: "USDC" }; + const encoded = compressMetadata(original); + const decoded = decompressMetadata(encoded); + expect(decoded).toEqual(original); + }); +});