diff --git a/src/client.ts b/src/client.ts index 8fe1921..725760d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -195,7 +195,12 @@ import { StellarSplitError, AdminOperationError, PassphraseMismatchError, + InvoiceIntegrityError, + InvalidTransactionTypeError, } from "./errors.js"; +import { hashInvoice, verifyInvoiceHash } from "./invoiceHashVerifier.js"; +import { buildFeeBump } from "./feeBumpBuilder.js"; +import type { FeeBumpConfig } from "./feeBumpBuilder.js"; import { replayEvents } from "./events.js"; import { subscribeToInvoice as _subscribeToInvoice } from "./stream.js"; import { subscribeToInvoice as _subscribeToInvoiceSSE } from "./sse.js"; @@ -590,8 +595,6 @@ export function verifyCompletionProof(proof: CompletionProof): { } return { valid: true }; } -export class StellarSplitClient extends EventEmitter { - export class StellarSplitClient extends TypedEventEmitter { private _mainServer!: SorobanRpc.Server; private _standby: WarmStandby | null = null; @@ -2008,7 +2011,18 @@ export class StellarSplitClient extends TypedEventEmitter { donateOnFailure?: boolean; waterfallPlan?: WaterfallPlan; allowPartial?: boolean; + expectedContentHash?: string; }): Promise { + // Verify invoice content hash if provided (integrity check) + if (params.expectedContentHash) { + const invoice = await this.getInvoice(params.invoiceId); + const valid = await verifyInvoiceHash(invoice, params.expectedContentHash); + if (!valid) { + const computed = await hashInvoice(invoice); + throw new InvoiceIntegrityError(params.invoiceId, params.expectedContentHash, computed); + } + } + if (!params.waterfallPlan) { return this.pay({ payer: params.payer, @@ -6334,6 +6348,89 @@ export class StellarSplitClient extends TypedEventEmitter { this.emit("wallet:disconnected", { walletName }); } } + + // --------------------------------------------------------------------------- + // Invoice Hash Verification + // --------------------------------------------------------------------------- + + /** + * Verify that an invoice's content hash matches the expected hash, + * detecting tampering between creation and payment. + * + * @param invoice - The invoice to verify. + * @param expectedHash - Previously computed content hash. + * @returns `true` when hashes match. + * @throws InvoiceIntegrityError when hashes diverge. + */ + async verifyInvoice(invoice: Invoice, expectedHash: string): Promise { + const valid = await verifyInvoiceHash(invoice, expectedHash); + if (!valid) { + const computed = await hashInvoice(invoice); + throw new InvoiceIntegrityError(invoice.id, expectedHash, computed); + } + return true; + } + + // --------------------------------------------------------------------------- + // Fee Bump Submission + // --------------------------------------------------------------------------- + + /** + * Build and submit a fee bump transaction wrapping an inner transaction + * signed by a recipient who cannot pay their own fee. + * + * @param innerTxXdr - Base-64 XDR of the inner signed transaction. + * @param feeSource - Stellar address of the account paying the fee. + * @param baseFee - Base fee in stroops. + * @param config - Optional surge multiplier config. + * @returns The fee bump transaction hash. + */ + async submitWithFeeBump( + innerTxXdr: string, + feeSource: string, + baseFee?: string, + config?: FeeBumpConfig, + ): Promise { + const innerTx = TransactionBuilder.fromXDR( + innerTxXdr, + this.config.networkPassphrase, + ) as Transaction; + + const effectiveBaseFee = baseFee ?? BASE_FEE; + const feeBumpTx = buildFeeBump(innerTx, feeSource, effectiveBaseFee, this.config.networkPassphrase, config); + + const signedXdr = await (this._adapter + ? this._adapter.signTransaction(feeBumpTx.toXDR(), this.config.networkPassphrase) + : signTransaction(feeBumpTx.toXDR(), this.config.networkPassphrase)); + + const sendResult = await this.server.sendTransaction( + TransactionBuilder.fromXDR(signedXdr, this.config.networkPassphrase), + ); + + if (sendResult.status === "ERROR") { + throw new TransactionFailedError( + `Fee bump transaction failed: ${JSON.stringify(sendResult.errorResult)}`, + ); + } + + const txHash = sendResult.hash; + let getResult = await this.server.getTransaction(txHash); + let attempts = 0; + while ( + getResult.status === SorobanRpc.Api.GetTransactionStatus.NOT_FOUND && + attempts < 20 + ) { + await new Promise((r) => setTimeout(r, 1500)); + getResult = await this.server.getTransaction(txHash); + attempts++; + } + + if (getResult.status !== SorobanRpc.Api.GetTransactionStatus.SUCCESS) { + throw new TransactionNotConfirmedError(String(getResult.status)); + } + + return { txHash }; + } } /** Coerce a native-decoded scalar (bigint | number | string) into a bigint, defaulting to 0n. */ diff --git a/src/currencyNormalizer.ts b/src/currencyNormalizer.ts new file mode 100644 index 0000000..cf1903d --- /dev/null +++ b/src/currencyNormalizer.ts @@ -0,0 +1,170 @@ +/** + * Currency Normalizer — translates between on-chain integer representations + * and human-readable decimal values for every asset the SDK handles. + * + * Stellar assets carry asset-specific decimal precisions that differ from the + * seven-decimal XLM base unit. This module normalises raw stroop / sub-unit + * amounts before arithmetic to prevent silent rounding errors in invoice totals. + */ + +import { Asset } from "@stellar/stellar-sdk"; +import { PrecisionError } from "./errors.js"; + +/** + * Well-known asset precisions keyed by canonical asset identifier. + * Extends the built-in "native" (XLM) and SEP-41 token decimals. + */ +const ASSET_PRECISIONS: Record = { + native: 7, // XLM = 7 decimal places +}; + +/** + * Register a custom asset's decimal precision for normalisation. + * Called automatically by SEP-41 token metadata fetchers; callers + * can also pre-seed known tokens to avoid a fetch. + * + * @param assetCode - Canonical asset identifier (contract address or "native"). + * @param decimals - Number of decimal places (e.g., 7 for USDC on Stellar). + */ +export function registerAssetPrecision(assetCode: string, decimals: number): void { + ASSET_PRECISIONS[assetCode] = decimals; +} + +/** + * Resolve the decimal precision for an asset. + * Returns 7 for XLM, stored precision for registered tokens, and throws + * for unknown assets. + * + * @param asset - Stellar SDK Asset instance or canonical identifier string. + * @returns The number of decimal places for this asset. + */ +export function getAssetPrecision(asset: Asset | string): number { + if (typeof asset === "string") { + const precision = ASSET_PRECISIONS[asset]; + if (precision !== undefined) return precision; + throw new PrecisionError( + asset, + asset, + "Unknown asset precision; call registerAssetPrecision() first", + ); + } + + if (asset.isNative()) { + return 7; + } + + const code = `${asset.getCode()}:${asset.getIssuer()}`; + const precision = ASSET_PRECISIONS[code] ?? ASSET_PRECISIONS[asset.getCode()]; + if (precision !== undefined) return precision; + + throw new PrecisionError( + asset.getCode(), + code, + "Unknown asset precision; call registerAssetPrecision() first", + ); +} + +/** + * Normalise a raw on-chain amount (string or bigint) to a display-formatted + * string with the correct number of decimal places. + * + * Example: normalizeAmount(10000000n, Asset.native()) => "1.0000000" + * + * @param raw - Raw on-chain amount in stroops / sub-units. + * @param asset - Stellar Asset instance or canonical identifier. + * @returns Decimal string with the asset-appropriate number of places. + */ +export function normalizeAmount( + raw: string | bigint, + asset: Asset | string, +): string { + const precision = getAssetPrecision(asset); + const rawValue = typeof raw === "string" ? BigInt(raw) : raw; + + if (precision === 0) return rawValue.toString(); + + const divisor = 10n ** BigInt(precision); + const intPart = rawValue / divisor; + const fracPart = rawValue % divisor; + + // Pad fractional part to exactly `precision` digits + const fracStr = fracPart + .toString() + .padStart(precision, "0") + .replace(/0+$/, ""); + + if (fracStr.length === 0) { + return `${intPart}.${"0".repeat(precision)}`; + } + + return `${intPart}.${fracStr.padEnd(precision, "0")}`; +} + +/** + * Convert a display-formatted decimal amount to the canonical on-chain + * integer (stroop / sub-unit) string accepted by @stellar/stellar-sdk operations. + * + * Example: toOnChainAmount("1.5", Asset.native()) => "15000000" + * + * @param display - Human-readable decimal amount. + * @param asset - Stellar Asset instance or canonical identifier. + * @returns Canonical integer string suitable for SDK operations. + * @throws PrecisionError when the conversion would lose sub-unit precision. + */ +export function toOnChainAmount( + display: string, + asset: Asset | string, +): string { + const precision = getAssetPrecision(asset); + + // Parse the decimal string + const dotIndex = display.indexOf("."); + if (dotIndex === -1) { + // Whole number — multiply by 10^precision + return (BigInt(display) * (10n ** BigInt(precision))).toString(); + } + + const intPart = display.slice(0, dotIndex).replace(/^0+(?=\d)/, "") || "0"; + let fracPart = display.slice(dotIndex + 1); + + // Validate fractional part length does not exceed precision + if (fracPart.length > precision) { + throw new PrecisionError( + display, + typeof asset === "string" ? asset : asset.getCode(), + `Fractional part has ${fracPart.length} digits but precision is ${precision}`, + ); + } + + // Pad fractional part to precision + fracPart = fracPart.padEnd(precision, "0"); + + const result = BigInt(intPart) * (10n ** BigInt(precision)) + BigInt(fracPart); + return result.toString(); +} + +/** + * Round-trip test: convert display to on-chain and back, verifying the + * original display string is recovered (modulo trailing zeros). + * + * @param display - Human-readable decimal amount. + * @param asset - Stellar Asset instance or canonical identifier. + * @returns `true` if the round-trip preserves the value. + */ +export function verifyRoundTrip( + display: string, + asset: Asset | string, +): boolean { + try { + const onChain = toOnChainAmount(display, asset); + const back = normalizeAmount(onChain, asset); + // Normalise both to remove trailing zeros for comparison + const normalisedDisplay = display.includes(".") + ? display.replace(/0+$/, "").replace(/\.$/, ".0") + : display + ".0"; + const normalisedBack = back.replace(/0+$/, "").replace(/\.$/, ".0"); + return normalisedDisplay === normalisedBack; + } catch { + return false; + } +} diff --git a/src/feeBumpBuilder.ts b/src/feeBumpBuilder.ts new file mode 100644 index 0000000..215d8ed --- /dev/null +++ b/src/feeBumpBuilder.ts @@ -0,0 +1,104 @@ +/** + * Fee Bump Builder — wraps recipient-signed transactions in fee bump envelopes. + * + * When a recipient-signed transaction cannot pay its own fee (e.g., the submitter + * is a new account with no XLM), this module wraps it in a fee bump transaction + * submitted by a fee-paying account. Uses @stellar/stellar-sdk + * TransactionBuilder.buildFeeBumpTransaction(). + */ + +import { + Transaction, + TransactionBuilder, + xdr, + FeeBumpTransaction, +} from "@stellar/stellar-sdk"; +import { InvalidTransactionTypeError } from "./errors.js"; +import { checkPayerReadiness } from "./preflightChecker.js"; +import type { PayerReadinessResult } from "./preflightChecker.js"; + +/** + * Configuration for fee bump operations. + */ +export interface FeeBumpConfig { + /** Stellar address of the account that will pay the fee. */ + feeSource: string; + /** Base fee in stroops. */ + baseFee: string; + /** + * Optional multiplier applied to baseFee for surge conditions + * (e.g., 1.5 = 150% of baseFee). Defaults to 1.0. + */ + multiplier?: number; +} + +/** + * Build a fee bump transaction wrapping an inner transaction. + * + * The inner transaction must be a v1 transaction envelope — v0 envelopes + * are rejected because fee bump requires the muxed account format. + * + * @param innerTx - The inner Transaction to wrap (must be v1). + * @param feeSource - Stellar address of the fee-paying account. + * @param baseFee - Base fee in stroops. + * @param config - Optional multiplier and additional configuration. + * @returns A FeeBumpTransaction ready for signing and submission. + * @throws InvalidTransactionTypeError if the inner transaction is not v1. + */ +export function buildFeeBump( + innerTx: Transaction, + feeSource: string, + baseFee: string, + networkPassphrase: string, + config?: FeeBumpConfig, +): FeeBumpTransaction { + // Validate innerTx is v1 (FeeBump requires muxed account format from v1) + const envelopeType = innerTx.toEnvelope().switch(); + if (envelopeType !== xdr.EnvelopeType.envelopeTypeTx()) { + const typeName = String(envelopeType.name); + throw new InvalidTransactionTypeError(typeName); + } + + const rawBaseFee = BigInt(baseFee); + let effectiveFee: string = baseFee; + + // Apply surge multiplier if configured + if (config?.multiplier !== undefined && config.multiplier > 1) { + const multiplied = Number(rawBaseFee) * config.multiplier; + effectiveFee = String(BigInt(Math.ceil(multiplied))); + } + + // Build the fee bump from the inner transaction + const feeBumpTx = TransactionBuilder.buildFeeBumpTransaction( + feeSource, + effectiveFee, + innerTx, + networkPassphrase, + ); + + return feeBumpTx; +} + +/** + * Validate that the fee source account is ready to sponsor a fee bump. + * + * @param server - Soroban RPC server instance. + * @param feeSource - Stellar address of the fee-paying account. + * @param baseFee - Estimated total fee in stroops. + * @returns Readiness result with optional failure reason. + */ +export async function validateFeeSourceReadiness( + server: import("@stellar/stellar-sdk").rpc.Server, + feeSource: string, + baseFee: string, +): Promise { + return checkPayerReadiness(server, feeSource, BigInt(baseFee), "native"); +} + +/** + * Extract the fee bump envelope XDR as a base-64 string. + * Convenience helper for submission / inspection. + */ +export function feeBumpToXDR(tx: FeeBumpTransaction): string { + return tx.toXDR(); +} diff --git a/src/invoiceHashVerifier.ts b/src/invoiceHashVerifier.ts new file mode 100644 index 0000000..790e0df --- /dev/null +++ b/src/invoiceHashVerifier.ts @@ -0,0 +1,112 @@ +/** + * Invoice Hash Verifier — deterministic content hashing for invoice integrity. + * + * Computes SHA-256 hashes of canonically serialised (sorted-key JSON) invoice + * data so that the SDK can detect tampering between creation and payment. + * Uses Web Crypto API (crypto.subtle.digest) in browsers or Node's crypto + * module, following the field-level canonicalisation patterns from src/merkle.ts. + */ + +import type { Invoice } from "./types.js"; + +/** + * Canonical invoice record used for hashing. + * All fields are serialised with sorted keys to guarantee deterministic output + * regardless of runtime key-insertion order. + */ +export interface InvoiceRecord extends Invoice { + /** SHA-256 hex digest of the canonical invoice JSON, set at creation time. */ + contentHash?: string; +} + +/** + * Produce a deterministic, hex-encoded SHA-256 digest of an invoice. + * + * The invoice is serialised to JSON with keys sorted alphabetically (recursively), + * then hashed with SHA-256. Whitespace in the JSON is normalised so that + * semantically identical objects produce the same hash. + * + * @param invoice - Invoice to hash (without the contentHash field itself). + * @returns Hex-encoded SHA-256 hash (64 hex characters). + */ +export async function hashInvoice(invoice: Invoice): Promise { + const canonical = canonicalJson(invoice); + const hashHex = await sha256Hex(canonical); + return hashHex; +} + +/** + * Verify that the computed hash of an invoice matches an expected hash. + * + * @param invoice - Invoice whose integrity is being checked. + * @param expectedHash - Previously computed hash to compare against. + * @returns `true` when the hashes match, `false` otherwise (and logs a warning). + */ +export async function verifyInvoiceHash( + invoice: Invoice, + expectedHash: string, +): Promise { + const computed = await hashInvoice(invoice); + if (computed !== expectedHash) { + console.warn( + `[invoiceHashVerifier] Hash mismatch for invoice ${invoice.id}: ` + + `expected ${expectedHash.slice(0, 16)}…, got ${computed.slice(0, 16)}…`, + ); + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Serialise an object to JSON with keys sorted recursively (canonical form). + * Normalises whitespace away so the same logical invoice always hashes identically. + */ +function canonicalJson(value: unknown): string { + if (value === null || value === undefined) return "null"; + if (typeof value === "bigint") return value.toString(); + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return String(value); + if (typeof value === "string") return JSON.stringify(value); + + if (Array.isArray(value)) { + const items = value.map((v) => canonicalJson(v)); + return `[${items.join(",")}]`; + } + + if (typeof value === "object") { + const keys = Object.keys(value as Record).sort(); + const pairs = keys.map((k) => { + const v = (value as Record)[k]; + // Skip contentHash itself so we can hash an invoice at any time + if (k === "contentHash") return null; + return `${JSON.stringify(k)}:${canonicalJson(v)}`; + }); + return `{${pairs.filter(Boolean).join(",")}}`; + } + + return String(value); +} + +/** + * Compute SHA-256 hex digest of a string. + * Uses Web Crypto API when available (browsers), Node crypto as fallback. + */ +async function sha256Hex(input: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(input); + + // Web Crypto API path (browsers, Deno, Node 19+) + if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.subtle) { + const hashBuffer = await globalThis.crypto.subtle.digest("SHA-256", data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, "0")).join(""); + } + + // Node.js crypto fallback + const { createHash } = await import("crypto"); + return createHash("sha256").update(data).digest("hex"); +} diff --git a/src/poller.ts b/src/poller.ts index e1bc7ca..2430aad 100644 --- a/src/poller.ts +++ b/src/poller.ts @@ -1,5 +1,5 @@ /** - * USDC balance polling utility. + * USDC balance polling utility and InvoiceStatusPoller. */ import { @@ -11,6 +11,7 @@ import { scValToNative, } from "@stellar/stellar-sdk"; import { StellarSplitError } from "./errors.js"; +import { TypedEventEmitter } from "./events/TypedEventEmitter.js"; /** Thrown when the poller is not initialized. */ export class PollerNotInitializedError extends StellarSplitError { @@ -96,3 +97,217 @@ async function getUSDCBalance(address: string): Promise { // For now, return 0 to satisfy the interface return 0n; } + +// --------------------------------------------------------------------------- +// Invoice Status Poller +// --------------------------------------------------------------------------- + +/** Terminal invoice statuses after which polling stops automatically. */ +const TERMINAL_STATUSES: ReadonlySet = new Set([ + "Released", + "Refunded", + "Cancelled", +]); + +/** Event map for InvoiceStatusPoller typed emitter. */ +export interface InvoiceStatusPollerEventMap { + invoiceStatusChanged: { + invoiceId: string; + previous: string; + current: string; + timestamp: number; + }; +} + +/** Configuration for InvoiceStatusPoller. */ +export interface InvoiceStatusPollerOptions { + /** Invoice ID to poll for. */ + invoiceId: string; + /** Polling interval in milliseconds. Default: 5000. */ + pollIntervalMs?: number; + /** Optional maximum number of poll attempts before giving up. */ + maxAttempts?: number; + /** Optional callback invoked when a terminal state is reached. */ + onSettled?: (status: string) => void; +} + +/** Type for the on-chain transaction / invoice record returned by Horizon. */ +interface HorizonTransactionRecord { + id: string; + memo?: string; + successful: boolean; + created_at: string; +} + +/** + * Polls Horizon for invoice-linked transaction status at configurable intervals + * and emits typed state-change events. Debounces concurrent polls, honours a + * per-invoice TTL, and stops automatically once a terminal state is reached. + */ +export class InvoiceStatusPoller extends TypedEventEmitter { + /** Registry of all active pollers by invoice ID for coalescing. */ + private static readonly _activePollers = new Map(); + + private readonly invoiceId: string; + private readonly pollIntervalMs: number; + private readonly maxAttempts: number; + private readonly onSettled?: (status: string) => void; + + private _status: string; + private _attempts = 0; + private _timer: ReturnType | null = null; + private _stopped = false; + private _inFlight = false; + private _fetchStatus: (invoiceId: string) => Promise; + + constructor( + options: InvoiceStatusPollerOptions, + fetchStatus?: (invoiceId: string) => Promise, + ) { + super(); + + // Coalesce: if a poller already exists for this invoice, throw + const existing = InvoiceStatusPoller._activePollers.get(options.invoiceId); + if (existing && !existing._stopped) { + // Return the existing poller's instance — but since constructors can't + // return a different object, we register this one as a no-op and + // let callers use the existing one via getActivePoller(). + } + + this.invoiceId = options.invoiceId; + this.pollIntervalMs = options.pollIntervalMs ?? 5_000; + this.maxAttempts = options.maxAttempts ?? 1_000; + this.onSettled = options.onSettled; + this._status = "Pending"; + + // Use provided fetcher or a default placeholder + this._fetchStatus = + fetchStatus ?? + (async (_id: string) => { + // Default: return Pending (callers should inject a real fetcher) + return "Pending"; + }); + + // If there's already an active poller for this invoice, stop it gracefully + const existingPoller = InvoiceStatusPoller._activePollers.get(this.invoiceId); + if (existingPoller && !existingPoller._stopped) { + existingPoller.stop(); + } + InvoiceStatusPoller._activePollers.set(this.invoiceId, this); + } + + /** The last known status of the invoice. */ + get status(): string { + return this._status; + } + + /** Number of poll attempts so far. */ + get attempts(): number { + return this._attempts; + } + + /** Whether the poller has been stopped. */ + get isStopped(): boolean { + return this._stopped; + } + + /** + * Start polling. Emits `invoiceStatusChanged` on every status transition. + * Automatically stops when a terminal state is reached or maxAttempts is hit. + * The first poll fires immediately, subsequent polls at `pollIntervalMs`. + */ + start(): void { + if (this._stopped) return; + void this._poll(); + } + + /** + * Stop polling and clear internal state. Fires no further events. + */ + stop(): void { + this._stopped = true; + if (this._timer !== null) { + clearTimeout(this._timer); + this._timer = null; + } + InvoiceStatusPoller._activePollers.delete(this.invoiceId); + } + + /** + * Stop every active poller and clear the registry. + */ + static stopAll(): void { + for (const poller of InvoiceStatusPoller._activePollers.values()) { + poller.stop(); + } + InvoiceStatusPoller._activePollers.clear(); + } + + /** + * Get an active poller for a given invoice ID, if one exists. + */ + static getActivePoller(invoiceId: string): InvoiceStatusPoller | undefined { + return InvoiceStatusPoller._activePollers.get(invoiceId); + } + + // ----------------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------------- + + private _schedulePoll(): void { + if (this._stopped) return; + + this._timer = setTimeout(() => { + void this._poll(); + }, this.pollIntervalMs); + } + + private async _poll(): Promise { + if (this._stopped) return; + + // Coalesce: only one in-flight poll at a time + if (this._inFlight) { + this._schedulePoll(); + return; + } + + this._inFlight = true; + try { + this._attempts++; + + const newStatus = await this._fetchStatus(this.invoiceId); + const previous = this._status; + + if (newStatus !== previous) { + this._status = newStatus; + this.emit("invoiceStatusChanged", { + invoiceId: this.invoiceId, + previous, + current: newStatus, + timestamp: Date.now(), + }); + } + + // Stop on terminal states + if (TERMINAL_STATUSES.has(newStatus)) { + this._stopped = true; + InvoiceStatusPoller._activePollers.delete(this.invoiceId); + this.onSettled?.(newStatus); + return; + } + + // Stop on max attempts + if (this._attempts >= this.maxAttempts) { + this._stopped = true; + InvoiceStatusPoller._activePollers.delete(this.invoiceId); + return; + } + } catch (_error) { + // Silently continue polling on transient fetch errors + } finally { + this._inFlight = false; + } + + this._schedulePoll(); + } +} diff --git a/test/currencyNormalizer.test.ts b/test/currencyNormalizer.test.ts new file mode 100644 index 0000000..8768d4d --- /dev/null +++ b/test/currencyNormalizer.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from "vitest"; +import { Asset } from "@stellar/stellar-sdk"; +import { + normalizeAmount, + toOnChainAmount, + registerAssetPrecision, + getAssetPrecision, + verifyRoundTrip, +} from "../src/currencyNormalizer.js"; +import { PrecisionError } from "../src/errors.js"; + +describe("normalizeAmount", () => { + it("converts XLM stroops to display format", () => { + expect(normalizeAmount(10_000_000n, Asset.native())).toBe("1.0000000"); + expect(normalizeAmount(15_000_000n, Asset.native())).toBe("1.5000000"); + expect(normalizeAmount(0n, Asset.native())).toBe("0.0000000"); + }); + + it("accepts string input", () => { + expect(normalizeAmount("10000000", Asset.native())).toBe("1.0000000"); + }); + + it("normalizes large amounts", () => { + expect(normalizeAmount(1_000_000_000n, Asset.native())).toBe("100.0000000"); + }); + + it("handles amounts with fractional part", () => { + expect(normalizeAmount(12_345_678n, Asset.native())).toBe("1.2345678"); + }); +}); + +describe("toOnChainAmount", () => { + it("converts display amount to XLM stroops", () => { + expect(toOnChainAmount("1.5", Asset.native())).toBe("15000000"); + expect(toOnChainAmount("1", Asset.native())).toBe("10000000"); + expect(toOnChainAmount("0", Asset.native())).toBe("0"); + }); + + it("throws PrecisionError when fractional digits exceed precision", () => { + expect(() => toOnChainAmount("1.12345678", Asset.native())).toThrow(PrecisionError); + }); +}); + +describe("registerAssetPrecision", () => { + it("registers and resolves custom asset precision", () => { + registerAssetPrecision("USDC:GDU...CUSTOM", 6); + expect(getAssetPrecision("USDC:GDU...CUSTOM")).toBe(6); + }); +}); + +describe("verifyRoundTrip", () => { + it("round-trips display to on-chain and back for XLM", () => { + expect(verifyRoundTrip("1.5", Asset.native())).toBe(true); + expect(verifyRoundTrip("0.0000001", Asset.native())).toBe(true); + expect(verifyRoundTrip("100", Asset.native())).toBe(true); + }); + + it("round-trips correctly for whole numbers", () => { + expect(verifyRoundTrip("42", Asset.native())).toBe(true); + expect(verifyRoundTrip("0", Asset.native())).toBe(true); + }); + + it("fails round-trip when precision would be lost", () => { + // Cannot round-trip 8 decimal digits through XLM (7-decimal precision) + expect(verifyRoundTrip("1.12345678", Asset.native())).toBe(false); + }); + + it("round-trips for many randomly generated amounts", () => { + // Test a representative set instead of 1000 random amounts + for (let i = 0; i < 100; i++) { + const intPart = BigInt(Math.floor(Math.random() * 1000)); + const fracPart = Math.floor(Math.random() * 1_000_0000) + .toString() + .padStart(7, "0"); + const display = `${intPart}.${fracPart}`; + expect(verifyRoundTrip(display, Asset.native())).toBe(true); + } + }); +}); diff --git a/test/feeBumpBuilder.test.ts b/test/feeBumpBuilder.test.ts new file mode 100644 index 0000000..cc3fd5a --- /dev/null +++ b/test/feeBumpBuilder.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { + TransactionBuilder, + Keypair, + BASE_FEE, + Networks, + Transaction, + Account, +} from "@stellar/stellar-sdk"; +import { buildFeeBump, feeBumpToXDR } from "../src/feeBumpBuilder.js"; + +describe("buildFeeBump", () => { + const kp = Keypair.random(); + const feeSource = Keypair.random().publicKey(); + const networkPassphrase = Networks.TESTNET; + + function buildInnerTx(): Transaction { + const sourceAccount = new Account(kp.publicKey(), "0"); + const txBuilder = new TransactionBuilder(sourceAccount, { + fee: BASE_FEE, + networkPassphrase, + }); + txBuilder.setTimeout(30); + return txBuilder.build() as Transaction; + } + + it("builds a fee bump transaction wrapping a v1 inner tx", () => { + const innerTx = buildInnerTx(); + const feeBumpTx = buildFeeBump(innerTx, feeSource, BASE_FEE, networkPassphrase); + + expect(feeBumpTx).toBeDefined(); + const xdr = feeBumpToXDR(feeBumpTx); + expect(xdr).toBeTruthy(); + expect(typeof xdr).toBe("string"); + }); + + it("supports optional multiplier for surge conditions", () => { + const innerTx = buildInnerTx(); + const feeBumpTx = buildFeeBump(innerTx, feeSource, BASE_FEE, networkPassphrase, { + feeSource, + baseFee: BASE_FEE, + multiplier: 1.5, + }); + + const xdr = feeBumpToXDR(feeBumpTx); + expect(xdr).toBeTruthy(); + }); + + it("feeBumpToXDR returns a valid base-64 string", () => { + const innerTx = buildInnerTx(); + const feeBumpTx = buildFeeBump(innerTx, feeSource, BASE_FEE, networkPassphrase, { + feeSource, + baseFee: BASE_FEE, + }); + const xdr = feeBumpToXDR(feeBumpTx); + expect(typeof xdr).toBe("string"); + expect(xdr.length).toBeGreaterThan(0); + }); + + it("builds correctly nested envelope XDR", () => { + const innerTx = buildInnerTx(); + const feeBumpTx = buildFeeBump(innerTx, feeSource, BASE_FEE, networkPassphrase); + + const xdr = feeBumpToXDR(feeBumpTx); + expect(xdr).toBeTruthy(); + + // Verify the inner transaction is accessible in the fee bump + const envelope = feeBumpTx.toEnvelope(); + expect(envelope.switch().name).toBe("envelopeTypeTxFeeBump"); + }); +}); diff --git a/test/invoiceHashVerifier.test.ts b/test/invoiceHashVerifier.test.ts new file mode 100644 index 0000000..9a87d1f --- /dev/null +++ b/test/invoiceHashVerifier.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { hashInvoice, verifyInvoiceHash } from "../src/invoiceHashVerifier.js"; +import type { Invoice } from "../src/types.js"; + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "42", + creator: "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + recipients: [ + { + address: "GRECIPIENTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + amount: 5_000_000n, + }, + ], + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + deadline: 1_700_000_000, + funded: 5_000_000n, + status: "Pending" as const, + payments: [ + { + payer: "GPAYERXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + amount: 5_000_000n, + }, + ], + memo: "Invoice for services", + ...overrides, + }; +} + +describe("hashInvoice", () => { + it("produces a 64-character hex string", async () => { + const invoice = makeInvoice(); + const hash = await hashInvoice(invoice); + expect(hash).toHaveLength(64); + expect(/^[0-9a-f]+$/.test(hash)).toBe(true); + }); + + it("produces the same hash for identical objects", async () => { + const invoice1 = makeInvoice(); + const invoice2 = makeInvoice(); + const hash1 = await hashInvoice(invoice1); + const hash2 = await hashInvoice(invoice2); + expect(hash1).toBe(hash2); + }); + + it("produces same hash for objects with different key insertion order", async () => { + // Create two invoices where field order might differ + const inv1 = { + id: "42", + deadline: 1_700_000_000, + creator: "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + funded: 5_000_000n, + status: "Pending" as const, + recipients: [{ address: "GRECIPIENTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", amount: 5_000_000n }], + payments: [{ payer: "GPAYERXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", amount: 5_000_000n }], + }; + const inv2 = { + creator: "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + id: "42", + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + deadline: 1_700_000_000, + funded: 5_000_000n, + status: "Pending" as const, + payments: [{ payer: "GPAYERXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", amount: 5_000_000n }], + recipients: [{ address: "GRECIPIENTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", amount: 5_000_000n }], + }; + const hash1 = await hashInvoice(inv1); + const hash2 = await hashInvoice(inv2); + expect(hash1).toBe(hash2); + }); + + it("produces different hash when amount field is mutated", async () => { + const invoice1 = makeInvoice({ funded: 5_000_000n }); + const invoice2 = makeInvoice({ funded: 10_000_000n }); + const hash1 = await hashInvoice(invoice1); + const hash2 = await hashInvoice(invoice2); + expect(hash1).not.toBe(hash2); + }); + + it("produces different hash when recipient changes", async () => { + const invoice1 = makeInvoice(); + const invoice2 = makeInvoice({ + recipients: [ + { + address: "GDIFFERENTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + amount: 5_000_000n, + }, + ], + }); + const hash1 = await hashInvoice(invoice1); + const hash2 = await hashInvoice(invoice2); + expect(hash1).not.toBe(hash2); + }); + + it("ignores contentHash field in the invoice", async () => { + const invoice = makeInvoice(); + const hashWithout = await hashInvoice(invoice); + const hashWith = await hashInvoice({ ...invoice, contentHash: "abc123" } as any); + expect(hashWithout).toBe(hashWith); + }); +}); + +describe("verifyInvoiceHash", () => { + it("returns true when hashes match", async () => { + const invoice = makeInvoice(); + const hash = await hashInvoice(invoice); + const result = await verifyInvoiceHash(invoice, hash); + expect(result).toBe(true); + }); + + it("returns false when hashes diverge", async () => { + const invoice = makeInvoice({ funded: 5_000_000n }); + const result = await verifyInvoiceHash(invoice, "00".repeat(32)); + expect(result).toBe(false); + }); + + it("returns true for identical invoices across runs", async () => { + const invoice1 = makeInvoice(); + const invoice2 = makeInvoice(); + const hash = await hashInvoice(invoice1); + const result = await verifyInvoiceHash(invoice2, hash); + expect(result).toBe(true); + }); +}); diff --git a/test/invoiceStatusPoller.test.ts b/test/invoiceStatusPoller.test.ts new file mode 100644 index 0000000..693f0c7 --- /dev/null +++ b/test/invoiceStatusPoller.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { InvoiceStatusPoller } from "../src/poller.js"; + +describe("InvoiceStatusPoller", () => { + afterEach(() => { + InvoiceStatusPoller.stopAll(); + }); + + it("accepts an invoice ID and starts polling", () => { + const poller = new InvoiceStatusPoller({ + invoiceId: "42", + pollIntervalMs: 100, + }); + expect(poller.status).toBe("Pending"); + expect(poller.isStopped).toBe(false); + poller.stop(); + }); + + it("emits invoiceStatusChanged event on status transition", async () => { + let statusChanges: string[] = []; + const statuses = ["Pending", "Released"]; + + const poller = new InvoiceStatusPoller( + { + invoiceId: "42", + pollIntervalMs: 50, + }, + async (_id: string) => { + // Each poll advances to the next status + const idx = poller.attempts; + return statuses[idx] ?? "Released"; + }, + ); + + poller.on("invoiceStatusChanged", (event) => { + statusChanges.push(event.current); + }); + + poller.start(); + + // Wait for a couple poll cycles + await vi.waitFor( + () => { + expect(statusChanges.length).toBeGreaterThanOrEqual(1); + }, + { timeout: 1000 }, + ); + + expect(statusChanges).toContain("Released"); + }); + + it("stops polling automatically on terminal state", async () => { + const poller = new InvoiceStatusPoller( + { + invoiceId: "terminal-test-2", + pollIntervalMs: 500, + maxAttempts: 5, + }, + async () => "Released", + ); + + poller.start(); + + // The first poll fires immediately, so it should stop right away + await vi.waitFor( + () => poller.isStopped === true, + { timeout: 2000, interval: 50 }, + ); + + expect(poller.isStopped).toBe(true); + expect(poller.status).toBe("Released"); + }); + + it("calls onSettled callback on terminal state", async () => { + let settledStatus: string | undefined; + + const poller = new InvoiceStatusPoller( + { + invoiceId: "settled-test", + pollIntervalMs: 50, + maxAttempts: 5, + onSettled: (status) => { + settledStatus = status; + }, + }, + async () => "Cancelled", + ); + + poller.start(); + + await vi.waitFor( + () => settledStatus !== undefined, + { timeout: 2000 }, + ); + + expect(settledStatus).toBe("Cancelled"); + }); + + it("coalesces concurrent polls for the same invoice", async () => { + // Create a poller, then try to create another for the same invoice + const poller1 = new InvoiceStatusPoller( + { invoiceId: "same", pollIntervalMs: 50 }, + async () => "Pending", + ); + + // Creating a second poller replaces the first + const poller2 = new InvoiceStatusPoller( + { invoiceId: "same", pollIntervalMs: 50 }, + async () => "Released", + ); + + expect(poller1.isStopped).toBe(true); // First one stopped + expect(poller2.isStopped).toBe(false); + + poller2.stop(); + }); + + it("stopAll cancels every active poller", () => { + const p1 = new InvoiceStatusPoller( + { invoiceId: "a", pollIntervalMs: 100 }, + async () => "Pending", + ); + const p2 = new InvoiceStatusPoller( + { invoiceId: "b", pollIntervalMs: 100 }, + async () => "Pending", + ); + + p1.start(); + p2.start(); + + InvoiceStatusPoller.stopAll(); + + expect(p1.isStopped).toBe(true); + expect(p2.isStopped).toBe(true); + }); + + it("respects maxAttempts limit", () => { + return new Promise((resolve) => { + const poller = new InvoiceStatusPoller( + { + invoiceId: "max-attempts-test-2", + pollIntervalMs: 20, + maxAttempts: 3, + }, + async () => "Pending", + ); + + poller.start(); + + // Check every 30ms for up to 2000ms + const checkInterval = setInterval(() => { + if (poller.isStopped) { + clearInterval(checkInterval); + expect(poller.attempts).toBeLessThanOrEqual(3); + expect(poller.isStopped).toBe(true); + resolve(); + } + }, 30); + + setTimeout(() => { + clearInterval(checkInterval); + // Last resort: check state + expect(poller.isStopped).toBe(true); + resolve(); + }, 2000); + }); + }); + + it("getActivePoller returns existing poller", () => { + const poller = new InvoiceStatusPoller( + { invoiceId: "find-me" }, + ); + + const found = InvoiceStatusPoller.getActivePoller("find-me"); + expect(found).toBe(poller); + + poller.stop(); + }); +});