Skip to content
Open
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
71 changes: 71 additions & 0 deletions src/compression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
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<string, unknown> {
// 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<string, unknown>;
} catch {
throw new StellarSplitError(
"Invalid JSON in metadata",
"CONTRACT_REJECTED",
);
}
}
43 changes: 43 additions & 0 deletions src/feeEstimator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export type { FeeSurgeConfig, FeeRecommendation, CongestionLevel } from "./feeSu
* `./feeSurgeDetector.js`.
*/

import { StellarSplitError } from "./errors.js";
import {
Account,
TransactionBuilder,
Expand All @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1366,3 +1366,22 @@ export type {
SubmitTransactionOptions,
SubmitServer,
} from "./transaction/submit.js";

// ---------------------------------------------------------------------------
// #619 — Metadata compression helpers (JSON + base64url round-trip)
// ---------------------------------------------------------------------------

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";
65 changes: 65 additions & 0 deletions src/memoBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NUMBER>" at the end
const trancheMatch = payload.match(/^(.+?):t(\d+)$/);
if (trancheMatch) {
return {
invoiceId: trancheMatch[1]!,
tranche: parseInt(trancheMatch[2]!, 10),
};
}

return { invoiceId: payload };
}
74 changes: 74 additions & 0 deletions test/compressMetadata.test.ts
Original file line number Diff line number Diff line change
@@ -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));
});
});
49 changes: 49 additions & 0 deletions test/estimateFeeForAmount.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 45 additions & 0 deletions test/paymentMemo.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});