From c09a1a09b63580f67f68c99aee955552127625b1 Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Thu, 20 Aug 2026 22:29:33 +0800 Subject: [PATCH 1/2] feat: add compressMetadata and decompressMetadata helpers (Closes #619) --- src/compression.ts | 71 +++++++++++++++++++++++++++++++++ src/index.ts | 6 +++ test/compressMetadata.test.ts | 74 +++++++++++++++++++++++++++++++++++ 3 files changed, 151 insertions(+) create mode 100644 test/compressMetadata.test.ts diff --git a/src/compression.ts b/src/compression.ts index 4373003..9aff12d 100644 --- a/src/compression.ts +++ b/src/compression.ts @@ -141,3 +141,74 @@ export function createCompressionResponseInterceptor(_config: CompressionConfig) }; }; } + +// --------------------------------------------------------------------------- +// Metadata compression helpers (JSON + base64url round-trip) +// --------------------------------------------------------------------------- + +import { StellarSplitError } from "./errors.js"; + +const DEFAULT_MAX_METADATA_BYTES = 512; + +/** + * Serialize a metadata object to JSON and encode as base64url (no padding `=`). + * + * @param obj - Arbitrary JSON-serialisable metadata object. + * @param maxBytes - Maximum allowed length for the encoded string (default 512). + * @returns Base64url-encoded string without padding. + * @throws {StellarSplitError} with code `CONTRACT_REJECTED` if the encoded + * string exceeds `maxBytes`. + */ +export function compressMetadata( + obj: Record, + maxBytes: number = DEFAULT_MAX_METADATA_BYTES, +): string { + const json = JSON.stringify(obj); + const encoded = Buffer.from(json).toString("base64url"); + if (encoded.length > maxBytes) { + throw new StellarSplitError( + `Compressed metadata exceeds ${maxBytes} bytes (${encoded.length})`, + "CONTRACT_REJECTED", + ); + } + return encoded; +} + +/** + * Decode a base64url-encoded string and parse it back to a metadata object. + * + * @param encoded - Base64url-encoded string (padding optional). + * @returns The deserialised metadata object. + * @throws {StellarSplitError} with code `CONTRACT_REJECTED` if the input is + * not valid base64url or not valid JSON. + */ +export function decompressMetadata(encoded: string): Record { + // Validate that input contains only valid base64url characters (A-Z, a-z, 0-9, -, _) + if (!/^[A-Za-z0-9_-]+$/.test(encoded)) { + throw new StellarSplitError( + "Invalid base64url encoding: contains invalid characters", + "CONTRACT_REJECTED", + ); + } + let json: string; + try { + json = Buffer.from(encoded, "base64url").toString("utf-8"); + } catch { + throw new StellarSplitError( + "Invalid base64url encoding", + "CONTRACT_REJECTED", + ); + } + try { + const obj = JSON.parse(json); + if (typeof obj !== "object" || obj === null || Array.isArray(obj)) { + throw new Error("Not a plain object"); + } + return obj as Record; + } catch { + throw new StellarSplitError( + "Invalid JSON in metadata", + "CONTRACT_REJECTED", + ); + } +} diff --git a/src/index.ts b/src/index.ts index 2fe2b4a..22ac7d2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1366,3 +1366,9 @@ export type { SubmitTransactionOptions, SubmitServer, } from "./transaction/submit.js"; + +// --------------------------------------------------------------------------- +// #619 — Metadata compression helpers (JSON + base64url round-trip) +// --------------------------------------------------------------------------- + +export { compressMetadata, decompressMetadata } from "./compression.js"; diff --git a/test/compressMetadata.test.ts b/test/compressMetadata.test.ts new file mode 100644 index 0000000..c2809ce --- /dev/null +++ b/test/compressMetadata.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { compressMetadata, decompressMetadata } from "../src/compression.js"; +import { StellarSplitError } from "../src/errors.js"; + +function expectContractRejected(fn: () => unknown): void { + try { + fn(); + expect.unreachable("expected a throw"); + } catch (err) { + expect(err).toBeInstanceOf(StellarSplitError); + const stellarErr = err as StellarSplitError; + expect(stellarErr.code).toBe("CONTRACT_REJECTED"); + } +} + +describe("compressMetadata / decompressMetadata", () => { + it("round-trips a simple object", () => { + const obj = { invoiceId: "inv_123", amount: 1000, currency: "USDC" }; + const encoded = compressMetadata(obj); + const decoded = decompressMetadata(encoded); + expect(decoded).toEqual(obj); + }); + + it("round-trips an empty object", () => { + const obj = {}; + const encoded = compressMetadata(obj); + const decoded = decompressMetadata(encoded); + expect(decoded).toEqual(obj); + }); + + it("produces base64url without padding", () => { + const encoded = compressMetadata({ a: 1 }); + // base64url has no '=' padding + expect(encoded).not.toContain("="); + }); + + it("throws CONTRACT_REJECTED on oversized payload", () => { + const large = { data: "x".repeat(600) }; + expectContractRejected(() => compressMetadata(large, 10)); + }); + + it("throws CONTRACT_REJECTED on invalid base64url input", () => { + expectContractRejected(() => decompressMetadata("!!!invalid-base64!!!")); + }); + + it("throws CONTRACT_REJECTED on non-base64 input", () => { + // Contains '$' which is not a valid base64url character + expectContractRejected(() => decompressMetadata("not$base64$!")); + }); + + it("throws CONTRACT_REJECTED on invalid JSON (after valid base64)", () => { + // Buffer.from("not-json").toString("base64url") -> valid base64, not JSON + const encoded = Buffer.from("not-json").toString("base64url"); + expectContractRejected(() => decompressMetadata(encoded)); + }); + + it("throws CONTRACT_REJECTED on JSON array (not a plain object)", () => { + const encoded = Buffer.from(JSON.stringify([1, 2, 3])).toString("base64url"); + expectContractRejected(() => decompressMetadata(encoded)); + }); + + it("throws CONTRACT_REJECTED on JSON null", () => { + const encoded = Buffer.from("null").toString("base64url"); + expectContractRejected(() => decompressMetadata(encoded)); + }); + + it("uses default maxBytes of 512", () => { + const justUnder = { data: "x".repeat(300) }; + expect(() => compressMetadata(justUnder)).not.toThrow(); + // 512 bytes is the default; a very large object should throw + const over = { data: "x".repeat(2000) }; + expectContractRejected(() => compressMetadata(over)); + }); +}); \ No newline at end of file From 842f96b98246eb4aa08a5e585c84f1674aa7166a Mon Sep 17 00:00:00 2001 From: waterWang <672684719@qq.com> Date: Thu, 20 Aug 2026 22:35:45 +0800 Subject: [PATCH 2/2] feat: add estimateFeeForAmount, buildPaymentMemo, parsePaymentMemo helpers (Closes #615, #610) --- src/feeEstimator.ts | 43 ++++++++++++++++++++ src/index.ts | 13 +++++++ src/memoBuilder.ts | 65 +++++++++++++++++++++++++++++++ test/estimateFeeForAmount.test.ts | 49 +++++++++++++++++++++++ test/paymentMemo.test.ts | 45 +++++++++++++++++++++ 5 files changed, 215 insertions(+) create mode 100644 test/estimateFeeForAmount.test.ts create mode 100644 test/paymentMemo.test.ts diff --git a/src/feeEstimator.ts b/src/feeEstimator.ts index e104da9..780b75e 100644 --- a/src/feeEstimator.ts +++ b/src/feeEstimator.ts @@ -10,6 +10,7 @@ export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSu * `./feeSurgeDetector.js`. */ +import { StellarSplitError } from "./errors.js"; import { Account, TransactionBuilder, @@ -23,6 +24,48 @@ export interface FeeEstimate { total: string; } +/** Fee stats used by {@link estimateFeeForAmount}. */ +export interface FeeStats { + /** Base fee (stroops per operation). */ + baseFee: bigint; + /** 50th-percentile (median) fee observed (stroops per operation). */ + p50Fee: bigint; + /** 99th-percentile fee observed (stroops per operation). */ + p99Fee: bigint; +} + +/** + * Estimate the absolute fee (in stroops) and percentage for a payment amount. + * + * The estimated fee is derived from the base fee in `feeStats` and never + * converted to a fractional number — it stays a `bigint` until the caller + * computes the percentage. + * + * @param amount - Payment amount in stroops. + * @param feeStats - Current on-chain fee statistics. + * @returns Estimated fee in stroops, the fee as a percentage of the amount, + * and the total (amount + fee). + * @throws {StellarSplitError} with code `INVALID_RECIPIENT` when `amount` is negative. + */ +export function estimateFeeForAmount( + amount: bigint, + feeStats: FeeStats, +): { feeLumens: bigint; feePercent: number; totalWithFee: bigint } { + if (amount < 0n) { + throw new StellarSplitError( + "Amount cannot be negative", + "INVALID_RECIPIENT", + ); + } + + const feeLumens = feeStats.baseFee; + const totalWithFee = amount + feeLumens; + + const feePercent = amount === 0n ? 0 : (Number(feeLumens) / Number(amount)) * 100; + + return { feeLumens, feePercent, totalWithFee }; +} + export interface FeeEstimateError { error: string; baseFee: string; diff --git a/src/index.ts b/src/index.ts index 22ac7d2..49dd57e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1372,3 +1372,16 @@ export type { // --------------------------------------------------------------------------- export { compressMetadata, decompressMetadata } from "./compression.js"; + +// --------------------------------------------------------------------------- +// #610 — Invoice payment memo builder and parser +// --------------------------------------------------------------------------- + +export { buildPaymentMemo, parsePaymentMemo } from "./memoBuilder.js"; + +// --------------------------------------------------------------------------- +// #615 — Fee estimation for a payment amount +// --------------------------------------------------------------------------- + +export { estimateFeeForAmount } from "./feeEstimator.js"; +export type { FeeStats } from "./feeEstimator.js"; diff --git a/src/memoBuilder.ts b/src/memoBuilder.ts index ed7b1c3..43cb33d 100644 --- a/src/memoBuilder.ts +++ b/src/memoBuilder.ts @@ -165,3 +165,68 @@ export function isStellarSplitMemo(memo: Memo): boolean { const value = memo.value as string; return typeof value === "string" && value.startsWith(MEMO_PREFIX); } + +// --------------------------------------------------------------------------- +// #610 — Simple invoice payment memo builder and parser +// --------------------------------------------------------------------------- + +const PAYMENT_MEMO_PREFIX = "split:"; +const MAX_TEXT_MEMO_BYTES = 28; + +/** + * Build a canonical text memo string for an invoice payment. + * + * Format: `split:{invoiceId}` base case, or `split:{invoiceId}:t{tranche}` + * when a tranche number is provided. The result is truncated to 28 bytes + * (Stellar text memo limit) — the truncation avoids cutting a multi-byte UTF-8 + * character in the middle. + * + * @param invoiceId - The invoice ID to encode. + * @param opts - Optional tranche number. + * @returns A string no longer than 28 bytes when UTF-8 encoded. + */ +export function buildPaymentMemo( + invoiceId: string, + opts?: { tranche?: number }, +): string { + let memo = opts?.tranche !== undefined + ? `${PAYMENT_MEMO_PREFIX}${invoiceId}:t${opts.tranche}` + : `${PAYMENT_MEMO_PREFIX}${invoiceId}`; + + // Truncate to 28 bytes, avoiding mid-UTF-8-character cut + while (Buffer.byteLength(memo, "utf8") > MAX_TEXT_MEMO_BYTES) { + // Remove the last character (handles surrogate pairs as a unit) + const lastChar = memo.codePointAt(memo.length - 1); + memo = memo.slice(0, -(lastChar !== undefined && memo.length > 1 && memo.codePointAt(memo.length - 2)! >= 0xd800 && lastChar <= 0xdfff ? 2 : 1)); + } + + return memo; +} + +/** + * Parse a payment memo string back into its components. + * + * @param memo - The memo string to parse. + * @returns An object with `invoiceId` and optional `tranche`, or `null` if the + * memo does not start with the `split:` prefix. + */ +export function parsePaymentMemo( + memo: string, +): { invoiceId: string; tranche?: number } | null { + if (!memo.startsWith(PAYMENT_MEMO_PREFIX)) { + return null; + } + + const payload = memo.slice(PAYMENT_MEMO_PREFIX.length); // e.g. "inv_123:t1" + + // Check for tranche pattern: ":t" at the end + const trancheMatch = payload.match(/^(.+?):t(\d+)$/); + if (trancheMatch) { + return { + invoiceId: trancheMatch[1]!, + tranche: parseInt(trancheMatch[2]!, 10), + }; + } + + return { invoiceId: payload }; +} diff --git a/test/estimateFeeForAmount.test.ts b/test/estimateFeeForAmount.test.ts new file mode 100644 index 0000000..5d5e94c --- /dev/null +++ b/test/estimateFeeForAmount.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { estimateFeeForAmount, type FeeStats } from "../src/feeEstimator.js"; +import { StellarSplitError } from "../src/errors.js"; + +const defaultStats: FeeStats = { + baseFee: 100n, + p50Fee: 100n, + p99Fee: 250n, +}; + +describe("estimateFeeForAmount", () => { + it("calculates fee and total for a known amount", () => { + const result = estimateFeeForAmount(10_000n, defaultStats); + expect(result.feeLumens).toBe(100n); + expect(result.totalWithFee).toBe(10_100n); + expect(result.feePercent).toBeCloseTo(1, 5); // 100/10000*100 = 1% + }); + + it("keeps feeLumens as bigint", () => { + const result = estimateFeeForAmount(10n, defaultStats); + expect(typeof result.feeLumens).toBe("bigint"); + expect(typeof result.totalWithFee).toBe("bigint"); + }); + + it("returns 0 percent for zero amount", () => { + const result = estimateFeeForAmount(0n, defaultStats); + expect(result.feeLumens).toBe(100n); + expect(result.totalWithFee).toBe(100n); + expect(result.feePercent).toBe(0); + }); + + it("throws INVALID_RECIPIENT for negative amount", () => { + try { + estimateFeeForAmount(-1n, defaultStats); + expect.unreachable("expected throw"); + } catch (err) { + expect(err).toBeInstanceOf(StellarSplitError); + expect((err as StellarSplitError).code).toBe("INVALID_RECIPIENT"); + } + }); + + it("handles a larger base fee", () => { + const stats: FeeStats = { baseFee: 500n, p50Fee: 400n, p99Fee: 1500n }; + const result = estimateFeeForAmount(50_000n, stats); + expect(result.feeLumens).toBe(500n); + expect(result.totalWithFee).toBe(50_500n); + expect(result.feePercent).toBe(1); + }); +}); \ No newline at end of file diff --git a/test/paymentMemo.test.ts b/test/paymentMemo.test.ts new file mode 100644 index 0000000..057021d --- /dev/null +++ b/test/paymentMemo.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { buildPaymentMemo, parsePaymentMemo } from "../src/memoBuilder.js"; + +describe("buildPaymentMemo / parsePaymentMemo", () => { + it("builds base format for invoice ID", () => { + expect(buildPaymentMemo("inv_123")).toBe("split:inv_123"); + }); + + it("builds tranche format", () => { + expect(buildPaymentMemo("inv_123", { tranche: 2 })).toBe("split:inv_123:t2"); + }); + + it("truncates to 28 bytes for long invoice IDs", () => { + const memo = buildPaymentMemo("invoice-very-long-id-1234567890-abcdef"); + expect(Buffer.byteLength(memo, "utf8")).toBeLessThanOrEqual(28); + }); + + it("truncates without cutting a multi-byte UTF-8 character", () => { + // Multi-byte emoji makes byte length exceed char length + const memo = buildPaymentMemo("inv-😀😀😀😀😀😀😀😀😀😀-long-id"); + expect(Buffer.byteLength(memo, "utf8")).toBeLessThanOrEqual(28); + // Last chunk decodes without replacement characters + const lastChar = memo[memo.length - 1]; + expect(lastChar).not.toBe("\uFFFD"); + }); + + it("round-trips base case", () => { + const memo = buildPaymentMemo("inv_42"); + expect(parsePaymentMemo(memo)).toEqual({ invoiceId: "inv_42" }); + }); + + it("round-trips tranche case", () => { + const memo = buildPaymentMemo("inv_42", { tranche: 3 }); + expect(parsePaymentMemo(memo)).toEqual({ invoiceId: "inv_42", tranche: 3 }); + }); + + it("returns null for non-split memo", () => { + expect(parsePaymentMemo("SS:v1:42:ABCDEFGH")).toBeNull(); + expect(parsePaymentMemo("random-memo")).toBeNull(); + }); + + it("returns null for empty string", () => { + expect(parsePaymentMemo("")).toBeNull(); + }); +}); \ No newline at end of file