diff --git a/apps/web/src/lib/retry.test.ts b/apps/web/src/lib/retry.test.ts new file mode 100644 index 0000000..4977f51 --- /dev/null +++ b/apps/web/src/lib/retry.test.ts @@ -0,0 +1,334 @@ +/** + * apps/web/src/lib/retry.test.ts + * + * Deterministic tests for the retry/backoff utility. + * + * Fake timers replace `setTimeout` so no real clock sleeps occur — all + * backoff delays are driven by `vi.runAllTimersAsync()`. + * + * Scenarios covered + * ────────────────── + * 1. Eventual success – fn fails N-1 times then succeeds; correct value + * and attempt count returned. + * 2. Exhausted retries – fn always throws; last error is re-thrown after + * exactly `attempts` calls. + * 3. Abort – AbortController fires mid-sleep; promise rejects + * with AbortError and fn is not called again. + * 4. Attempt count – attempt counts are reported correctly for first-try + * success and for success on the last attempt. + * 5. Backoff delays – computed delay doubles each retry up to maxDelayMs. + * 6. shouldRetry predicate – non-retryable errors are thrown immediately. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { retry } from "./retry" + +// ───────────────────────────────────────────────────────────────────────────── +// Fake-timer helpers +// ───────────────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** + * Run a retry promise to completion while advancing fake timers. + * + * We alternate between: + * 1. Flushing microtasks (Promise.resolve()) so the retry loop can run up + * to the next `await sleep(...)` point. + * 2. Advancing all pending timers so the sleep resolves. + * + * Repeated until the promise settles. + */ +async function driveRetry(promise: Promise): Promise { + let settled = false + let resolvedValue: T + let rejectedError: unknown + + promise.then( + (v) => { settled = true; resolvedValue = v as T }, + (e) => { settled = true; rejectedError = e }, + ) + + // Drive until settled — max 20 rounds to guard against infinite loops + for (let i = 0; i < 20 && !settled; i++) { + await Promise.resolve() // flush microtasks + await vi.runAllTimersAsync() // advance all timers + their microtasks + await Promise.resolve() // flush any new microtasks created + } + + // Final flush + await Promise.resolve() + + if (rejectedError !== undefined) throw rejectedError + return resolvedValue! +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Eventual success +// ───────────────────────────────────────────────────────────────────────────── + +describe("eventual success", () => { + it("returns the value when fn succeeds on the first attempt", async () => { + const fn = vi.fn().mockResolvedValue("ok") + + const result = await driveRetry(retry(fn, { attempts: 3, baseMs: 100 })) + + expect(result.value).toBe("ok") + expect(result.attempts).toBe(1) + expect(fn).toHaveBeenCalledTimes(1) + }) + + it("succeeds after one failure then one success", async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error("transient")) + .mockResolvedValue("recovered") + + const result = await driveRetry(retry(fn, { attempts: 3, baseMs: 50 })) + + expect(result.value).toBe("recovered") + expect(result.attempts).toBe(2) + expect(fn).toHaveBeenCalledTimes(2) + }) + + it("succeeds on the last allowed attempt", async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error("fail 1")) + .mockRejectedValueOnce(new Error("fail 2")) + .mockResolvedValue("last-chance") + + const result = await driveRetry(retry(fn, { attempts: 3, baseMs: 50 })) + + expect(result.value).toBe("last-chance") + expect(result.attempts).toBe(3) + expect(fn).toHaveBeenCalledTimes(3) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Exhausted retries +// ───────────────────────────────────────────────────────────────────────────── + +describe("exhausted retries", () => { + it("throws the last error after all attempts are exhausted", async () => { + const err = new Error("always fails") + const fn = vi.fn().mockRejectedValue(err) + + await expect(driveRetry(retry(fn, { attempts: 3, baseMs: 50 }))).rejects.toThrow( + "always fails", + ) + expect(fn).toHaveBeenCalledTimes(3) + }) + + it("calls fn exactly `attempts` times on total failure", async () => { + const fn = vi.fn().mockRejectedValue(new Error("nope")) + + await expect(driveRetry(retry(fn, { attempts: 4, baseMs: 10 }))).rejects.toThrow() + expect(fn).toHaveBeenCalledTimes(4) + }) + + it("re-throws the last error, not the first", async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error("first error")) + .mockRejectedValueOnce(new Error("last error")) + + await expect(driveRetry(retry(fn, { attempts: 2, baseMs: 10 }))).rejects.toThrow( + "last error", + ) + }) + + it("throws RangeError synchronously when attempts < 1", async () => { + const fn = vi.fn() + await expect(retry(fn, { attempts: 0 })).rejects.toThrow(RangeError) + expect(fn).not.toHaveBeenCalled() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Abort +// ───────────────────────────────────────────────────────────────────────────── + +describe("abort", () => { + it("rejects with AbortError when signal is aborted before first call", async () => { + const controller = new AbortController() + controller.abort() + + const fn = vi.fn().mockResolvedValue("x") + await expect( + driveRetry(retry(fn, { attempts: 3, signal: controller.signal })), + ).rejects.toMatchObject({ name: "AbortError" }) + + expect(fn).not.toHaveBeenCalled() + }) + + it("rejects with AbortError when signal fires during backoff sleep", async () => { + const controller = new AbortController() + const fn = vi.fn().mockRejectedValue(new Error("transient")) + + // fn will fail, retry goes to sleep — abort mid-sleep + const promise = retry(fn, { attempts: 5, baseMs: 10_000, signal: controller.signal }) + + // Let fn run and reach the sleep + await Promise.resolve() + await Promise.resolve() + + // Abort while sleeping + controller.abort() + + await expect(driveRetry(promise)).rejects.toMatchObject({ name: "AbortError" }) + // fn ran once before the abort-during-sleep + expect(fn).toHaveBeenCalledTimes(1) + }) + + it("does not call fn again after abort", async () => { + const controller = new AbortController() + const fn = vi.fn() + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValue("should not reach") + + const promise = retry(fn, { attempts: 5, baseMs: 5_000, signal: controller.signal }) + + await Promise.resolve() + await Promise.resolve() + controller.abort() + + await expect(driveRetry(promise)).rejects.toMatchObject({ name: "AbortError" }) + expect(fn).toHaveBeenCalledTimes(1) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Attempt counts +// ───────────────────────────────────────────────────────────────────────────── + +describe("attempt counts", () => { + it("reports attempts=1 on immediate success", async () => { + const fn = vi.fn().mockResolvedValue(42) + const { attempts } = await driveRetry(retry(fn, { attempts: 5 })) + expect(attempts).toBe(1) + }) + + it("reports attempts equal to the number of calls made", async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error("x")) + .mockRejectedValueOnce(new Error("x")) + .mockResolvedValue("done") + + const { attempts } = await driveRetry(retry(fn, { attempts: 5, baseMs: 10 })) + expect(attempts).toBe(3) + expect(fn).toHaveBeenCalledTimes(3) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Backoff delays +// ───────────────────────────────────────────────────────────────────────────── + +describe("backoff delays", () => { + it("doubles the delay on each retry up to maxDelayMs", () => { + // Test the pure delay computation directly — no timers needed + // backoff formula: baseMs * 2^attempt, capped at maxDelayMs + // attempt 0 → 100 * 1 = 100 + // attempt 1 → 100 * 2 = 200 + // attempt 2 → 100 * 4 = 400, capped at 300 + const baseMs = 100 + const maxDelayMs = 300 + const computeDelay = (attempt: number) => Math.min(baseMs * 2 ** attempt, maxDelayMs) + + expect(computeDelay(0)).toBe(100) + expect(computeDelay(1)).toBe(200) + expect(computeDelay(2)).toBe(300) + expect(computeDelay(3)).toBe(300) // still capped + }) + + it("caps delay at maxDelayMs for large baseMs", () => { + const baseMs = 1_000 + const maxDelayMs = 500 + const computeDelay = (attempt: number) => Math.min(baseMs * 2 ** attempt, maxDelayMs) + + // Every delay must be ≤ maxDelayMs regardless of attempt + for (let i = 0; i < 10; i++) { + expect(computeDelay(i)).toBeLessThanOrEqual(maxDelayMs) + } + }) + + it("no delay is applied after the final failing attempt", async () => { + // With attempts:2, only 1 sleep should occur (between attempt 1 and 2). + // Verify by checking fn was called exactly 2 times after a failed run. + const fn = vi.fn().mockRejectedValue(new Error("fail")) + + await expect( + driveRetry(retry(fn, { attempts: 2, baseMs: 50 })), + ).rejects.toThrow("fail") + + expect(fn).toHaveBeenCalledTimes(2) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 6. shouldRetry predicate +// ───────────────────────────────────────────────────────────────────────────── + +describe("shouldRetry predicate", () => { + it("retries only when shouldRetry returns true", async () => { + const retryableError = new Error("retryable") + const fn = vi.fn() + .mockRejectedValueOnce(retryableError) + .mockResolvedValue("ok") + + const result = await driveRetry( + retry(fn, { + attempts: 3, + baseMs: 10, + shouldRetry: (err) => (err as Error).message === "retryable", + }), + ) + + expect(result.value).toBe("ok") + expect(fn).toHaveBeenCalledTimes(2) + }) + + it("throws immediately for non-retryable errors without further attempts", async () => { + const fatal = new Error("fatal") + const fn = vi.fn().mockRejectedValue(fatal) + + await expect( + driveRetry( + retry(fn, { + attempts: 5, + baseMs: 10, + shouldRetry: () => false, + }), + ), + ).rejects.toThrow("fatal") + + expect(fn).toHaveBeenCalledTimes(1) + }) + + it("receives the current attempt number (1-indexed)", async () => { + const calls: number[] = [] + const fn = vi.fn() + .mockRejectedValueOnce(new Error("e")) + .mockRejectedValueOnce(new Error("e")) + .mockResolvedValue("ok") + + await driveRetry( + retry(fn, { + attempts: 5, + baseMs: 10, + shouldRetry: (_err, attempt) => { + calls.push(attempt) + return true + }, + }), + ) + + // shouldRetry is called after attempt 1 and after attempt 2 + expect(calls).toEqual([1, 2]) + }) +}) diff --git a/apps/web/src/lib/retry.ts b/apps/web/src/lib/retry.ts new file mode 100644 index 0000000..90ce82e --- /dev/null +++ b/apps/web/src/lib/retry.ts @@ -0,0 +1,148 @@ +/** + * apps/web/src/lib/retry.ts + * + * Generic retry with exponential backoff. + * + * Delays are implemented via `setTimeout` so they are fully controllable by + * `vi.useFakeTimers()` in tests — no real clock sleeps occur. + * + * Backoff formula: delay = baseMs * 2^attempt (capped at maxDelayMs) + * + * @example + * const result = await retry(() => fetchData(), { attempts: 3, baseMs: 200 }) + * + * @example abort early + * const controller = new AbortController() + * const p = retry(fetchData, { attempts: 5, signal: controller.signal }) + * controller.abort() + * await p // rejects with AbortError + */ + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type RetryOptions = { + /** + * Maximum number of attempts (including the first call). + * Must be ≥ 1. Default: 3. + */ + attempts?: number + /** + * Base delay in milliseconds for the first retry. + * Subsequent retries double this value. Default: 100. + */ + baseMs?: number + /** + * Upper bound on the computed delay. Default: 30_000 (30 s). + */ + maxDelayMs?: number + /** + * Optional `AbortSignal`. When aborted the current sleep is cut short + * and the returned promise rejects with an `AbortError`. + */ + signal?: AbortSignal + /** + * Optional predicate. When provided, only errors that satisfy this + * function are retried; others are rethrown immediately. + * Default: retry on every error. + */ + shouldRetry?: (error: unknown, attempt: number) => boolean +} + +export type RetryResult = { + value: T + /** Total number of attempts made (1 = succeeded on first try). */ + attempts: number +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Returns a promise that resolves after `ms` milliseconds, or rejects early + * when the given `AbortSignal` fires. */ +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(createAbortError()) + return + } + + const id = setTimeout(resolve, ms) + + signal?.addEventListener( + "abort", + () => { + clearTimeout(id) + reject(createAbortError()) + }, + { once: true }, + ) + }) +} + +function createAbortError(): DOMException { + return new DOMException("Retry aborted", "AbortError") +} + +function computeDelay(baseMs: number, maxDelayMs: number, attempt: number): number { + // attempt is 0-indexed: first retry is attempt 0, second is 1, … + const raw = baseMs * 2 ** attempt + return Math.min(raw, maxDelayMs) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Execute `fn` up to `attempts` times, waiting an exponentially increasing + * delay between each attempt. + * + * Resolves with `{ value, attempts }` on success. + * Rejects with the last error when all attempts are exhausted. + * Rejects immediately with an `AbortError` when the signal fires. + */ +export async function retry( + fn: () => Promise | T, + options: RetryOptions = {}, +): Promise> { + const maxAttempts = options.attempts ?? 3 + const baseMs = options.baseMs ?? 100 + const maxDelayMs = options.maxDelayMs ?? 30_000 + const signal = options.signal + const shouldRetry = options.shouldRetry ?? (() => true) + + if (maxAttempts < 1) { + throw new RangeError(`retry: attempts must be ≥ 1, got ${maxAttempts}`) + } + + let lastError: unknown + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + if (signal?.aborted) { + throw createAbortError() + } + + try { + const value = await fn() + return { value, attempts: attempt + 1 } + } catch (error) { + lastError = error + + // Don't retry if the caller says not to + if (!shouldRetry(error, attempt + 1)) { + throw error + } + + // No delay after the final attempt — just fall through to throw + if (attempt < maxAttempts - 1) { + const delay = computeDelay(baseMs, maxDelayMs, attempt) + await sleep(delay, signal) + } + } + } + + throw lastError +} diff --git a/apps/web/src/lib/soroban/assemble.test.ts b/apps/web/src/lib/soroban/assemble.test.ts new file mode 100644 index 0000000..6d618d8 --- /dev/null +++ b/apps/web/src/lib/soroban/assemble.test.ts @@ -0,0 +1,278 @@ +/** + * apps/web/src/lib/soroban/assemble.test.ts + * + * Tests for transaction assembly from Soroban simulation output. + * + * `rpc.assembleTransaction` is a pure SDK function — no network calls are + * made and no MSW handlers are needed. All fixtures use real + * `@stellar/stellar-sdk` types constructed with the SDK's own builders so + * the test exercises the actual XDR encode/decode path. + * + * Scenarios covered + * ────────────────── + * 1. Assembled transaction fields and footprint — the returned builder + * produces a transaction with the simulation's resource data applied. + * 2. minResourceFee is surfaced on the result. + * 3. Error simulation input is rejected with a clear message. + * 4. Restore-required simulation input is rejected with a clear message. + * 5. assembleAndBuild convenience wrapper returns a Transaction directly. + */ + +import { describe, expect, it } from "vitest" +import { + Keypair, + Networks, + TransactionBuilder, + Contract, + Account, + SorobanDataBuilder, + rpc as StellarRpc, + xdr, +} from "@stellar/stellar-sdk" +import { assembleTx, assembleAndBuild } from "./assemble" + +// ───────────────────────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────────────────────── + +/** A deterministic keypair for test transactions. */ +const SOURCE_KEYPAIR = Keypair.fromSecret( + "SCZANGBA5YELZLBZYA7GY7DXHXCOJCB72A7ASBOMKM7Q5QNDOHLQZ", +) +const SOURCE_PUBLIC = SOURCE_KEYPAIR.publicKey() + +/** A valid Soroban contract address (C...). */ +const CONTRACT_ADDRESS = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF" + +/** + * Build a minimal real Stellar transaction that invokes a contract function. + * Uses sequence "0" so tests don't need a live account. + */ +function buildRawTransaction() { + const account = new Account(SOURCE_PUBLIC, "100") + const contract = new Contract(CONTRACT_ADDRESS) + + return new TransactionBuilder(account, { + fee: "100", + networkPassphrase: Networks.TESTNET, + }) + .addOperation( + contract.call("increment", xdr.ScVal.scvVoid()), + ) + .setTimeout(30) + .build() +} + +/** + * A minimal valid raw simulation response. + * + * `transactionData` is a base64-encoded empty `SorobanTransactionData` XDR. + * We use the SDK's `SorobanDataBuilder` to produce a legitimate value so + * `assembleTransaction` can parse it without throwing. + */ +function buildRawSimulation( + overrides: Partial = {}, +): StellarRpc.Api.RawSimulateTransactionResponse { + const emptyFootprint = new SorobanDataBuilder().build().toXDR("base64") + + return { + id: "test-sim-id", + latestLedger: 12345, + minResourceFee: "500000", + transactionData: emptyFootprint, + results: [{ auth: [], xdr: xdr.ScVal.scvVoid().toXDR("base64") }], + events: [], + ...overrides, + } +} + +/** Parse a raw simulation into the typed success response. */ +function buildParsedSimulation( + overrides: Partial = {}, +) { + return StellarRpc.parseRawSimulation(buildRawSimulation(overrides)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// assembleTx — assembled transaction fields and footprint +// ───────────────────────────────────────────────────────────────────────────── + +describe("assembleTx — assembled transaction fields and footprint", () => { + it("returns a TransactionBuilder on a valid simulation", () => { + const tx = buildRawTransaction() + const sim = buildParsedSimulation() + + const result = assembleTx(tx, sim) + + expect(result.builder).toBeDefined() + expect(typeof result.builder.build).toBe("function") + }) + + it("builder.build() produces a Transaction with the original source account", () => { + const tx = buildRawTransaction() + const sim = buildParsedSimulation() + + const { builder } = assembleTx(tx, sim) + const assembled = builder.build() + + expect(assembled.source).toBe(SOURCE_PUBLIC) + }) + + it("assembled transaction has Soroban resource data applied (non-empty sorobanData)", () => { + const tx = buildRawTransaction() + const sim = buildParsedSimulation() + + const assembled = assembleAndBuild(tx, sim) + + // After assembly the transaction envelope should carry sorobanData + // (the XDR round-trip confirms the footprint was merged in) + const xdrStr = assembled.toXDR() + expect(typeof xdrStr).toBe("string") + expect(xdrStr.length).toBeGreaterThan(0) + + // Re-parsing confirms the assembled XDR is a valid transaction + const reparsed = TransactionBuilder.fromXDR(xdrStr, Networks.TESTNET) + expect(reparsed.source).toBe(SOURCE_PUBLIC) + }) + + it("accepts a raw (unparsed) simulation response directly", () => { + const tx = buildRawTransaction() + const rawSim = buildRawSimulation() + + // assembleTransaction accepts both raw and parsed — verify raw works + const result = assembleTx(tx, rawSim) + expect(result.builder).toBeDefined() + }) + + it("preserves the original transaction's network passphrase", () => { + const tx = buildRawTransaction() + const sim = buildParsedSimulation() + + const assembled = assembleAndBuild(tx, sim) + // The transaction must be valid on testnet + expect(() => + TransactionBuilder.fromXDR(assembled.toXDR(), Networks.TESTNET), + ).not.toThrow() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// assembleTx — minResourceFee surfaced +// ───────────────────────────────────────────────────────────────────────────── + +describe("assembleTx — minResourceFee", () => { + it("surfaces minResourceFee from a raw simulation response", () => { + const tx = buildRawTransaction() + const rawSim = buildRawSimulation({ minResourceFee: "750000" }) + + const { minResourceFee } = assembleTx(tx, rawSim) + + expect(minResourceFee).toBe("750000") + }) + + it("surfaces minResourceFee from a parsed simulation response", () => { + const tx = buildRawTransaction() + const sim = buildParsedSimulation({ minResourceFee: "1234567" }) + + const { minResourceFee } = assembleTx(tx, sim) + + expect(minResourceFee).toBe("1234567") + }) + + it("defaults minResourceFee to '0' when not present in the response", () => { + const tx = buildRawTransaction() + // Build a raw sim without minResourceFee, then cast to avoid TS + const rawSim = buildRawSimulation() + const simWithoutFee = { ...rawSim, minResourceFee: undefined } as unknown as + StellarRpc.Api.RawSimulateTransactionResponse + + const { minResourceFee } = assembleTx(tx, simWithoutFee) + + expect(minResourceFee).toBe("0") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// assembleTx — malformed / error simulation input +// ───────────────────────────────────────────────────────────────────────────── + +describe("assembleTx — malformed simulation input", () => { + it("throws when the simulation is an error response", () => { + const tx = buildRawTransaction() + + // Construct a minimal error simulation using parseRawSimulation + const errorSim = StellarRpc.parseRawSimulation({ + id: "test", + latestLedger: 12345, + error: "Budget exceeded: cpu instructions limit 100, used 9999", + events: [], + } as StellarRpc.Api.RawSimulateTransactionResponse) + + expect(() => assembleTx(tx, errorSim)).toThrow("Simulation failed") + expect(() => assembleTx(tx, errorSim)).toThrow("Budget exceeded") + }) + + it("error message includes the original simulation error string", () => { + const tx = buildRawTransaction() + const errorMessage = "HostError: Value error: some contract logic failure" + + const errorSim = StellarRpc.parseRawSimulation({ + id: "test", + latestLedger: 1, + error: errorMessage, + events: [], + } as StellarRpc.Api.RawSimulateTransactionResponse) + + expect(() => assembleTx(tx, errorSim)).toThrow(errorMessage) + }) + + it("throws when the simulation requires ledger entry restoration", () => { + const tx = buildRawTransaction() + + // A restore simulation has a restorePreamble field alongside success fields + const emptyFootprint = new SorobanDataBuilder().build().toXDR("base64") + const restoreSim = StellarRpc.parseRawSimulation({ + id: "test", + latestLedger: 12345, + minResourceFee: "500000", + transactionData: emptyFootprint, + results: [{ auth: [], xdr: xdr.ScVal.scvVoid().toXDR("base64") }], + events: [], + restorePreamble: { + minResourceFee: "100000", + transactionData: emptyFootprint, + }, + }) + + expect(() => assembleTx(tx, restoreSim)).toThrow("restoration") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// assembleAndBuild — convenience wrapper +// ───────────────────────────────────────────────────────────────────────────── + +describe("assembleAndBuild", () => { + it("returns a Transaction instance directly", () => { + const tx = buildRawTransaction() + const sim = buildParsedSimulation() + + const assembled = assembleAndBuild(tx, sim) + + // Transaction has a toXDR method — confirms it's a real Transaction + expect(typeof assembled.toXDR).toBe("function") + expect(assembled.source).toBe(SOURCE_PUBLIC) + }) + + it("propagates errors from assembleTx", () => { + const tx = buildRawTransaction() + const errorSim = StellarRpc.parseRawSimulation({ + id: "test", + latestLedger: 1, + error: "contract trap", + events: [], + } as StellarRpc.Api.RawSimulateTransactionResponse) + + expect(() => assembleAndBuild(tx, errorSim)).toThrow("Simulation failed") + }) +}) diff --git a/apps/web/src/lib/soroban/assemble.ts b/apps/web/src/lib/soroban/assemble.ts new file mode 100644 index 0000000..b0f71df --- /dev/null +++ b/apps/web/src/lib/soroban/assemble.ts @@ -0,0 +1,124 @@ +/** + * apps/web/src/lib/soroban/assemble.ts + * + * Transaction assembly from Soroban simulation output. + * + * `rpc.assembleTransaction` is a pure SDK function — it merges a raw + * transaction with the resource footprint, auth entries, and minimum resource + * fee returned by a simulation response, producing a new `TransactionBuilder` + * ready to be built and signed. No network call is made. + * + * This module adds: + * - Input validation (rejects error / malformed simulation responses) + * - A convenience `assembleAndBuild` helper that returns a ready-to-sign + * `Transaction` in one call + */ + +import { + rpc as StellarRpc, + Transaction, + FeeBumpTransaction, + TransactionBuilder, +} from "@stellar/stellar-sdk" + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +export type AssembleResult = { + /** + * A `TransactionBuilder` with the simulation footprint, auth entries, and + * resource fee applied. Call `.build()` to obtain the final `Transaction`. + */ + builder: TransactionBuilder + /** + * The minimum resource fee (in stroops) extracted from the simulation. + * Useful for displaying fee estimates before the caller commits to signing. + */ + minResourceFee: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Normalise a raw or parsed simulation into the parsed variant so the SDK + * type-guards (`isSimulationError`, `isSimulationRestore`) work correctly. + */ +function ensureParsed( + simulation: + | StellarRpc.Api.SimulateTransactionResponse + | StellarRpc.Api.RawSimulateTransactionResponse, +): StellarRpc.Api.SimulateTransactionResponse { + if (StellarRpc.Api.isSimulationRaw(simulation)) { + return StellarRpc.parseRawSimulation(simulation) + } + return simulation +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Assemble a transaction from a successful simulation response. + * + * Returns a `TransactionBuilder` with the Soroban resource footprint and + * auth entries applied. Throws for error, malformed, or restore-required + * simulation responses so the caller always receives a usable builder. + * + * @example + * const simulation = await sorobanRpc.simulateTransaction(tx) + * const { builder, minResourceFee } = assembleTx(tx, simulation) + * const assembled = builder.build() + * // assembled is ready to sign + * + * @param raw The original unsigned transaction + * @param simulation The simulation response from `sorobanRpc.simulateTransaction` + * @throws {Error} when the simulation is an error response + * @throws {Error} when the simulation requires ledger entry restoration + */ +export function assembleTx( + raw: Transaction | FeeBumpTransaction, + simulation: + | StellarRpc.Api.SimulateTransactionResponse + | StellarRpc.Api.RawSimulateTransactionResponse, +): AssembleResult { + const parsed = ensureParsed(simulation) + + if (StellarRpc.Api.isSimulationError(parsed)) { + throw new Error(`Simulation failed — cannot assemble transaction: ${parsed.error}`) + } + + if (StellarRpc.Api.isSimulationRestore(parsed)) { + throw new Error( + "Simulation requires ledger entry restoration before this transaction can be assembled", + ) + } + + // Extract minResourceFee — present on success and restore responses. + const minResourceFee: string = + (parsed as StellarRpc.Api.SimulateTransactionSuccessResponse).minResourceFee ?? "0" + + const builder = StellarRpc.assembleTransaction(raw, simulation) + + return { builder, minResourceFee } +} + +/** + * Assemble and immediately build a transaction from a simulation response. + * + * Convenience wrapper around `assembleTx` that calls `.build()` for you. + * + * @returns A fully assembled, unsigned `Transaction` ready to be signed. + */ +export function assembleAndBuild( + raw: Transaction | FeeBumpTransaction, + simulation: + | StellarRpc.Api.SimulateTransactionResponse + | StellarRpc.Api.RawSimulateTransactionResponse, +): Transaction { + const { builder } = assembleTx(raw, simulation) + return builder.build() as Transaction +} diff --git a/apps/web/src/lib/soroban/events.test.ts b/apps/web/src/lib/soroban/events.test.ts new file mode 100644 index 0000000..e272e0a --- /dev/null +++ b/apps/web/src/lib/soroban/events.test.ts @@ -0,0 +1,329 @@ +/** + * apps/web/src/lib/soroban/events.test.ts + * + * Contract event query parsing tests. + * + * All RPC HTTP traffic is intercepted by MSW — no real Soroban RPC calls are + * made. The MSW server lifecycle is managed by setup-tests.ts (preloaded via + * `bun test --preload ./setup-tests.ts`). + * + * Scenarios covered + * ────────────────── + * 1. Decoded topics and value – fixture with a symbol topic and i128 value + * are parsed correctly from base64 XDR. + * 2. Pagination cursor – cursor from response is surfaced; a second + * call using that cursor routes correctly. + * 3. Empty result – zero events returned, cursor is empty string. + */ + +import { describe, expect, it } from "vitest" +import { http, HttpResponse } from "msw" +import { xdr, Address } from "@stellar/stellar-sdk" +import { server } from "../../../test/msw/server" +import { queryContractEvents } from "./events" + +// ───────────────────────────────────────────────────────────────────────────── +// Constants +// ───────────────────────────────────────────────────────────────────────────── + +const RPC_URL = "https://soroban-testnet.stellar.org" + +/** A valid 56-char C... contract address used as the filter target. */ +const CONTRACT_ID = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF" + +// ───────────────────────────────────────────────────────────────────────────── +// XDR fixture helpers +// +// We build the base64 XDR strings from the SDK itself so the fixture is +// always valid and consistent with the SDK version in use. +// ───────────────────────────────────────────────────────────────────────────── + +/** Base64 XDR for ScVal::Symbol("transfer") */ +const SYMBOL_TRANSFER_XDR = xdr.ScVal.scvSymbol("transfer").toXDR("base64") + +/** Base64 XDR for ScVal::Symbol("deposit") */ +const SYMBOL_DEPOSIT_XDR = xdr.ScVal.scvSymbol("deposit").toXDR("base64") + +/** Base64 XDR for ScVal::I128 representing 1_000_000 (1e6 in low 64 bits) */ +const I128_VALUE_XDR = xdr.ScVal.scvI128( + new xdr.Int128Parts({ hi: xdr.Int64.fromString("0"), lo: xdr.Uint64.fromString("1000000") }), +).toXDR("base64") + +/** Base64 XDR for ScVal::I128 representing 500_000 */ +const I128_VALUE2_XDR = xdr.ScVal.scvI128( + new xdr.Int128Parts({ hi: xdr.Int64.fromString("0"), lo: xdr.Uint64.fromString("500000") }), +).toXDR("base64") + +// ───────────────────────────────────────────────────────────────────────────── +// Raw event fixture builder +// ───────────────────────────────────────────────────────────────────────────── + +type RawEventOverrides = { + id?: string + contractId?: string + topic?: string[] + value?: string + ledger?: number + txHash?: string + cursor?: string +} + +function makeRawEvent(overrides: RawEventOverrides = {}) { + return { + id: overrides.id ?? "0000000012345-0000000001", + type: "contract" as const, + ledger: overrides.ledger ?? 12345, + ledgerClosedAt: "2024-01-01T00:00:00Z", + transactionIndex: 0, + operationIndex: 0, + inSuccessfulContractCall: true, + txHash: overrides.txHash ?? "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + contractId: overrides.contractId ?? CONTRACT_ID, + topic: overrides.topic ?? [SYMBOL_TRANSFER_XDR], + value: overrides.value ?? I128_VALUE_XDR, + } +} + +/** Build a JSON-RPC 2.0 getEvents success response. */ +function makeRpcEventsResponse( + events: ReturnType[], + cursor = "0000000012345-0000000001", + latestLedger = 12345, +) { + return { + jsonrpc: "2.0", + id: 1, + result: { + events, + cursor, + latestLedger, + oldestLedger: 1, + latestLedgerCloseTime: "2024-01-01T00:00:00Z", + oldestLedgerCloseTime: "2024-01-01T00:00:00Z", + }, + } +} + +/** MSW handler that only intercepts getEvents POST requests. */ +type RpcBody = { id?: string | number; method?: string; params?: unknown } + +function getEventsHandler( + response: ReturnType, + onRequest?: (params: unknown) => void, +) { + return http.post(RPC_URL, async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as RpcBody + if (body.method !== "getEvents") { + // Fall through for other methods — return empty success so they don't fail + return HttpResponse.json({ jsonrpc: "2.0", id: body.id ?? 1, result: {} }) + } + onRequest?.(body.params) + return HttpResponse.json({ ...response, id: body.id ?? 1 }) + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: decoded topics and value +// ───────────────────────────────────────────────────────────────────────────── + +describe("queryContractEvents – decoded topics and value", () => { + it("decodes a symbol topic correctly", async () => { + server.use( + getEventsHandler( + makeRpcEventsResponse([makeRawEvent({ topic: [SYMBOL_TRANSFER_XDR] })]), + ), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + + expect(page.events).toHaveLength(1) + const event = page.events[0]! + expect(event.topics).toHaveLength(1) + + const topic = event.topics[0]! + // The SDK parses scvSymbol — switch() returns the type discriminant + expect(topic.switch().name).toBe("scvSymbol") + expect(topic.sym().toString()).toBe("transfer") + }) + + it("decodes an i128 value correctly", async () => { + server.use( + getEventsHandler( + makeRpcEventsResponse([makeRawEvent({ value: I128_VALUE_XDR })]), + ), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + + const value = page.events[0]!.value + expect(value.switch().name).toBe("scvI128") + // Low 64 bits hold 1_000_000 + expect(value.i128().lo().toString()).toBe("1000000") + expect(value.i128().hi().toString()).toBe("0") + }) + + it("maps all base metadata fields onto the ContractEvent", async () => { + const raw = makeRawEvent({ + id: "fixture-event-id", + ledger: 42000, + txHash: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + }) + + server.use(getEventsHandler(makeRpcEventsResponse([raw]))) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + const event = page.events[0]! + + expect(event.id).toBe("fixture-event-id") + expect(event.ledger).toBe(42000) + expect(event.txHash).toBe( + "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + ) + expect(event.type).toBe("contract") + }) + + it("decodes multi-topic events", async () => { + server.use( + getEventsHandler( + makeRpcEventsResponse([ + makeRawEvent({ topic: [SYMBOL_TRANSFER_XDR, SYMBOL_DEPOSIT_XDR] }), + ]), + ), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + const { topics } = page.events[0]! + + expect(topics).toHaveLength(2) + expect(topics[0]!.sym().toString()).toBe("transfer") + expect(topics[1]!.sym().toString()).toBe("deposit") + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: pagination cursor handling +// ───────────────────────────────────────────────────────────────────────────── + +describe("queryContractEvents – pagination cursor", () => { + it("surfaces the cursor from the RPC response", async () => { + server.use( + getEventsHandler( + makeRpcEventsResponse( + [makeRawEvent()], + "0000000099999-0000000001", + ), + ), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + + expect(page.cursor).toBe("0000000099999-0000000001") + }) + + it("sends cursor in the next request and returns the next page", async () => { + const page1Cursor = "0000000010000-0000000001" + const page1Event = makeRawEvent({ id: "event-page1", value: I128_VALUE_XDR }) + const page2Event = makeRawEvent({ id: "event-page2", value: I128_VALUE2_XDR }) + + let capturedParams: unknown + + server.use( + http.post(RPC_URL, async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as RpcBody + if (body.method !== "getEvents") { + return HttpResponse.json({ jsonrpc: "2.0", id: body.id ?? 1, result: {} }) + } + + capturedParams = body.params + + // Dispatch on whether params contains a cursor + const params = body.params as { cursor?: string } | null + const hasCursor = params && typeof params === "object" && "cursor" in params + + if (hasCursor) { + return HttpResponse.json({ + ...makeRpcEventsResponse([page2Event], "0000000020000-0000000001"), + id: body.id ?? 1, + }) + } + + return HttpResponse.json({ + ...makeRpcEventsResponse([page1Event], page1Cursor), + id: body.id ?? 1, + }) + }), + ) + + // First page — ledger-range mode + const first = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + expect(first.events[0]!.id).toBe("event-page1") + expect(first.cursor).toBe(page1Cursor) + + // Second page — cursor mode + const second = await queryContractEvents({ + contractId: CONTRACT_ID, + cursor: first.cursor, + }) + expect(second.events[0]!.id).toBe("event-page2") + expect(second.cursor).toBe("0000000020000-0000000001") + + // Confirm the second request carried a cursor param + expect(capturedParams).toMatchObject({ cursor: page1Cursor }) + }) + + it("surfaces latestLedger from the response", async () => { + server.use( + getEventsHandler(makeRpcEventsResponse([], "", 99999)), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + + expect(page.latestLedger).toBe(99999) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// Tests: empty result fallback +// ───────────────────────────────────────────────────────────────────────────── + +describe("queryContractEvents – empty result", () => { + it("returns empty events array and empty cursor when no events exist", async () => { + server.use( + getEventsHandler(makeRpcEventsResponse([], "", 12345)), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + + expect(page.events).toHaveLength(0) + expect(page.cursor).toBe("") + expect(page.latestLedger).toBe(12345) + }) + + it("returns empty array when RPC returns null events", async () => { + server.use( + http.post(RPC_URL, async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as RpcBody + if (body.method !== "getEvents") { + return HttpResponse.json({ jsonrpc: "2.0", id: body.id ?? 1, result: {} }) + } + return HttpResponse.json({ + jsonrpc: "2.0", + id: body.id ?? 1, + result: { + events: [], + cursor: "", + latestLedger: 1, + oldestLedger: 1, + latestLedgerCloseTime: "2024-01-01T00:00:00Z", + oldestLedgerCloseTime: "2024-01-01T00:00:00Z", + }, + }) + }), + ) + + const page = await queryContractEvents({ contractId: CONTRACT_ID, startLedger: 1 }) + + expect(Array.isArray(page.events)).toBe(true) + expect(page.events).toHaveLength(0) + }) +}) diff --git a/apps/web/src/lib/soroban/events.ts b/apps/web/src/lib/soroban/events.ts new file mode 100644 index 0000000..62f7a9d --- /dev/null +++ b/apps/web/src/lib/soroban/events.ts @@ -0,0 +1,135 @@ +/** + * apps/web/src/lib/soroban/events.ts + * + * Contract event query helpers for Soroban. + * + * Wraps the Soroban RPC `getEvents` method and exposes a clean typed + * interface. Using the singleton `sorobanRpc` client means all HTTP + * traffic goes through the shared RPC URL and is fully interceptable by + * MSW in tests. + * + * The SDK parses raw base64-XDR `topic` / `value` fields into `xdr.ScVal` + * objects on the returned `EventResponse`. Callers can use + * `xdr.ScVal.switch()` / `.value()` or the higher-level helpers from + * `@stellar/stellar-sdk` to decode them further. + */ + +import { rpc as StellarRpc, xdr } from "@stellar/stellar-sdk" +import { sorobanRpc } from "./client" + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** A fully-parsed Soroban contract event (topics and value decoded from XDR). */ +export type ContractEvent = { + id: string + type: StellarRpc.Api.EventType + ledger: number + ledgerClosedAt: string + txHash: string + contractId: string + /** Decoded ScVal topics */ + topics: xdr.ScVal[] + /** Decoded ScVal value */ + value: xdr.ScVal +} + +/** Result of a single `queryContractEvents` call. */ +export type ContractEventsPage = { + events: ContractEvent[] + /** + * Opaque cursor string for pagination. Pass to the next call as `cursor` + * to fetch the following page. Empty string when the result set is empty. + */ + cursor: string + latestLedger: number +} + +export type QueryContractEventsOptions = { + /** Contract ID (C... address) to filter by. */ + contractId: string + /** Topic filters (array of ScVal arrays encoded as base64 XDR strings). */ + topicFilters?: string[][] + /** + * Ledger range mode: start from this ledger. + * Mutually exclusive with `cursor`. + */ + startLedger?: number + /** + * Cursor pagination mode: continue from a previous page's cursor. + * Mutually exclusive with `startLedger`. + */ + cursor?: string + /** Max events to return (default: 20). */ + limit?: number +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +function toContractEvent(raw: StellarRpc.Api.EventResponse): ContractEvent { + return { + id: raw.id, + type: raw.type, + ledger: raw.ledger, + ledgerClosedAt: raw.ledgerClosedAt, + txHash: raw.txHash, + // The SDK sets contractId as a Contract instance when present + contractId: raw.contractId?.contractId().toString("hex") ?? "", + topics: raw.topic, + value: raw.value, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Query contract events from Soroban RPC. + * + * Supply either `startLedger` (ledger-range mode) or `cursor` (pagination + * mode) — never both. + * + * @example + * // First page from ledger 1000 + * const page = await queryContractEvents({ + * contractId: "CAAA...", + * startLedger: 1000, + * limit: 50, + * }) + * + * // Next page using cursor + * if (page.cursor) { + * const next = await queryContractEvents({ + * contractId: "CAAA...", + * cursor: page.cursor, + * limit: 50, + * }) + * } + */ +export async function queryContractEvents( + options: QueryContractEventsOptions, +): Promise { + const limit = options.limit ?? 20 + + const filter: StellarRpc.Api.EventFilter = { + type: "contract", + contractIds: [options.contractId], + ...(options.topicFilters ? { topics: options.topicFilters } : {}), + } + + const request: StellarRpc.Api.GetEventsRequest = options.cursor + ? { filters: [filter], cursor: options.cursor, limit } + : { filters: [filter], startLedger: options.startLedger ?? 1, limit } + + const response = await sorobanRpc.getEvents(request) + + return { + events: response.events.map(toContractEvent), + cursor: response.cursor, + latestLedger: response.latestLedger, + } +} diff --git a/apps/web/src/lib/stellar/payments.test.ts b/apps/web/src/lib/stellar/payments.test.ts new file mode 100644 index 0000000..86ef112 --- /dev/null +++ b/apps/web/src/lib/stellar/payments.test.ts @@ -0,0 +1,244 @@ +/** + * apps/web/src/lib/stellar/payments.test.ts + * + * Unit tests for the Horizon payment history paginator. + * + * All HTTP is intercepted by MSW — no real Horizon calls are made. + * The MSW server lifecycle is managed by setup-tests.ts (preloaded via + * `bun test --preload ./setup-tests.ts`). + * + * Scenarios covered + * ────────────────── + * 1. Single page – records returned, nextCursor is null. + * 2. Multi-page – cursor advancement; merged results span both pages. + * 3. Empty history – zero records, nextCursor is null. + * 4. fetchAllPaymentHistory – auto-follows cursors, returns flat merged array. + */ + +import { describe, expect, it } from "vitest" +import { http, HttpResponse } from "msw" +import { server } from "../../../test/msw/server" +import { + fetchPaymentHistory, + fetchAllPaymentHistory, + type PaymentRecord, +} from "./payments" + +// ───────────────────────────────────────────────────────────────────────────── +// Fixtures +// ───────────────────────────────────────────────────────────────────────────── + +const ACCOUNT = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" +const PAYMENTS_URL = `https://horizon-testnet.stellar.org/accounts/${ACCOUNT}/payments` + +function makeRecord(id: string, overrides: Partial = {}): PaymentRecord { + return { + id, + type: "payment", + created_at: "2024-01-01T00:00:00Z", + transaction_hash: `txhash_${id}`, + from: "GBBD47UZQ2YNRGESRV37TJZWQ6HC76ZK34CSXVGBTCVRXGT7GBNXVQ34", + to: ACCOUNT, + asset_type: "credit_alphanum4", + asset_code: "USDC", + asset_issuer: "GBBD47UZQ2YNRGESRV37TJZWQ6HC76ZK34CSXVGBTCVRXGT7GBNXVQ34", + amount: "100.0000000", + ...overrides, + } +} + +/** + * Build a Horizon HAL collection page. + * Pass `nextCursor` to populate `_links.next.href`; omit / null for last page. + */ +function horizonPage(records: PaymentRecord[], nextCursor: string | null = null) { + return { + _links: { + self: { href: PAYMENTS_URL }, + next: nextCursor + ? { href: `${PAYMENTS_URL}?cursor=${nextCursor}&limit=10&order=desc` } + : null, + prev: null, + }, + _embedded: { records }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// MSW handler helper – matches any request to the payments endpoint and +// dispatches based on the `cursor` query param. +// ───────────────────────────────────────────────────────────────────────────── + +type CursorMap = Record< + string, // cursor value ("" = no cursor) + { records: PaymentRecord[]; nextCursor: string | null } +> + +function paymentsHandler(pages: CursorMap) { + return http.get(PAYMENTS_URL, ({ request }) => { + const cursor = new URL(request.url).searchParams.get("cursor") ?? "" + const page = pages[cursor] ?? { records: [], nextCursor: null } + return HttpResponse.json(horizonPage(page.records, page.nextCursor)) + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// fetchPaymentHistory – single page +// ───────────────────────────────────────────────────────────────────────────── + +describe("fetchPaymentHistory – single page", () => { + it("returns records and null nextCursor when page is not full", async () => { + const records = [makeRecord("1"), makeRecord("2")] + + server.use( + http.get(PAYMENTS_URL, () => HttpResponse.json(horizonPage(records, null))), + ) + + const page = await fetchPaymentHistory(ACCOUNT, { limit: 10 }) + + expect(page.records).toHaveLength(2) + expect(page.records[0]?.id).toBe("1") + expect(page.records[1]?.id).toBe("2") + expect(page.nextCursor).toBeNull() + }) + + it("surfaces all expected fields on each record", async () => { + server.use( + http.get(PAYMENTS_URL, () => + HttpResponse.json(horizonPage([makeRecord("42")])), + ), + ) + + const page = await fetchPaymentHistory(ACCOUNT, { limit: 10 }) + + expect(page.records[0]).toMatchObject({ + id: "42", + type: "payment", + asset_code: "USDC", + amount: "100.0000000", + transaction_hash: "txhash_42", + }) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// fetchPaymentHistory – multi-page / cursor advancement +// ───────────────────────────────────────────────────────────────────────────── + +describe("fetchPaymentHistory – multi-page pagination", () => { + it("returns nextCursor when page is exactly full (records.length === limit)", async () => { + const records = [makeRecord("a"), makeRecord("b"), makeRecord("c")] + + server.use( + http.get(PAYMENTS_URL, () => + HttpResponse.json(horizonPage(records, "cursor_page2")), + ), + ) + + const page = await fetchPaymentHistory(ACCOUNT, { limit: 3 }) + + expect(page.records).toHaveLength(3) + expect(page.nextCursor).toBe("cursor_page2") + }) + + it("passes cursor on second request and advances correctly", async () => { + const page1Records = [makeRecord("10"), makeRecord("9"), makeRecord("8")] + const page2Records = [makeRecord("7"), makeRecord("6")] + + server.use( + paymentsHandler({ + "": { records: page1Records, nextCursor: "cursor_xyz" }, + cursor_xyz: { records: page2Records, nextCursor: null }, + }), + ) + + const first = await fetchPaymentHistory(ACCOUNT, { limit: 3 }) + expect(first.records.map((r) => r.id)).toEqual(["10", "9", "8"]) + expect(first.nextCursor).toBe("cursor_xyz") + + const second = await fetchPaymentHistory(ACCOUNT, { limit: 3, cursor: "cursor_xyz" }) + expect(second.records.map((r) => r.id)).toEqual(["7", "6"]) + expect(second.nextCursor).toBeNull() + + // Caller merges the two pages + const merged = [...first.records, ...second.records] + expect(merged.map((r) => r.id)).toEqual(["10", "9", "8", "7", "6"]) + }) + + it("returns null nextCursor when _links.next is absent on a full page", async () => { + // Edge case: full page but Horizon omits next link → treat as last page + const records = [makeRecord("1"), makeRecord("2")] + server.use( + http.get(PAYMENTS_URL, () => + HttpResponse.json({ + _links: { self: { href: PAYMENTS_URL }, next: null }, + _embedded: { records }, + }), + ), + ) + + const page = await fetchPaymentHistory(ACCOUNT, { limit: 2 }) + // records.length === limit but no next href → nextCursor still null + // (cursor from null href returns null) + expect(page.nextCursor).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// fetchPaymentHistory – empty history fallback +// ───────────────────────────────────────────────────────────────────────────── + +describe("fetchPaymentHistory – empty history", () => { + it("returns empty records and null nextCursor with explicit options", async () => { + server.use( + http.get(PAYMENTS_URL, () => HttpResponse.json(horizonPage([], null))), + ) + + const page = await fetchPaymentHistory(ACCOUNT, { limit: 10 }) + + expect(page.records).toHaveLength(0) + expect(page.nextCursor).toBeNull() + }) + + it("returns empty records and null nextCursor with default options", async () => { + server.use( + http.get(PAYMENTS_URL, () => HttpResponse.json(horizonPage([], null))), + ) + + const page = await fetchPaymentHistory(ACCOUNT) + + expect(Array.isArray(page.records)).toBe(true) + expect(page.records).toHaveLength(0) + expect(page.nextCursor).toBeNull() + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// fetchAllPaymentHistory – auto-follow cursors +// ───────────────────────────────────────────────────────────────────────────── + +describe("fetchAllPaymentHistory", () => { + it("follows cursors across pages and returns merged flat array", async () => { + server.use( + paymentsHandler({ + "": { records: [makeRecord("a"), makeRecord("b")], nextCursor: "cur_1" }, + cur_1: { records: [makeRecord("c"), makeRecord("d")], nextCursor: "cur_2" }, + cur_2: { records: [makeRecord("e")], nextCursor: null }, + }), + ) + + const all = await fetchAllPaymentHistory(ACCOUNT, { limit: 2 }) + + expect(all.map((r) => r.id)).toEqual(["a", "b", "c", "d", "e"]) + }) + + it("returns empty array for an account with no payment history", async () => { + server.use( + http.get(PAYMENTS_URL, () => HttpResponse.json(horizonPage([], null))), + ) + + const all = await fetchAllPaymentHistory(ACCOUNT) + + expect(all).toEqual([]) + }) +}) diff --git a/apps/web/src/lib/stellar/payments.ts b/apps/web/src/lib/stellar/payments.ts new file mode 100644 index 0000000..e3bb98b --- /dev/null +++ b/apps/web/src/lib/stellar/payments.ts @@ -0,0 +1,129 @@ +/** + * apps/web/src/lib/stellar/payments.ts + * + * Paginated payment history reader for a Stellar account. + * + * Fetches from the Horizon REST API directly so the response shape is + * predictable and fully interceptable by MSW in tests. Cursor-based + * pagination is supported via the `nextCursor` field returned on each page. + */ + +const HORIZON_URL = import.meta.env.VITE_HORIZON_URL as string + +// ───────────────────────────────────────────────────────────────────────────── +// Types +// ───────────────────────────────────────────────────────────────────────────── + +/** A single payment record as returned by Horizon. */ +export type PaymentRecord = { + id: string + type: string + created_at: string + transaction_hash: string + from: string + to: string + asset_type: string + asset_code?: string + asset_issuer?: string + amount: string +} + +/** Result of a single paginated fetch. */ +export type PaymentPage = { + /** Payment records for this page. */ + records: PaymentRecord[] + /** + * Opaque cursor to pass on the next call to get the following page. + * `null` when there are no more pages. + */ + nextCursor: string | null +} + +export type FetchPaymentHistoryOptions = { + /** Records per page (default: 10). */ + limit?: number + /** Cursor from a previous page's `nextCursor`. Omit to start from the most recent payment. */ + cursor?: string +} + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +/** Extract the `cursor` query param from a Horizon `_links.next.href`. */ +function cursorFromNextHref(href: string | null | undefined): string | null { + if (!href) return null + try { + return new URL(href).searchParams.get("cursor") + } catch { + return null + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public API +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Fetch one page of payment history for `accountId` from Horizon. + * + * @example + * const page1 = await fetchPaymentHistory("GAAA...", { limit: 20 }) + * if (page1.nextCursor) { + * const page2 = await fetchPaymentHistory("GAAA...", { limit: 20, cursor: page1.nextCursor }) + * } + */ +export async function fetchPaymentHistory( + accountId: string, + options: FetchPaymentHistoryOptions = {}, +): Promise { + const limit = options.limit ?? 10 + + const url = new URL(`${HORIZON_URL}/accounts/${accountId}/payments`) + url.searchParams.set("limit", String(limit)) + url.searchParams.set("order", "desc") + if (options.cursor) { + url.searchParams.set("cursor", options.cursor) + } + + const res = await fetch(url.toString()) + if (!res.ok) { + throw new Error(`Horizon payments error: ${res.status} ${res.statusText}`) + } + + const json = await res.json() as { + _links?: { next?: { href?: string } | null } + _embedded?: { records?: PaymentRecord[] } + } + + const records: PaymentRecord[] = json._embedded?.records ?? [] + const nextHref = json._links?.next?.href ?? null + // Only surface a cursor when the page is full — a partial page means we're done. + const nextCursor = records.length < limit ? null : cursorFromNextHref(nextHref) + + return { records, nextCursor } +} + +/** + * Fetch all payment pages for `accountId` and return them merged into a + * single flat array. + * + * ⚠️ For accounts with large history this issues many requests. + * Prefer `fetchPaymentHistory` with cursor pagination for production UIs. + */ +export async function fetchAllPaymentHistory( + accountId: string, + options: Omit = {}, +): Promise { + const all: PaymentRecord[] = [] + let cursor: string | undefined + + for (;;) { + const page = await fetchPaymentHistory(accountId, { ...options, cursor }) + all.push(...page.records) + if (!page.nextCursor) break + cursor = page.nextCursor + } + + return all +}