From 4c7a1d3fa38898d8a65582a8aefa1fb8c525ceda Mon Sep 17 00:00:00 2001 From: onjehdaniel889 Date: Sat, 29 Aug 2026 23:26:10 +0100 Subject: [PATCH] fix: schema version guard, bridge chain validation, adaptive backoff, timeline status field - bulkImportValidator: export SUPPORTED_SCHEMA_VERSIONS [1,2]; reject payloads with absent or unsupported schemaVersion before row-level validation (#749) - bridge: export SUPPORTED_CHAIN_IDS and BridgeChainMismatchError; validate BridgeOptions.targetChainId against allowlist before any tx is built (#745) - AdaptiveThrottle: add exponential backoff after consecutive 429 breaches; window doubles up to maxBackoffMs (default 60s); add getBackoffMultiplier() for observability; backoff resets after a clean window (#738) - types/timeline: export TimelineEntryStatus string union (pending|in_progress| completed|failed); add required status field to TimelineEntry (#734) Closes #749 Closes #745 Closes #738 Closes #734 --- src/bridge.ts | 99 ++++++++++- src/bulkImportValidator.ts | 69 +++++++- src/index.ts | 8 +- src/throttle/AdaptiveThrottle.ts | 59 ++++++- src/timeline/PaymentTimelineReconstructor.ts | 2 + src/types/timeline.ts | 13 ++ test/AdaptiveThrottle.test.ts | 106 ++++++++++++ test/bridge.test.ts | 164 ++++++++++++++++--- test/bulkImportValidator.test.ts | 97 ++++++++++- test/timeline.test.ts | 90 +++++++++- 10 files changed, 666 insertions(+), 41 deletions(-) diff --git a/src/bridge.ts b/src/bridge.ts index 80fa05e..918dbdc 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -25,6 +25,46 @@ import type { } from "./types.js"; import { signTransaction } from "./wallet.js"; +// --------------------------------------------------------------------------- +// Supported chain IDs & mismatch error +// --------------------------------------------------------------------------- + +/** + * The set of chain IDs that this SDK's bridge module accepts. + * Any `targetChainId` outside this list will cause `buildBridgePayment` and + * `submitBridgePayment` to throw a {@link BridgeChainMismatchError}. + */ +export const SUPPORTED_CHAIN_IDS: readonly ChainId[] = ["ethereum", "solana"]; + +/** + * Thrown when `BridgeOptions.targetChainId` is absent or not present in + * {@link SUPPORTED_CHAIN_IDS}. Because a mismatch would silently route funds + * to the wrong chain, the error is raised before any transaction is built. + */ +export class BridgeChainMismatchError extends Error { + constructor(targetChainId: string | undefined) { + super( + targetChainId === undefined || targetChainId === null || targetChainId === "" + ? `targetChainId is required. Supported chain IDs: [${SUPPORTED_CHAIN_IDS.join(", ")}]` + : `Unsupported targetChainId "${targetChainId}". Supported chain IDs: [${SUPPORTED_CHAIN_IDS.join(", ")}]`, + ); + this.name = "BridgeChainMismatchError"; + } +} + +/** + * Options passed alongside bridge payment parameters to specify and validate + * the intended destination chain. + */ +export interface BridgeOptions { + /** + * The chain the funds should be routed to. Must be one of + * {@link SUPPORTED_CHAIN_IDS}; an unsupported or missing value throws + * {@link BridgeChainMismatchError} before any transaction is built. + */ + targetChainId: ChainId; +} + // --------------------------------------------------------------------------- // Per-chain bridge configuration // --------------------------------------------------------------------------- @@ -223,12 +263,26 @@ export async function estimateBridgeFee( * source-chain wallet before it can be submitted via * {@link submitBridgePayment}. * - * @param params - Payment parameters (chain, payer, invoiceId, amount, etc.). + * @param params - Payment parameters (chain, payer, invoiceId, amount, etc.). + * @param options - Bridge options; `targetChainId` must be a supported chain. * @returns Unsigned BridgePaymentRequest ready for source-chain signing. + * @throws {BridgeChainMismatchError} if `options.targetChainId` is absent or + * not in {@link SUPPORTED_CHAIN_IDS}. */ export function buildBridgePayment( params: BridgePaymentParams, + options?: BridgeOptions, ): BridgePaymentRequest { + // Validate targetChainId before building any transaction. + const targetChainId = options?.targetChainId; + if ( + targetChainId === undefined || + targetChainId === null || + !(SUPPORTED_CHAIN_IDS as readonly string[]).includes(targetChainId) + ) { + throw new BridgeChainMismatchError(targetChainId); + } + const { sourceChain, payer, @@ -303,14 +357,41 @@ export interface BridgePayDeps { * * @param proof - Signed bridge proof from the source-chain wallet. * @param clientConfig - StellarSplitClient configuration (rpcUrl, contractId, etc.). + * @param options - Bridge options; `targetChainId` must be a supported chain. * @param _deps - Optional injectable dependencies (for testing only). * @returns Transaction hash of the submitted bridge payment. + * @throws {BridgeChainMismatchError} if `options.targetChainId` is absent or + * not in {@link SUPPORTED_CHAIN_IDS}. */ export async function submitBridgePayment( proof: SignedBridgeProof, clientConfig: StellarSplitClientConfig, + options?: BridgeOptions | BridgePayDeps, _deps?: BridgePayDeps, ): Promise<{ txHash: string }> { + // Normalise the overloaded signature: submitBridgePayment(proof, cfg, deps) + // was the old public shape; new shape is submitBridgePayment(proof, cfg, options, deps). + let resolvedOptions: BridgeOptions | undefined; + let resolvedDeps: BridgePayDeps | undefined; + + if (options !== undefined && "targetChainId" in options) { + resolvedOptions = options as BridgeOptions; + resolvedDeps = _deps; + } else { + // Legacy call: third arg is actually deps (no options). + resolvedDeps = options as BridgePayDeps | undefined; + } + + // Validate targetChainId before touching the network. + const targetChainId = resolvedOptions?.targetChainId; + if ( + targetChainId === undefined || + targetChainId === null || + !(SUPPORTED_CHAIN_IDS as readonly string[]).includes(targetChainId) + ) { + throw new BridgeChainMismatchError(targetChainId); + } + const { request, signature } = proof; if (!request.payloadHash) { @@ -325,14 +406,14 @@ export async function submitBridgePayment( ? clientConfig.rpcUrl[0]! : clientConfig.rpcUrl; - const server: SorobanServerLike = _deps?.server ?? new SorobanRpc.Server(rpcUrl, { + const server: SorobanServerLike = resolvedDeps?.server ?? new SorobanRpc.Server(rpcUrl, { allowHttp: rpcUrl.startsWith("http://"), }); // Build the bridge_pay contract call operation via injectable or real Contract let operation: any; - if (_deps?.contractCall) { - operation = _deps.contractCall( + if (resolvedDeps?.contractCall) { + operation = resolvedDeps.contractCall( "bridge_pay", request.invoiceId, request.payer, @@ -360,8 +441,8 @@ export async function submitBridgePayment( // Build the transaction (injectable for tests) let tx: any; - if (_deps?.buildTx) { - tx = _deps.buildTx(account, operation, clientConfig.networkPassphrase); + if (resolvedDeps?.buildTx) { + tx = resolvedDeps.buildTx(account, operation, clientConfig.networkPassphrase); } else { tx = new TransactionBuilder(account as unknown as Account, { fee: BASE_FEE, @@ -379,7 +460,7 @@ export async function submitBridgePayment( } // Assemble and sign - const _assembleTransaction = _deps?.assembleTransaction ?? SorobanRpc.assembleTransaction; + const _assembleTransaction = resolvedDeps?.assembleTransaction ?? SorobanRpc.assembleTransaction; const preparedTx = _assembleTransaction(tx, simResult).build(); const preparedXdr: string = typeof preparedTx === "string" @@ -391,12 +472,12 @@ export async function submitBridgePayment( const adapter = (clientConfig as { adapter?: { signTransaction: (xdr: string, network: string) => Promise }; }).adapter; - const _sign = _deps?.signTransaction ?? (adapter?.signTransaction.bind(adapter)) ?? + const _sign = resolvedDeps?.signTransaction ?? (adapter?.signTransaction.bind(adapter)) ?? ((xdr: string, network: string) => signTransaction(xdr, network)); const signedXdr = await _sign(preparedXdr, clientConfig.networkPassphrase); // Submit - const _fromXDR = _deps?.fromXDR ?? TransactionBuilder.fromXDR.bind(TransactionBuilder); + const _fromXDR = resolvedDeps?.fromXDR ?? TransactionBuilder.fromXDR.bind(TransactionBuilder); const sendResult = await server.sendTransaction( _fromXDR(signedXdr, clientConfig.networkPassphrase), ); diff --git a/src/bulkImportValidator.ts b/src/bulkImportValidator.ts index aba1e94..a18e4a7 100644 --- a/src/bulkImportValidator.ts +++ b/src/bulkImportValidator.ts @@ -1,5 +1,11 @@ import type { CreateInvoiceParams } from "./types.js"; +/** + * Schema versions that this SDK version accepts for bulk import payloads. + * Callers can inspect this list to pre-screen payloads before submission. + */ +export const SUPPORTED_SCHEMA_VERSIONS: readonly number[] = [1, 2]; + /** * Describes a single validation error on a specific row and field. */ @@ -23,9 +29,23 @@ export interface BulkImportValidationResult { } /** - * Validate an array of invoice-creation rows against the same constraints - * the contract's `_create_invoice_inner` enforces: + * A bulk import payload that carries a `schemaVersion` alongside the rows. + * When this overload is used the version is validated before row-level checks. + */ +export interface BulkImportPayload { + /** Must be one of the values in {@link SUPPORTED_SCHEMA_VERSIONS}. */ + schemaVersion: number; + rows: CreateInvoiceParams[]; +} + +/** + * Validate an array of invoice-creation rows (or a versioned payload) against + * the same constraints the contract's `_create_invoice_inner` enforces: * + * 0. **Schema version present and supported** – when a + * {@link BulkImportPayload} is supplied, `schemaVersion` must appear in + * {@link SUPPORTED_SCHEMA_VERSIONS}. Absent or unsupported versions cause + * an immediate error before any row-level validation runs. * 1. **Positive amounts** – every recipient amount must be > 0. * 2. **Recipients present** – `recipients` array must not be empty. * 3. **Future deadline** – `deadline` must be in the future (greater than @@ -34,13 +54,54 @@ export interface BulkImportValidationResult { * Unlike a fail-fast approach, *all* rows are always validated so the caller * can surface every problem in one pass. * - * @param rows - Array of {@link CreateInvoiceParams}-like objects to validate. + * @param input - Either a raw array of {@link CreateInvoiceParams} rows, or a + * {@link BulkImportPayload} object that also carries a `schemaVersion`. * @returns A {@link BulkImportValidationResult} with valid row indices and * collected per-row errors. */ export function validateBulkImport( - rows: CreateInvoiceParams[], + input: CreateInvoiceParams[] | BulkImportPayload, ): BulkImportValidationResult { + // Unwrap a versioned payload, validating the schema version first. + let rows: CreateInvoiceParams[]; + + if (Array.isArray(input)) { + rows = input; + } else { + const { schemaVersion, rows: payloadRows } = input; + + if (schemaVersion === undefined || schemaVersion === null) { + return { + validRows: [], + errors: [ + { + row: -1, + field: "schemaVersion", + message: + `schemaVersion is required. Supported versions: [${SUPPORTED_SCHEMA_VERSIONS.join(", ")}]`, + }, + ], + }; + } + + if (!(SUPPORTED_SCHEMA_VERSIONS as readonly number[]).includes(schemaVersion)) { + return { + validRows: [], + errors: [ + { + row: -1, + field: "schemaVersion", + message: + `Unsupported schemaVersion ${schemaVersion}. Supported versions: [${SUPPORTED_SCHEMA_VERSIONS.join(", ")}]`, + }, + ], + }; + } + + rows = payloadRows; + } + + // --- row-level validation (unchanged) --- const validRows: number[] = []; const errors: BulkImportRowError[] = []; diff --git a/src/index.ts b/src/index.ts index 35bb785..bfaba56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,10 +32,12 @@ export { } from "./invoiceTemplate.js"; export { validateBulkImport, + SUPPORTED_SCHEMA_VERSIONS, } from "./bulkImportValidator.js"; export type { BulkImportRowError, BulkImportValidationResult, + BulkImportPayload, } from "./bulkImportValidator.js"; export { StellarSplitError, @@ -732,6 +734,7 @@ export type { export { AdaptiveThrottle, DEFAULT_PENALTY_DURATION_MS, + DEFAULT_MAX_BACKOFF_MS, } from "./throttle/AdaptiveThrottle.js"; export type { AdaptiveThrottleConfig, ThrottleStats } from "./throttle/AdaptiveThrottle.js"; export { parseRateLimitHeaders } from "./throttle/RateLimitParser.js"; @@ -1131,9 +1134,11 @@ export { submitBridgePayment, computePayloadHash, DEFAULT_CHAIN_CONFIGS, + SUPPORTED_CHAIN_IDS, + BridgeChainMismatchError, } from "./bridge.js"; -export type { ChainBridgeConfig, BridgeConfig } from "./bridge.js"; +export type { ChainBridgeConfig, BridgeConfig, BridgeOptions } from "./bridge.js"; export type { ChainId, @@ -1149,6 +1154,7 @@ export type { TimelineEntry, TimelineEventType, TimelineSource, + TimelineEntryStatus, ReconstructedTimeline, RebuildOptions, } from "./types/timeline.js"; diff --git a/src/throttle/AdaptiveThrottle.ts b/src/throttle/AdaptiveThrottle.ts index a6bb2ec..7aef9ae 100644 --- a/src/throttle/AdaptiveThrottle.ts +++ b/src/throttle/AdaptiveThrottle.ts @@ -11,9 +11,18 @@ import { parseRateLimitHeaders, type RateLimitInfo } from "./RateLimitParser.js" export const DEFAULT_PENALTY_DURATION_MS = 5_000; +/** Default maximum backoff window (60 seconds). */ +export const DEFAULT_MAX_BACKOFF_MS = 60_000; + export interface AdaptiveThrottleConfig { /** How long a 429 halves the refill rate for. Default: 5 000ms. */ penaltyDurationMs?: number; + /** + * Maximum effective window after repeated consecutive breaches. + * Each consecutive breach doubles the window up to this cap. + * Default: 60 000ms. + */ + maxBackoffMs?: number; /** Time source — exposed for deterministic tests. */ now?: () => number; } @@ -42,12 +51,19 @@ export class AdaptiveThrottle { private _queue: Array<() => void> = []; private _drainTimer: ReturnType | null = null; + /** Number of consecutive breaches without a clean window. */ + private _consecutiveBreaches = 0; + /** Timestamp (per injected clock) when the current penalty window started. */ + private _penaltyWindowStart = 0; + private readonly _now: () => number; private readonly _penaltyDurationMs: number; + private readonly _maxBackoffMs: number; constructor(config: AdaptiveThrottleConfig = {}) { this._now = config.now ?? (() => Date.now()); this._penaltyDurationMs = config.penaltyDurationMs ?? DEFAULT_PENALTY_DURATION_MS; + this._maxBackoffMs = config.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS; this._lastRefillAt = this._now(); } @@ -55,11 +71,29 @@ export class AdaptiveThrottle { return this._penalized ? this._baseRefillRate / 2 : this._baseRefillRate; } + /** The current effective backoff window in milliseconds. */ + private get _effectiveBackoffMs(): number { + if (this._consecutiveBreaches === 0) return this._penaltyDurationMs; + return Math.min( + this._penaltyDurationMs * Math.pow(2, this._consecutiveBreaches - 1), + this._maxBackoffMs, + ); + } + /** Resize the bucket from the server's most recently observed rate-limit headers. */ update(info: RateLimitInfo): void { const now = this._now(); this._refill(now); + // If we were penalized and a full backoff window has elapsed with no new + // breach, reset the consecutive-breach counter. + if (!this._penalized && this._consecutiveBreaches > 0) { + const effectiveWindow = this._effectiveBackoffMs; + if (now - this._penaltyWindowStart >= effectiveWindow) { + this._consecutiveBreaches = 0; + } + } + if (!Number.isFinite(info.limit)) { this._limit = Infinity; this._tokens = Infinity; @@ -79,18 +113,39 @@ export class AdaptiveThrottle { this.update(parseRateLimitHeaders(headers)); } - /** Record a 429 response: halves the refill rate for `penaltyDurationMs`. */ + /** Record a 429 response: applies exponential backoff. Each consecutive + * breach doubles the effective penalty window (capped at `maxBackoffMs`). + * The backoff resets after a full window with no breach. */ recordRateLimited(): void { const now = this._now(); this._refill(now); this._penalized = true; + this._consecutiveBreaches += 1; + this._penaltyWindowStart = now; + + const backoffMs = Math.min( + this._penaltyDurationMs * Math.pow(2, this._consecutiveBreaches - 1), + this._maxBackoffMs, + ); if (this._penaltyTimer !== null) clearTimeout(this._penaltyTimer); this._penaltyTimer = setTimeout(() => { this._penaltyTimer = null; this._penalized = false; if (this._queue.length > 0) this._scheduleDrain(); - }, this._penaltyDurationMs); + }, backoffMs); + } + + /** + * Returns the current backoff multiplier (2^consecutiveBreaches, capped so + * that `base * multiplier <= maxBackoffMs`). Returns 1 when no breach has + * occurred. Useful for observability and dashboards. + */ + getBackoffMultiplier(): number { + if (this._consecutiveBreaches === 0) return 1; + const raw = Math.pow(2, this._consecutiveBreaches - 1); + const capped = this._maxBackoffMs / this._penaltyDurationMs; + return Math.min(raw, capped); } /** Resolve once a token is available, consuming it. */ diff --git a/src/timeline/PaymentTimelineReconstructor.ts b/src/timeline/PaymentTimelineReconstructor.ts index 4355a13..89e6ea1 100644 --- a/src/timeline/PaymentTimelineReconstructor.ts +++ b/src/timeline/PaymentTimelineReconstructor.ts @@ -116,6 +116,7 @@ export class PaymentTimelineReconstructor { timestamp: e.timestamp, ledger: e.ledger, type: computeEventType(e), + status: "completed" as const, data: rawData, source: "soroban" as const, txHash, @@ -160,6 +161,7 @@ export class PaymentTimelineReconstructor { : ledger, ledger, type: "payment_received", + status: "completed", data: { paymentId: op.id, amount: op.amount, diff --git a/src/types/timeline.ts b/src/types/timeline.ts index 27e47f8..636341a 100644 --- a/src/types/timeline.ts +++ b/src/types/timeline.ts @@ -8,10 +8,23 @@ export type TimelineEventType = export type TimelineSource = "soroban" | "horizon"; +/** + * Lifecycle status of a single timeline entry. + * A string union (rather than a numeric enum) is used for clean JSON + * serialisation — the values round-trip through JSON without conversion. + */ +export type TimelineEntryStatus = + | "pending" + | "in_progress" + | "completed" + | "failed"; + export interface TimelineEntry { timestamp: number; ledger: number; type: TimelineEventType; + /** Current lifecycle status of this entry. */ + status: TimelineEntryStatus; data: Record; source: TimelineSource; txHash?: string; diff --git a/test/AdaptiveThrottle.test.ts b/test/AdaptiveThrottle.test.ts index 74ccc56..9b175ee 100644 --- a/test/AdaptiveThrottle.test.ts +++ b/test/AdaptiveThrottle.test.ts @@ -176,3 +176,109 @@ describe("AdaptiveThrottle", () => { expect(throttle.getStats().penalized).toBe(true); }); }); + +// --------------------------------------------------------------------------- +// Exponential backoff (getBackoffMultiplier + maxBackoffMs) +// --------------------------------------------------------------------------- + +import { DEFAULT_MAX_BACKOFF_MS } from "../src/throttle/AdaptiveThrottle.js"; + +describe("exponential backoff", () => { + it("DEFAULT_MAX_BACKOFF_MS is 60 000", () => { + expect(DEFAULT_MAX_BACKOFF_MS).toBe(60_000); + }); + + it("getBackoffMultiplier() returns 1 with no breaches", () => { + const throttle = new AdaptiveThrottle(); + expect(throttle.getBackoffMultiplier()).toBe(1); + }); + + it("getBackoffMultiplier() doubles after each consecutive breach", () => { + vi.useFakeTimers(); + let now = 0; + const throttle = new AdaptiveThrottle({ now: () => now, penaltyDurationMs: 1_000, maxBackoffMs: 60_000 }); + throttle.update({ limit: 10, remaining: 10, resetAt: now + 1_000 }); + + throttle.recordRateLimited(); // breach 1 → multiplier = 1 (2^0) + expect(throttle.getBackoffMultiplier()).toBe(1); + + throttle.recordRateLimited(); // breach 2 → multiplier = 2 (2^1) + expect(throttle.getBackoffMultiplier()).toBe(2); + + throttle.recordRateLimited(); // breach 3 → multiplier = 4 (2^2) + expect(throttle.getBackoffMultiplier()).toBe(4); + + vi.useRealTimers(); + }); + + it("effective window doubles with each breach", async () => { + vi.useFakeTimers(); + let now = 0; + const base = 1_000; + const throttle = new AdaptiveThrottle({ now: () => now, penaltyDurationMs: base, maxBackoffMs: 60_000 }); + throttle.update({ limit: 10, remaining: 0, resetAt: now + base }); + + // First breach → 1 000 ms window + throttle.recordRateLimited(); + expect(throttle.getStats().penalized).toBe(true); + + now += base - 1; + await vi.advanceTimersByTimeAsync(base - 1); + expect(throttle.getStats().penalized).toBe(true); + + now += 1; + await vi.advanceTimersByTimeAsync(1); + expect(throttle.getStats().penalized).toBe(false); + + // Second breach → 2 000 ms window + throttle.recordRateLimited(); + now += base; // only 1 000ms of 2 000ms elapsed + await vi.advanceTimersByTimeAsync(base); + expect(throttle.getStats().penalized).toBe(true); + + now += base; + await vi.advanceTimersByTimeAsync(base); + expect(throttle.getStats().penalized).toBe(false); + + vi.useRealTimers(); + }); + + it("backoff is capped at maxBackoffMs", () => { + vi.useFakeTimers(); + let now = 0; + const throttle = new AdaptiveThrottle({ now: () => now, penaltyDurationMs: 1_000, maxBackoffMs: 4_000 }); + throttle.update({ limit: 5, remaining: 5, resetAt: now + 1_000 }); + + // 5 breaches: 1000, 2000, 4000, (cap)4000, (cap)4000 + for (let i = 0; i < 5; i++) throttle.recordRateLimited(); + + // multiplier should be capped: maxBackoffMs / penaltyDurationMs = 4 + expect(throttle.getBackoffMultiplier()).toBe(4); + + vi.useRealTimers(); + }); + + it("backoff resets after a full window with no breach", async () => { + vi.useFakeTimers(); + let now = 0; + const base = 1_000; + const throttle = new AdaptiveThrottle({ now: () => now, penaltyDurationMs: base, maxBackoffMs: 60_000 }); + throttle.update({ limit: 10, remaining: 10, resetAt: now + base }); + + // Two breaches → multiplier 2 + throttle.recordRateLimited(); + throttle.recordRateLimited(); + expect(throttle.getBackoffMultiplier()).toBe(2); + + // Let penalty expire + now += base * 2 + 1; + await vi.advanceTimersByTimeAsync(base * 2 + 1); + expect(throttle.getStats().penalized).toBe(false); + + // Calling update() after a full window with no breach resets the counter + throttle.update({ limit: 10, remaining: 10, resetAt: now + base }); + expect(throttle.getBackoffMultiplier()).toBe(1); + + vi.useRealTimers(); + }); +}); diff --git a/test/bridge.test.ts b/test/bridge.test.ts index 37d6457..1a52e71 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -12,6 +12,8 @@ import { submitBridgePayment, computePayloadHash, DEFAULT_CHAIN_CONFIGS, + SUPPORTED_CHAIN_IDS, + BridgeChainMismatchError, } from "../src/bridge.js"; import type { ChainId, @@ -252,7 +254,7 @@ describe("buildBridgePayment", () => { }; it("returns a BridgePaymentRequest with all required fields", () => { - const req = buildBridgePayment(baseParams); + const req = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); expect(req.sourceChain).toBe("ethereum"); expect(req.invoiceId).toBe(INVOICE_ID); expect(req.payer).toBe(PAYER); @@ -264,24 +266,24 @@ describe("buildBridgePayment", () => { }); it("nonce is a non-empty hex string", () => { - const req = buildBridgePayment(baseParams); + const req = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); expect(req.nonce).toMatch(/^[0-9a-f]+$/); expect(req.nonce.length).toBeGreaterThan(0); }); it("payloadHash has length 64", () => { - const req = buildBridgePayment(baseParams); + const req = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); expect(req.payloadHash).toHaveLength(64); }); it("each call produces a unique nonce", () => { - const req1 = buildBridgePayment(baseParams); - const req2 = buildBridgePayment(baseParams); + const req1 = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); + const req2 = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); expect(req1.nonce).not.toBe(req2.nonce); }); it("payloadHash matches computePayloadHash with same inputs", () => { - const req = buildBridgePayment(baseParams); + const req = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); const expected = computePayloadHash( req.sourceChain, req.invoiceId, @@ -300,13 +302,13 @@ describe("buildBridgePayment", () => { sourceChain: "solana", sourceToken: SOL_TOKEN, }; - const req = buildBridgePayment(solParams); + const req = buildBridgePayment(solParams, { targetChainId: "solana" }); expect(req.sourceChain).toBe("solana"); expect(req.sourceToken).toBe(SOL_TOKEN); }); it("preserves all parameter values", () => { - const req = buildBridgePayment(baseParams); + const req = buildBridgePayment(baseParams, { targetChainId: "ethereum" }); expect(req.invoiceId).toBe(baseParams.invoiceId); expect(req.payer).toBe(baseParams.payer); expect(req.amount).toBe(baseParams.amount); @@ -384,7 +386,7 @@ describe("submitBridgePayment", () => { it("throws when payloadHash is missing", async () => { const proof = makeProof({ payloadHash: "" }); await expect( - submitBridgePayment(proof, CLIENT_CONFIG), + submitBridgePayment(proof, CLIENT_CONFIG, { targetChainId: "ethereum" }), ).rejects.toThrow("missing payloadHash"); }); @@ -394,14 +396,14 @@ describe("submitBridgePayment", () => { signature: "", }; await expect( - submitBridgePayment(proof, CLIENT_CONFIG), + submitBridgePayment(proof, CLIENT_CONFIG, { targetChainId: "ethereum" }), ).rejects.toThrow("missing signature"); }); it("calls getAccount and sendTransaction, returns txHash", async () => { const deps = buildDeps("bridge_tx_001"); const proof = makeProof(); - const result = await submitBridgePayment(proof, CLIENT_CONFIG, deps); + const result = await submitBridgePayment(proof, CLIENT_CONFIG, { targetChainId: "ethereum" }, deps); expect(result).toHaveProperty("txHash", "bridge_tx_001"); expect(deps.server.getAccount).toHaveBeenCalledWith(PAYER); @@ -414,7 +416,7 @@ describe("submitBridgePayment", () => { getTransaction: vi.fn().mockResolvedValue({ status: "FAILED", txHash: "fail_tx" }), }); const proof = makeProof(); - await expect(submitBridgePayment(proof, CLIENT_CONFIG, deps)).rejects.toThrow( + await expect(submitBridgePayment(proof, CLIENT_CONFIG, { targetChainId: "ethereum" }, deps)).rejects.toThrow( "failed on-chain", ); }); @@ -430,7 +432,7 @@ describe("submitBridgePayment", () => { SorobanRpc.Api.isSimulationError = (_r: any) => true; try { const proof = makeProof(); - await expect(submitBridgePayment(proof, CLIENT_CONFIG, deps)).rejects.toThrow( + await expect(submitBridgePayment(proof, CLIENT_CONFIG, { targetChainId: "ethereum" }, deps)).rejects.toThrow( "simulation failed", ); } finally { @@ -443,7 +445,7 @@ describe("submitBridgePayment", () => { sendTransaction: vi.fn().mockResolvedValue({ status: "ERROR", hash: "err_hash" }), }); const proof = makeProof(); - await expect(submitBridgePayment(proof, CLIENT_CONFIG, deps)).rejects.toThrow( + await expect(submitBridgePayment(proof, CLIENT_CONFIG, { targetChainId: "ethereum" }, deps)).rejects.toThrow( "Bridge pay transaction failed", ); }); @@ -455,14 +457,17 @@ describe("submitBridgePayment", () => { describe("type integration", () => { it("BridgePaymentRequest can be wrapped in SignedBridgeProof", () => { - const request = buildBridgePayment({ - sourceChain: "solana", - payer: PAYER, - invoiceId: "99", - amount: 5_000_000n, - sourceToken: SOL_TOKEN, - deadline: DEADLINE, - }); + const request = buildBridgePayment( + { + sourceChain: "solana", + payer: PAYER, + invoiceId: "99", + amount: 5_000_000n, + sourceToken: SOL_TOKEN, + deadline: DEADLINE, + }, + { targetChainId: "solana" }, + ); const proof: SignedBridgeProof = { request, @@ -475,3 +480,118 @@ describe("type integration", () => { expect(proof.signerAddress).toBeTruthy(); }); }); + +// --------------------------------------------------------------------------- +// SUPPORTED_CHAIN_IDS & BridgeChainMismatchError +// --------------------------------------------------------------------------- + +describe("SUPPORTED_CHAIN_IDS", () => { + it("exports ethereum and solana", () => { + expect(SUPPORTED_CHAIN_IDS).toContain("ethereum"); + expect(SUPPORTED_CHAIN_IDS).toContain("solana"); + }); + + it("does not include unknown chains", () => { + expect(SUPPORTED_CHAIN_IDS).not.toContain("bitcoin"); + expect(SUPPORTED_CHAIN_IDS).not.toContain("polygon"); + }); +}); + +describe("buildBridgePayment — chain ID validation", () => { + const baseParams: BridgePaymentParams = { + sourceChain: "ethereum", + payer: PAYER, + invoiceId: INVOICE_ID, + amount: 1_000_000n, + sourceToken: ETH_TOKEN, + deadline: DEADLINE, + }; + + it("throws BridgeChainMismatchError when targetChainId is missing", () => { + expect(() => buildBridgePayment(baseParams)).toThrow(BridgeChainMismatchError); + }); + + it("throws BridgeChainMismatchError when targetChainId is unsupported", () => { + expect(() => + buildBridgePayment(baseParams, { targetChainId: "bitcoin" as any }), + ).toThrow(BridgeChainMismatchError); + }); + + it("error message names the bad chain ID", () => { + try { + buildBridgePayment(baseParams, { targetChainId: "polygon" as any }); + expect.fail("should have thrown"); + } catch (err) { + expect((err as Error).message).toMatch(/polygon/); + expect((err as Error).message).toMatch(/Supported/i); + } + }); + + it("error message mentions missing targetChainId when undefined", () => { + try { + buildBridgePayment(baseParams, undefined); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(BridgeChainMismatchError); + } + }); + + it("succeeds with ethereum targetChainId", () => { + expect(() => + buildBridgePayment(baseParams, { targetChainId: "ethereum" }), + ).not.toThrow(); + }); + + it("succeeds with solana targetChainId", () => { + expect(() => + buildBridgePayment( + { ...baseParams, sourceChain: "solana", sourceToken: SOL_TOKEN }, + { targetChainId: "solana" }, + ), + ).not.toThrow(); + }); +}); + +describe("submitBridgePayment — chain ID validation", () => { + const CLIENT_CONFIG = { + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase: "Test SDF Network ; September 2015", + contractId: "CCJGSXWNBGGKQ4YLEFLNFK55UQ3IXNXZ7WOCBX3XQLQJWBJD2A4XTAQ", + }; + + const validProof: SignedBridgeProof = { + request: { + sourceChain: "ethereum", + invoiceId: INVOICE_ID, + payer: PAYER, + amount: 1_000_000n, + sourceToken: ETH_TOKEN, + deadline: DEADLINE, + nonce: "aabbccdd", + payloadHash: "a".repeat(64), + }, + signature: "0xdeadbeef", + signerAddress: "0xabc", + }; + + it("throws BridgeChainMismatchError before any network call when targetChainId is missing", async () => { + await expect(submitBridgePayment(validProof, CLIENT_CONFIG)).rejects.toThrow( + BridgeChainMismatchError, + ); + }); + + it("throws BridgeChainMismatchError for unsupported chain", async () => { + await expect( + submitBridgePayment(validProof, CLIENT_CONFIG, { targetChainId: "tron" as any }), + ).rejects.toThrow(BridgeChainMismatchError); + }); + + it("error is thrown before any server.getAccount call", async () => { + const mockGetAccount = vi.fn(); + // If getAccount was called it means we got past the chain check — that's the bug. + await expect( + submitBridgePayment(validProof, CLIENT_CONFIG, { targetChainId: "unknown" as any }), + ).rejects.toThrow(BridgeChainMismatchError); + expect(mockGetAccount).not.toHaveBeenCalled(); + }); +}); diff --git a/test/bulkImportValidator.test.ts b/test/bulkImportValidator.test.ts index e595571..3ce3eaf 100644 --- a/test/bulkImportValidator.test.ts +++ b/test/bulkImportValidator.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { validateBulkImport } from "../src/bulkImportValidator.js"; +import { validateBulkImport, SUPPORTED_SCHEMA_VERSIONS } from "../src/bulkImportValidator.js"; +import type { BulkImportPayload } from "../src/bulkImportValidator.js"; import type { CreateInvoiceParams } from "../src/types.js"; /** Helper: future deadline (1 hour from now). */ @@ -199,3 +200,97 @@ describe("bulkImportValidator", () => { }); }); }); + +// --------------------------------------------------------------------------- +// schemaVersion validation (SUPPORTED_SCHEMA_VERSIONS) +// --------------------------------------------------------------------------- +describe("schemaVersion validation", () => { + const stableNow2 = Math.floor(new Date("2026-01-15T00:00:00Z").getTime() / 1000); + const futureDeadline2 = stableNow2 + 7200; + + function payloadRow(overrides: Partial = {}): CreateInvoiceParams { + return { + creator: "GABCDEFG1234567890", + recipients: [{ address: "GXYZ1234567890", amount: 500n }], + token: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + deadline: futureDeadline2, + ...overrides, + }; + } + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-15T00:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("exports SUPPORTED_SCHEMA_VERSIONS containing 1 and 2", () => { + expect(SUPPORTED_SCHEMA_VERSIONS).toContain(1); + expect(SUPPORTED_SCHEMA_VERSIONS).toContain(2); + }); + + it("accepts a versioned payload with schemaVersion 1", () => { + const payload: BulkImportPayload = { + schemaVersion: 1, + rows: [payloadRow()], + }; + const result = validateBulkImport(payload); + expect(result.errors).toHaveLength(0); + expect(result.validRows).toEqual([0]); + }); + + it("accepts a versioned payload with schemaVersion 2", () => { + const payload: BulkImportPayload = { + schemaVersion: 2, + rows: [payloadRow()], + }; + const result = validateBulkImport(payload); + expect(result.errors).toHaveLength(0); + expect(result.validRows).toEqual([0]); + }); + + it("rejects an unsupported schemaVersion with a descriptive error", () => { + const payload: BulkImportPayload = { + schemaVersion: 99, + rows: [payloadRow()], + }; + const result = validateBulkImport(payload); + expect(result.validRows).toEqual([]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].field).toBe("schemaVersion"); + expect(result.errors[0].message).toMatch(/99/); + expect(result.errors[0].message).toMatch(/Supported/i); + }); + + it("rejects schemaVersion 0 (not in supported list)", () => { + const payload: BulkImportPayload = { + schemaVersion: 0, + rows: [payloadRow()], + }; + const result = validateBulkImport(payload); + expect(result.errors[0].field).toBe("schemaVersion"); + }); + + it("still validates rows normally when schemaVersion is valid", () => { + const payload: BulkImportPayload = { + schemaVersion: 1, + rows: [ + payloadRow(), // valid + payloadRow({ recipients: [] }), // invalid + ], + }; + const result = validateBulkImport(payload); + expect(result.validRows).toEqual([0]); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]).toMatchObject({ row: 1, field: "recipients" }); + }); + + it("raw array (no schemaVersion) continues to work unchanged", () => { + const result = validateBulkImport([payloadRow(), payloadRow()]); + expect(result.validRows).toEqual([0, 1]); + expect(result.errors).toHaveLength(0); + }); +}); diff --git a/test/timeline.test.ts b/test/timeline.test.ts index 6726b94..a1dd913 100644 --- a/test/timeline.test.ts +++ b/test/timeline.test.ts @@ -7,7 +7,7 @@ vi.mock("../src/events.js", () => ({ })); import { replayEvents } from "../src/events.js"; -import type { TimelineEventType } from "../src/types/timeline.js"; +import type { TimelineEventType, TimelineEntryStatus } from "../src/types/timeline.js"; const mockReplayEvents = replayEvents as ReturnType; @@ -394,4 +394,90 @@ describe("PaymentTimelineReconstructor", () => { expect(result.entries[1].ledger).toBe(3); }); }); -}); \ No newline at end of file +}); + +// --------------------------------------------------------------------------- +// TimelineEntryStatus — status field on TimelineEntry +// --------------------------------------------------------------------------- + +describe("TimelineEntryStatus and TimelineEntry.status", () => { + let reconstructor: PaymentTimelineReconstructor; + let mockServer: { getEvents: ReturnType }; + let mockHorizonChain: ReturnType; + + beforeEach(() => { + mockServer = { getEvents: vi.fn() }; + mockHorizonChain = makeMockHorizonChain(); + mockReplayEvents.mockReset(); + mockHorizonChain.call.mockReset(); + + reconstructor = new PaymentTimelineReconstructor({ + rpcUrl: "https://soroban-testnet.stellar.org", + contractId: "CCONTRACT", + networkPassphrase: "Test SDF Network ; September 2015", + server: mockServer as never, + horizonServer: { operations: mockHorizonChain.operations } as never, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("all Soroban entries carry status: 'completed'", async () => { + mockReplayEvents.mockResolvedValue([ + sorobanEvent({ type: "created", invoiceId: "42", ledger: 1, timestamp: 100 }), + sorobanEvent({ type: "payment", invoiceId: "42", ledger: 2, timestamp: 200 }), + ]); + mockHorizonChain.call.mockResolvedValue({ records: [] }); + + const result = await reconstructor.rebuild("42"); + for (const entry of result.entries) { + expect(entry.status).toBe("completed" satisfies TimelineEntryStatus); + } + }); + + it("Horizon payment entries carry status: 'completed'", async () => { + mockReplayEvents.mockResolvedValue([]); + mockHorizonChain.call.mockResolvedValue({ + records: [ + horizonPayment({ ledger: 5, transaction_hash: "tx-h1", memo: "invoice:42" }), + ], + }); + + const result = await reconstructor.rebuild("42"); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].status).toBe("completed" satisfies TimelineEntryStatus); + }); + + it("status field is present on every entry regardless of type", async () => { + mockReplayEvents.mockResolvedValue([ + sorobanEvent({ type: "created", invoiceId: "42", ledger: 1, timestamp: 100 }), + sorobanEvent({ type: "payment", invoiceId: "42", ledger: 2, timestamp: 200 }), + sorobanEvent({ type: "released", invoiceId: "42", ledger: 3, timestamp: 300 }), + sorobanEvent({ type: "refunded", invoiceId: "42", ledger: 4, timestamp: 400 }), + sorobanEvent({ type: "cancelled", invoiceId: "42", ledger: 5, timestamp: 500 }), + sorobanEvent({ type: "frozen", invoiceId: "42", ledger: 6, timestamp: 600 }), + sorobanEvent({ type: "unfrozen", invoiceId: "42", ledger: 7, timestamp: 700 }), + ]); + mockHorizonChain.call.mockResolvedValue({ records: [] }); + + const result = await reconstructor.rebuild("42"); + + expect(result.entries).toHaveLength(7); + for (const entry of result.entries) { + expect(entry).toHaveProperty("status"); + expect(["pending", "in_progress", "completed", "failed"]).toContain(entry.status); + } + }); + + it("TypeScript: TimelineEntryStatus union contains all four values", () => { + // Compile-time check — these assignments would fail to compile if the + // union were incomplete. + const s1: TimelineEntryStatus = "pending"; + const s2: TimelineEntryStatus = "in_progress"; + const s3: TimelineEntryStatus = "completed"; + const s4: TimelineEntryStatus = "failed"; + expect([s1, s2, s3, s4]).toHaveLength(4); + }); +});