Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
75 changes: 75 additions & 0 deletions src/compression.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string, unknown>,
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<string, unknown> {
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<string, unknown>;
}
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
106 changes: 106 additions & 0 deletions test/metadataCompression.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
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);
});
});