diff --git a/src/index.ts b/src/index.ts index ef5f0c0..f0d3794 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ import type { ExportFormat } from "./export.js"; export { StellarSplitClient } from "./client.js"; export { FinalityChecker } from "./finalityChecker.js"; +export { buildPaymentMemo, parsePaymentMemo } from "./memoBuilder.js"; export type { StellarSplitClientConfig, NetworkConfig, diff --git a/src/memoBuilder.ts b/src/memoBuilder.ts index ed7b1c3..67bfc27 100644 --- a/src/memoBuilder.ts +++ b/src/memoBuilder.ts @@ -1,167 +1,85 @@ /** - * Structured memo builder for StellarSplit invoice payments. + * Helpers for building and parsing Stellar transaction memos used on invoice + * payment transactions. * - * Encodes invoice ID, split protocol version, and payer identity into a - * canonical memo format that fits within Stellar's 28-byte text memo limit. - * - * Format: `SS:v{version}:{invoiceId}:{payerSuffix}` - * - "SS:" prefix (3 bytes) identifies StellarSplit memos - * - `v{version}` – split protocol version - * - `{invoiceId}` – the full invoice ID - * - `{payerSuffix}` – last 8 characters of the payer G-address - * - Total: 3 + len(version) + 1 + len(invoiceId) + 1 + 8 ≤ 28 bytes + * Stellar memo text is limited to 28 bytes (UTF-8). `buildPaymentMemo` + * truncates at the last full character boundary that keeps the result within + * that limit. `parsePaymentMemo` handles truncated memos gracefully: if the + * tranche suffix was cut off the tranche is simply absent from the result; if + * the invoiceId was itself truncated the truncated value is returned. */ -import { Memo } from "@stellar/stellar-sdk"; -import type { ParsedMemo, SplitConfig } from "./types.js"; - -/** Magic prefix identifying StellarSplit-encoded memos. */ -export const MEMO_PREFIX = "SS:"; - -/** Maximum length of a Stellar text memo in bytes. */ -const MAX_MEMO_BYTES = 28; - -/** Number of trailing payer-address characters stored in the memo. */ -const PAYER_SUFFIX_LENGTH = 8; +const MEMO_PREFIX = "split:"; +const MEMO_MAX_BYTES = 28; /** - * Build a canonical text memo encoding invoice ID, split version, and payer - * identity for use with {@link TransactionBuilder.addMemo}. - * - * @param invoiceId - The invoice ID to encode. - * @param config - Split configuration containing the protocol version. - * @param payerAddress - Stellar G-address of the payer. - * @returns A {@link Memo} instance suitable for transaction attachment. - * @throws If the encoded memo exceeds Stellar's 28-byte text memo limit. + * Truncate `str` so that Buffer.byteLength(result, 'utf8') <= maxBytes, + * never cutting in the middle of a multi-byte UTF-8 sequence. */ -export function buildMemo( - invoiceId: string, - config: SplitConfig, - payerAddress: string, -): Memo { - if (!payerAddress || payerAddress.length < PAYER_SUFFIX_LENGTH) { - throw new Error( - `Payer address must be at least ${PAYER_SUFFIX_LENGTH} characters`, - ); - } - - const payerSuffix = payerAddress.slice(-PAYER_SUFFIX_LENGTH); - const memo = `${MEMO_PREFIX}v${config.version}:${invoiceId}:${payerSuffix}`; - - const encoder = new TextEncoder(); - const bytes = encoder.encode(memo); - if (bytes.length > MAX_MEMO_BYTES) { - throw new Error( - `Memo exceeds ${MAX_MEMO_BYTES} bytes (${bytes.length} bytes): "${memo}"`, - ); +function truncateToBytes(str: string, maxBytes: number): string { + if (Buffer.byteLength(str, "utf8") <= maxBytes) return str; + // Walk character-by-character (handles surrogate pairs via codePointAt) + let bytes = 0; + let i = 0; + while (i < str.length) { + const cp = str.codePointAt(i)!; + // Determine byte width of this code point in UTF-8 + const charBytes = cp > 0xffff ? 4 : cp > 0x7ff ? 3 : cp > 0x7f ? 2 : 1; + if (bytes + charBytes > maxBytes) break; + bytes += charBytes; + i += cp > 0xffff ? 2 : 1; // surrogate pairs occupy 2 JS chars } - - return Memo.text(memo); + return str.slice(0, i); } /** - * Build a {@link Memo.hash} from the structured invoice payment data. + * Build a Stellar memo string for a split payment transaction. * - * Uses the first 32 bytes of the canonical UTF-8 encoding as the hash - * memo value. Memo.hash allows up to 32 bytes and is useful when the - * text representation would exceed the 28-byte limit. + * Format: `split:{invoiceId}` or `split:{invoiceId}:t{tranche}` * - * @param invoiceId - The invoice ID to encode. - * @param config - Split configuration containing the protocol version. - * @param payerAddress - Stellar G-address of the payer. - * @returns A Memo.hash instance. + * The result is guaranteed to be ≤ 28 bytes (UTF-8). If the full string + * exceeds 28 bytes it is truncated at the last full character boundary. */ -export function buildHashMemo( +export function buildPaymentMemo( invoiceId: string, - config: SplitConfig, - payerAddress: string, -): Memo { - const encoder = new TextEncoder(); - const data = `${MEMO_PREFIX}v${config.version}:${invoiceId}:${payerAddress}`; - const encoded = encoder.encode(data); - // Take up to 32 bytes for the hash memo - const hashBuffer = new Uint8Array(32); - hashBuffer.set(encoded.slice(0, Math.min(encoded.length, 32))); - return Memo.hash(Buffer.from(hashBuffer)); -} - -/** - * Build a {@link Memo.id} from a numeric invoice ID. - * - * Memo.id stores a uint64 identifier directly on the ledger. This is - * the most space-efficient option when only the invoice ID is needed. - * - * @param invoiceId - Numeric invoice ID (must fit in uint64). - * @returns A Memo.id instance. - */ -export function buildIdMemo(invoiceId: string | number): Memo { - const id = BigInt(invoiceId); - return Memo.id(id.toString()); + opts?: { tranche?: number } +): string { + const base = + opts?.tranche !== undefined + ? `${MEMO_PREFIX}${invoiceId}:t${opts.tranche}` + : `${MEMO_PREFIX}${invoiceId}`; + return truncateToBytes(base, MEMO_MAX_BYTES); } /** - * Parse a Stellar memo back into its structured components. + * Parse a Stellar memo produced by `buildPaymentMemo`. * - * Attempts to extract invoice ID, version, and payer suffix from the - * canonical `SS:v{version}:{invoiceId}:{payerSuffix}` format. + * Returns `null` for any memo that does not start with the `split:` prefix. * - * @param memo - The Stellar memo to parse. - * @returns A {@link ParsedMemo} with extracted fields. - * @throws If the memo is not a text memo or does not match the expected format. + * Edge-case behaviour for truncated memos: + * - If truncation removed the entire `:t{tranche}` suffix the result has no + * `tranche` field. + * - If truncation removed only part of the `:t{tranche}` suffix (e.g. cut + * inside the digits) the tranche field is omitted and the invoiceId is + * returned as-is up to the last `:t` boundary. + * - If truncation cut into the invoiceId itself the truncated invoiceId is + * returned without a tranche. */ -export function parseMemo(memo: Memo): ParsedMemo { - if (memo.type !== "text") { - throw new Error( - `Cannot parse memo of type "${memo.type}": only text memos are supported`, - ); +export function parsePaymentMemo( + memo: string +): { invoiceId: string; tranche?: number } | null { + if (!memo.startsWith(MEMO_PREFIX)) return null; + + const body = memo.slice(MEMO_PREFIX.length); // everything after "split:" + + // Look for the tranche separator ":t" followed by digits + const trancheMatch = body.match(/^(.*):t(\d+)$/); + if (trancheMatch) { + const invoiceId = trancheMatch[1]!; + const tranche = parseInt(trancheMatch[2]!, 10); + return { invoiceId, tranche }; } - const value = memo.value as string; - if (!value || !value.startsWith(MEMO_PREFIX)) { - throw new Error( - `Memo does not start with expected prefix "${MEMO_PREFIX}": "${value}"`, - ); - } - - const payload = value.slice(MEMO_PREFIX.length); // e.g. "v1:42:ABCDEFGH" - const versionMatch = payload.match(/^v(\d+):/); - if (!versionMatch) { - throw new Error(`Invalid memo format: missing version in "${value}"`); - } - - const version = parseInt(versionMatch[1]!, 10); - const afterVersion = payload.slice(versionMatch[0].length); // e.g. "42:ABCDEFGH" - - const lastColon = afterVersion.lastIndexOf(":"); - if (lastColon < 0) { - throw new Error(`Invalid memo format: missing payer suffix in "${value}"`); - } - - const invoiceId = afterVersion.slice(0, lastColon); - const payerId = afterVersion.slice(lastColon + 1); - - if (!invoiceId) { - throw new Error(`Invalid memo format: empty invoice ID in "${value}"`); - } - - if (payerId.length !== PAYER_SUFFIX_LENGTH) { - throw new Error( - `Invalid memo format: payer suffix must be ${PAYER_SUFFIX_LENGTH} characters in "${value}"`, - ); - } - - return { invoiceId, version, payerId }; -} - -/** - * Check whether a Memo matches the StellarSplit canonical format - * without throwing. - * - * @param memo - The memo to check. - * @returns True if the memo is a text memo starting with the "SS:" prefix. - */ -export function isStellarSplitMemo(memo: Memo): boolean { - if (memo.type !== "text") return false; - const value = memo.value as string; - return typeof value === "string" && value.startsWith(MEMO_PREFIX); + // No tranche — plain invoiceId (may be truncated, but we return it as-is) + return { invoiceId: body }; } diff --git a/test/memoBuilder.test.ts b/test/memoBuilder.test.ts index e879bd7..9535568 100644 --- a/test/memoBuilder.test.ts +++ b/test/memoBuilder.test.ts @@ -1,134 +1,119 @@ import { describe, it, expect } from "vitest"; -import { Memo } from "@stellar/stellar-sdk"; -import { - buildMemo, - buildHashMemo, - buildIdMemo, - parseMemo, - isStellarSplitMemo, - MEMO_PREFIX, -} from "../src/memoBuilder.js"; -import type { ParsedMemo, SplitConfig } from "../src/types.js"; +import { buildPaymentMemo, parsePaymentMemo } from "../src/memoBuilder.js"; -const CONFIG: SplitConfig = { version: 1 }; -const PAYER = "GABCDEFGHIJKLMNOPQRSTUVWXYZABCDEFGHIJKLMNOPQRSTUVWXYZABCD"; -const INVOICE_ID = "42"; - -describe("buildMemo", () => { - it("builds a text memo with the canonical SS: prefix", () => { - const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); - expect(memo.type).toBe("text"); - const text = memo.value as string; - expect(text).toMatch(/^SS:v1:42:[A-Z0-9]{8}$/); - }); - - it("fits within the 28-byte text memo limit", () => { - const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); - const bytes = new TextEncoder().encode(memo.value as string); - expect(bytes.length).toBeLessThanOrEqual(28); +describe("buildPaymentMemo", () => { + it("returns split:{invoiceId} for base format", () => { + expect(buildPaymentMemo("inv-123")).toBe("split:inv-123"); }); - it("includes the payer address suffix", () => { - const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); - const text = memo.value as string; - // Last 8 chars of PAYER - const expectedSuffix = PAYER.slice(-8); - expect(text).toContain(expectedSuffix); + it("returns split:{invoiceId}:t{tranche} when tranche is provided", () => { + expect(buildPaymentMemo("inv-123", { tranche: 2 })).toBe("split:inv-123:t2"); }); - it("encodes the split config version", () => { - const memo1 = buildMemo(INVOICE_ID, { version: 1 }, PAYER); - const memo2 = buildMemo(INVOICE_ID, { version: 3 }, PAYER); - expect((memo1.value as string)).toContain("v1:"); - expect((memo2.value as string)).toContain("v3:"); + it("truncates to 28 bytes when the full string exceeds the limit (ASCII)", () => { + // "split:" = 6 bytes, so invoiceId can be at most 22 chars before truncation + const longId = "a".repeat(30); // "split:" + 30 'a's = 36 bytes + const result = buildPaymentMemo(longId); + expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(28); + expect(result).toBe("split:" + "a".repeat(22)); }); - it("throws for payer addresses shorter than 8 chars", () => { - expect(() => buildMemo(INVOICE_ID, CONFIG, "GABC")).toThrow( - "Payer address must be at least 8 characters", - ); + it("does not cut in the middle of a multi-byte UTF-8 character", () => { + // Each '€' is 3 bytes. "split:" = 6 bytes → 22 bytes left. + // 22 / 3 = 7 full '€' chars = 21 bytes, leaving 1 byte gap (not enough for another '€'). + const euroId = "€".repeat(10); // would be 6 + 30 = 36 bytes untruncated + const result = buildPaymentMemo(euroId); + expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(28); + // Must be a valid string — no partial multi-byte characters + expect(result).toBe("split:" + "€".repeat(7)); // 6 + 21 = 27 bytes + // Confirm we didn't produce an invalid byte sequence + expect(Buffer.from(result, "utf8").toString("utf8")).toBe(result); }); - it("throws when the memo exceeds 28 bytes", () => { - // Use a very long invoice ID to force overflow - const longId = "123456789012345678901234567890"; - expect(() => buildMemo(longId, CONFIG, PAYER)).toThrow( - "Memo exceeds 28 bytes", - ); + it("tranche suffix is included when it fits within 28 bytes", () => { + const result = buildPaymentMemo("short", { tranche: 5 }); + expect(result).toBe("split:short:t5"); + expect(Buffer.byteLength(result, "utf8")).toBeLessThanOrEqual(28); }); }); -describe("buildHashMemo", () => { - it("returns a hash-type memo", () => { - const memo = buildHashMemo(INVOICE_ID, CONFIG, PAYER); - expect(memo.type).toBe("hash"); - }); -}); - -describe("buildIdMemo", () => { - it("returns an id-type memo for numeric invoice IDs", () => { - const memo = buildIdMemo("42"); - expect(memo.type).toBe("id"); - }); - - it("accepts number input", () => { - const memo = buildIdMemo(42); - expect(memo.type).toBe("id"); +describe("parsePaymentMemo", () => { + it("parses base format", () => { + expect(parsePaymentMemo("split:inv-123")).toEqual({ invoiceId: "inv-123" }); }); -}); -describe("parseMemo", () => { - it("round-trips: parseMemo(buildMemo(...)) returns original fields", () => { - const built = buildMemo(INVOICE_ID, CONFIG, PAYER); - const parsed = parseMemo(built); - expect(parsed.invoiceId).toBe(INVOICE_ID); - expect(parsed.version).toBe(CONFIG.version); - expect(parsed.payerId).toBe(PAYER.slice(-8)); + it("parses tranche format", () => { + expect(parsePaymentMemo("split:inv-123:t2")).toEqual({ + invoiceId: "inv-123", + tranche: 2, + }); }); - it("throws for non-text memos", () => { - const idMemo = buildIdMemo("42"); - expect(() => parseMemo(idMemo)).toThrow( - 'Cannot parse memo of type "id"', - ); + it("returns null for a memo without the split: prefix", () => { + expect(parsePaymentMemo("pay:inv-123")).toBeNull(); + expect(parsePaymentMemo("inv-123")).toBeNull(); + expect(parsePaymentMemo("")).toBeNull(); }); - it("throws for memos without the SS: prefix", () => { - const memo = Memo.text("random text"); - expect(() => parseMemo(memo)).toThrow( - `Memo does not start with expected prefix "${MEMO_PREFIX}"`, - ); + it("returns null for an unrelated memo string", () => { + expect(parsePaymentMemo("some random memo")).toBeNull(); }); +}); - it("throws for memos with invalid version format", () => { - const memo = Memo.text("SS:abc:42:ABCDEFGH"); - expect(() => parseMemo(memo)).toThrow("Invalid memo format"); +describe("round-trip: buildPaymentMemo → parsePaymentMemo", () => { + it("round-trips base format", () => { + const id = "inv-abc-999"; + expect(parsePaymentMemo(buildPaymentMemo(id))).toEqual({ invoiceId: id }); }); - it("throws for memos with empty invoice ID", () => { - const memo = Memo.text("SS:v1::ABCDEFGH"); - expect(() => parseMemo(memo)).toThrow("empty invoice ID"); + it("round-trips tranche format", () => { + const id = "inv-abc-999"; + const opts = { tranche: 3 }; + expect(parsePaymentMemo(buildPaymentMemo(id, opts))).toEqual({ + invoiceId: id, + tranche: opts.tranche, + }); }); - it("throws for memos with wrong payer suffix length", () => { - const memo = Memo.text("SS:v1:42:ABC"); - expect(() => parseMemo(memo)).toThrow("payer suffix must be 8 characters"); + it("round-trips correctly even when no truncation occurs", () => { + // 22 ASCII chars → "split:" + 22 chars = 28 bytes exactly, no truncation + const id = "a".repeat(22); + const result = parsePaymentMemo(buildPaymentMemo(id)); + expect(result).toEqual({ invoiceId: id }); }); }); -describe("isStellarSplitMemo", () => { - it("returns true for valid split memos", () => { - const memo = buildMemo(INVOICE_ID, CONFIG, PAYER); - expect(isStellarSplitMemo(memo)).toBe(true); - }); - - it("returns false for memos without the prefix", () => { - const memo = Memo.text("hello world"); - expect(isStellarSplitMemo(memo)).toBe(false); - }); - - it("returns false for non-text memos", () => { - expect(isStellarSplitMemo(Memo.id("1"))).toBe(false); - expect(isStellarSplitMemo(Memo.none())).toBe(false); +describe("edge case: truncation affects tranche/invoiceId", () => { + /** + * Documented behaviour when truncation hits the tranche suffix: + * + * If the full memo is longer than 28 bytes and truncation cuts into or + * removes the ":t{tranche}" suffix, parsePaymentMemo returns the truncated + * invoiceId (up to the last ":t" boundary if the boundary itself is cut) and + * no tranche field. + * + * Specifically, if truncation removes the tranche digits entirely but leaves + * the ":t" separator, the regex won't match ":t" without trailing digits, so + * it is absorbed into the invoiceId portion. + */ + it("loses tranche when truncation cuts the tranche digits", () => { + // invoiceId = "a".repeat(22) → "split:" + 22 'a's = 28 bytes already at the limit. + // Adding ":t1" would push it to 31 bytes, so it gets truncated back to 28 bytes, + // dropping the entire ":t1" suffix. + const id = "a".repeat(22); + const built = buildPaymentMemo(id, { tranche: 1 }); + expect(Buffer.byteLength(built, "utf8")).toBeLessThanOrEqual(28); + // The truncated string is just "split:" + 22 'a's — no tranche survives + const parsed = parsePaymentMemo(built); + expect(parsed).not.toBeNull(); + expect(parsed?.tranche).toBeUndefined(); + }); + + it("returns truncated invoiceId when invoiceId itself is cut", () => { + // 30 'a's → truncated to 22 'a's after "split:" + const id = "a".repeat(30); + const built = buildPaymentMemo(id); + const parsed = parsePaymentMemo(built); + expect(parsed).toEqual({ invoiceId: "a".repeat(22) }); }); });