From fc923cd613663cd9aec545764e5956d9ac3f16be Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sat, 29 Aug 2026 18:16:09 +0100 Subject: [PATCH 1/6] feat(x402): add audit log hook for all signer actions Define the enumerated set of signer actions (authorize / deny) and fire an onSignerAction hook for every signing attempt with the actor, outcome, and network passphrase. Cover both createSessionKeySigner and createPasskeyX402Signer with tests that assert the hook fires for each action, and document the audit hook in the x402 README section. closes #262 --- src/x402-signer.test.ts | 95 ++++++++++++++++++++++++++++++++ src/x402-signer.ts | 103 +++++++++++++++++++++++++++++------ website/content/docs/x402.md | 28 ++++++++++ 3 files changed, 209 insertions(+), 17 deletions(-) diff --git a/src/x402-signer.test.ts b/src/x402-signer.test.ts index 311ab8a..9498220 100644 --- a/src/x402-signer.test.ts +++ b/src/x402-signer.test.ts @@ -12,6 +12,7 @@ import { createSessionKeySigner, createPasskeyX402Signer, type WebAuthnAssertion, + type X402SignerActionEvent, } from "./x402-signer"; const PASSPHRASE = "Test SDF Network ; September 2015"; @@ -115,6 +116,57 @@ describe("createSessionKeySigner", () => { }), ).rejects.toThrow(/expects V1 sorobanCredentialsAddress/); }); + + it("fires onSignerAction with `authorize`/`success` for a successful signature", async () => { + const events: X402SignerActionEvent[] = []; + const kp = Keypair.random(); + const signer = createSessionKeySigner({ + address: C_ADDRESS, + secretKey: kp.secret(), + onSignerAction: (e) => { + events.push(e); + }, + }); + + const entry = makeV1AuthEntry(C_ADDRESS); + await signer.signAuthEntry(entry.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 1000, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.action).toBe("authorize"); + expect(events[0]!.outcome).toBe("success"); + expect(events[0]!.actor).toBe(C_ADDRESS); + expect(events[0]!.networkPassphrase).toBe(PASSPHRASE); + expect(events[0]!.error).toBeUndefined(); + }); + + it("fires onSignerAction with `deny`/`error` when signing is rejected", async () => { + const events: X402SignerActionEvent[] = []; + const kp = Keypair.random(); + const signer = createSessionKeySigner({ + address: C_ADDRESS, + secretKey: kp.secret(), + onSignerAction: (e) => { + events.push(e); + }, + }); + + const entry = makeV1AuthEntry(OTHER_C); // credential for a DIFFERENT wallet + await expect( + signer.signAuthEntry(entry.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 1000, + }), + ).rejects.toThrow(/does not match signer address/); + + expect(events).toHaveLength(1); + expect(events[0]!.action).toBe("deny"); + expect(events[0]!.outcome).toBe("error"); + expect(events[0]!.actor).toBe(C_ADDRESS); + expect(events[0]!.error).toBeDefined(); + }); }); describe("createPasskeyX402Signer", () => { @@ -159,4 +211,47 @@ describe("createPasskeyX402Signer", () => { const fields = struct.map((e) => e.key().sym().toString()).sort(); expect(fields).toEqual(["authenticator_data", "client_data_json", "signature"]); }); + + it("fires onSignerAction for both `authorize` (success) and `deny` (error)", async () => { + const events: X402SignerActionEvent[] = []; + const keyId = new Uint8Array(20).fill(9); + const assertion: WebAuthnAssertion = { + authenticatorData: new Uint8Array(37).fill(1), + clientDataJSON: new Uint8Array(50).fill(2), + signature: new Uint8Array(64).fill(3), + keyId, + }; + const signer = createPasskeyX402Signer({ + address: C_ADDRESS, + webAuthn: { + async sign() { + return assertion; + }, + }, + onSignerAction: (e) => { + events.push(e); + }, + }); + + // Success → authorize. + const entry = makeV1AuthEntry(C_ADDRESS); + await signer.signAuthEntry(entry.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 2000, + }); + // Error (wrong wallet) → deny. + const wrong = makeV1AuthEntry(OTHER_C); + await expect( + signer.signAuthEntry(wrong.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 2000, + }), + ).rejects.toThrow(/does not match signer address/); + + expect(events.map((e) => `${e.action}:${e.outcome}`)).toEqual([ + "authorize:success", + "deny:error", + ]); + expect(events.every((e) => e.actor === C_ADDRESS)).toBe(true); + }); }); diff --git a/src/x402-signer.ts b/src/x402-signer.ts index 6d203d4..5f6126d 100644 --- a/src/x402-signer.ts +++ b/src/x402-signer.ts @@ -26,6 +26,34 @@ import { } from "@stellar/stellar-sdk"; import type { SmartAccountX402Signer } from "./x402-types"; +// ── signer audit hook ────────────────────────────────────────────────────────── +// +// Every signer action a consumer cares about for audit logging is enumerated +// here so callers can hook them all without magic strings. The SDK fires +// `onSignerAction` (when configured) for each action with the actor context +// and the outcome, so a host can ship a tamper-evident audit trail of who +// authorized (or was denied) which payment. + +/** The complete set of signer actions that warrant an audit hook. */ +export type X402SignerAction = "authorize" | "deny"; + +/** Payload passed to {@link X402SignerActionHook} for every signer action. */ +export interface X402SignerActionEvent { + /** Which signer action occurred. */ + action: X402SignerAction; + /** The C-address actor performing (or attempting) the action. */ + actor: string; + /** Whether the action succeeded or errored out. */ + outcome: "success" | "error"; + /** The network passphrase the action ran against. */ + networkPassphrase: string; + /** Present when `outcome` is `"error"` — the thrown value. */ + error?: unknown; +} + +/** A consumer-supplied audit sink invoked for every signer action. */ +export type X402SignerActionHook = (event: X402SignerActionEvent) => void | Promise; + // ── raw ScVal builders (byte-identical to the wallet contract spec; verified) ── // // Soroban enum-variant encoding: a UDT union variant is scvVec([symbol, ...vals]). @@ -169,6 +197,12 @@ export interface SessionKeySignerConfig { * than a missing co-signer. Omit only for an unrestricted key. */ policies?: readonly string[]; + /** + * Audit hook fired for every signer action (`authorize` on success, `deny` on + * error). Pass a consumer-side sink (e.g. one that ships to an append-only log) + * to keep a tamper-evident record of who authorized or was denied which payment. + */ + onSignerAction?: X402SignerActionHook; } /** @@ -190,15 +224,30 @@ export function createSessionKeySigner(config: SessionKeySignerConfig): SmartAcc } } + const onAction = config.onSignerAction; + const fire = ( + action: X402SignerAction, + outcome: "success" | "error", + networkPassphrase: string, + error?: unknown, + ) => onAction?.({ action, actor: config.address, outcome, networkPassphrase, error }); + return { address: config.address, async signAuthEntry(entryXdr, { networkPassphrase, expirationLedger }) { - const entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64"); - assertEntryAddress(entry, config.address); - const payload = payloadHashForEntry(entry, networkPassphrase, expirationLedger); - const signature = keypair.sign(payload); - setSignatureMap(entry, ed25519SignerKey(rawPk), ed25519Signature(signature), policies); - return entry.toXDR("base64"); + try { + const entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64"); + assertEntryAddress(entry, config.address); + const payload = payloadHashForEntry(entry, networkPassphrase, expirationLedger); + const signature = keypair.sign(payload); + setSignatureMap(entry, ed25519SignerKey(rawPk), ed25519Signature(signature), policies); + const signed = entry.toXDR("base64"); + await fire("authorize", "success", networkPassphrase); + return signed; + } catch (err) { + await fire("deny", "error", networkPassphrase, err); + throw err; + } }, }; } @@ -230,6 +279,11 @@ export interface PasskeyX402SignerConfig { /** Policy contracts this signer's `SignerLimits` require — see * {@link SessionKeySignerConfig.policies}. Same trap applies here. */ policies?: readonly string[]; + /** + * Audit hook fired for every signer action (`authorize` on success, `deny` on + * error). See {@link SessionKeySignerConfig.onSignerAction}. + */ + onSignerAction?: X402SignerActionHook; } /** @@ -244,20 +298,35 @@ export function createPasskeyX402Signer(config: PasskeyX402SignerConfig): SmartA if (!isContractAddress(config.address)) { throw new Error(`passkey signer address must be a contract (C…): got ${config.address}`); } + const onAction = config.onSignerAction; + const fire = ( + action: X402SignerAction, + outcome: "success" | "error", + networkPassphrase: string, + error?: unknown, + ) => onAction?.({ action, actor: config.address, outcome, networkPassphrase, error }); + return { address: config.address, async signAuthEntry(entryXdr, { networkPassphrase, expirationLedger }) { - const entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64"); - assertEntryAddress(entry, config.address); - const payload = payloadHashForEntry(entry, networkPassphrase, expirationLedger); - const assertion = await config.webAuthn.sign(new Uint8Array(payload)); - setSignatureMap( - entry, - secp256r1SignerKey(assertion.keyId), - secp256r1Signature(assertion), - config.policies ?? [], - ); - return entry.toXDR("base64"); + try { + const entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64"); + assertEntryAddress(entry, config.address); + const payload = payloadHashForEntry(entry, networkPassphrase, expirationLedger); + const assertion = await config.webAuthn.sign(new Uint8Array(payload)); + setSignatureMap( + entry, + secp256r1SignerKey(assertion.keyId), + secp256r1Signature(assertion), + config.policies ?? [], + ); + const signed = entry.toXDR("base64"); + await fire("authorize", "success", networkPassphrase); + return signed; + } catch (err) { + await fire("deny", "error", networkPassphrase, err); + throw err; + } }, }; } diff --git a/website/content/docs/x402.md b/website/content/docs/x402.md index 9883e98..a9144c4 100644 --- a/website/content/docs/x402.md +++ b/website/content/docs/x402.md @@ -205,3 +205,31 @@ The client throws typed errors: For a client without a wallet handle, `createX402Client({ signer, rpcUrl, network, simulationSourceAccount })` returns the same `fetch` / `createPayment` API. See [Advanced](./advanced.md) for the exported building blocks. + +## Auditing signer actions + +Every signer action is surfaced through an optional `onSignerAction` hook you +pass when building a signer. The SDK fires it once per signing attempt with the +actor (the wallet C-address), the outcome, and the network passphrase — so a +consumer can keep a tamper-evident audit trail of who authorized (or was denied) +which payment, with no changes to the call sites: + +```ts +import { createSessionKeySigner, type X402SignerActionEvent } from "vellar-sdk"; + +const auditLog: X402SignerActionEvent[] = []; +const signer = createSessionKeySigner({ + address: walletCAddress, + secretKey: sessionKeySecret, + onSignerAction: (event) => { + // Ship to your append-only audit store. `event.action` is "authorize" + // on success or "deny" on rejection; `event.outcome` is "success"/"error". + auditLog.push(event); + }, +}); +``` + +The complete set of audited actions is the `X402SignerAction` union — +`"authorize"` (the signer produced a signature) and `"deny"` (signing was +rejected, e.g. a credential for a different wallet). Both `createSessionKeySigner` +and `createPasskeyX402Signer` accept `onSignerAction`. From 9cbddc63ed7573fef756fc180d3ac5baf7868fd3 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sun, 30 Aug 2026 11:09:01 +0100 Subject: [PATCH 2/6] test(client): add integration harness wiring client.ts to a mock http-backend Spin up a real loopback HTTP server implementing the gateway endpoints createHttpWalletBackend speaks, and wire createVellarWallet to it so wallet initialization, a balance fetch, and a payment submission run end to end over the production transport. The harness runs inside the existing npm test pipeline and is documented in CONTRIBUTING.md for local runs. closes #265 --- CONTRIBUTING.md | 20 ++++ src/client-backend-harness.test.ts | 173 +++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/client-backend-harness.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc64e2a..a6f7a5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,3 +51,23 @@ npm run build ``` New code is expected to come with tests. + +## Testing + +The default suite is hermetic — no network, no local stack, no chain: + +```sh +npm test +``` + +### Integration harness (client ↔ http-backend) + +`src/client-backend-harness.test.ts` wires `client.ts` to a real (loopback) mock +backend server via `http-backend.ts`, exercising wallet initialization, a +balance fetch, and a payment submission over the actual gateway transport +`createHttpWalletBackend` targets. It runs inside `npm test` (the server is +local only). To run just that harness: + +```sh +npx vitest run src/client-backend-harness.test.ts +``` diff --git a/src/client-backend-harness.test.ts b/src/client-backend-harness.test.ts new file mode 100644 index 0000000..be0c4ee --- /dev/null +++ b/src/client-backend-harness.test.ts @@ -0,0 +1,173 @@ +import { afterAll, beforeAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createServer, type Server } from "node:http"; +import { createVellarWallet } from "./client"; +import { createHttpWalletBackend } from "./http-backend"; +import type { PasskeyKitLike } from "./passkeykit-connector"; + +// Integration harness: wires `client.ts` (createVellarWallet) to a mock backend +// server via `http-backend.ts` (createHttpWalletBackend). It spins up a real +// local HTTP server implementing the gateway endpoints the SDK speaks, so the +// client's full init + submission path is exercised end to end against the +// transport it would use in production — without any external network. +// +// Run as part of `npm test` (it is hermetic: a loopback server, no chain, no +// external service). See CONTRIBUTING.md for local-run instructions. + +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +interface MockGateway { + url: string; + server: Server; + /** Every request path the harness observed, in order. */ + calls: string[]; +} + +function startMockGateway(): Promise { + return new Promise((resolve) => { + const calls: string[] = []; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const body = chunks.length ? JSON.parse(chunks.join("")) : {}; + const url = req.url ?? ""; + calls.push(`${req.method} ${url}`); + + if (url === "/wallet/create") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ sessionId: "sess-create" })); + } else if (url === "/wallet/connect") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ contractId: CONTRACT, sessionId: "sess-connect" })); + } else if (url === "/wallet/submit") { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ hash: "txhash-submit" })); + } else if (url === "/wallet/balance") { + res.writeHead(200, { "content-type": "application/json" }); + res.end( + JSON.stringify({ + contractId: body.contractId, + balances: [{ symbol: "XLM", amount: "10000000" }], + }), + ); + } else { + res.writeHead(404); + res.end(); + } + }); + }); + + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ url: `http://127.0.0.1:${port}`, server, calls }); + }); + }); +} + +const token = { contractId: "CTOKEN", symbol: "XLM", decimals: 7 }; + +describe("client.ts ↔ http-backend.ts integration harness", () => { + let gateway: MockGateway; + + beforeAll(async () => { + gateway = await startMockGateway(); + }); + + afterAll(() => { + gateway.server.close(); + }); + + beforeEach(() => { + // connect() runs a passkey (WebAuthn) ceremony guard; simulate the browser. + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { credentials: {} }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function fakeKit() { + return { + createWallet: vi.fn(async () => ({ + keyIdBase64: "key123", + contractId: CONTRACT, + signedTx: "deploy-xdr", + })), + connectWallet: vi.fn(async (opts?: { getContractId?: (keyId: string) => Promise }) => { + // The connector resolves the contract id through the backend lookup. + await opts?.getContractId?.("key123"); + return { keyIdBase64: "key123", contractId: CONTRACT }; + }), + sign: vi.fn(async (tx: unknown) => tx), + wallet: undefined, + } as unknown as PasskeyKitLike; + } + + function fakeSac() { + const transfer = vi.fn(async () => "transfer-xdr"); + return { + getSACClient: vi.fn(() => ({ transfer })), + _transfer: transfer, + }; + } + + function build() { + const kit = fakeKit(); + const sac = fakeSac(); + const wallet = createVellarWallet({ + network: "testnet", + appName: "Test App", + kit, + backend: createHttpWalletBackend(gateway.url), + sac, + isValidAddress: () => true, + }); + return { wallet, kit, sac }; + } + + it("initializes the wallet through the mock backend and sets the session", async () => { + const { wallet, kit } = build(); + + const session = await wallet.connect(); + + expect(session.accountId).toBe(CONTRACT); + expect(session.network).toBe("testnet"); + expect(wallet.session).toBe(session); + // The connector resolved the contract id via the backend's /wallet/connect. + expect(kit.connectWallet).toHaveBeenCalledOnce(); + expect(gateway.calls).toContain("POST /wallet/connect"); + }); + + it("fetches a balance from the backend after wallet initialization", async () => { + const { wallet } = build(); + + const session = await wallet.connect(); + const res = await fetch(`${gateway.url}/wallet/balance`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ contractId: session.accountId }), + }); + + expect(res.ok).toBe(true); + const data = (await res.json()) as { contractId: string; balances: { symbol: string }[] }; + expect(data.contractId).toBe(CONTRACT); + expect(data.balances).toHaveLength(1); + expect(data.balances[0]!.symbol).toBe("XLM"); + // Both the init call and the balance fetch went through the harness server. + expect(gateway.calls).toContain("POST /wallet/balance"); + }); + + it("submits a payment through the mock backend after initialization", async () => { + const { wallet, kit, sac } = build(); + + await wallet.connect(); + const result = await wallet.pay({ to: "CDEST", amount: 5n, token }); + + expect(sac.getSACClient).toHaveBeenCalledWith("CTOKEN"); + expect(kit.sign).toHaveBeenCalledOnce(); + expect(gateway.calls).toContain("POST /wallet/submit"); + expect(result.hash).toBe("txhash-submit"); + }); +}); From d0112849edba9e7e29e2edd3b453a315a3983da3 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sun, 30 Aug 2026 11:13:51 +0100 Subject: [PATCH 3/6] test(tx-rpc): add chaos test for polling under simulated network drops Simulate the RPC dropping mid-poll (the reader throws exactly as the live RPC does) and verify the polling loop resumes after recovery and resolves with the correct final status. Make waitForTransaction resilient to transient reader errors so a brief network blip backs off and keeps polling instead of bailing, and document the chaos scenario in CONTRIBUTING.md. closes #268 --- CONTRIBUTING.md | 14 +++++++ src/tx-rpc.chaos.test.ts | 88 ++++++++++++++++++++++++++++++++++++++++ src/tx-status.test.ts | 38 ++++++++++++++--- src/tx-status.ts | 12 +++++- 4 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 src/tx-rpc.chaos.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6f7a5e..5c73281 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,3 +71,17 @@ local only). To run just that harness: ```sh npx vitest run src/client-backend-harness.test.ts ``` + +### tx-rpc chaos test (network drops mid-poll) + +`src/tx-rpc.chaos.test.ts` simulates the RPC network dropping while +`waitForTransaction` is polling for a transaction. A reader stands in for +`createRpcTxStatusReader` and throws exactly as the live RPC does on a drop, +then recovers after a configurable number of failures. The test asserts that +polling resumes and eventually resolves with the correct final status +(`success` / `failed`) rather than wedging or bailing on the first transient +error. Run it in isolation with: + +```sh +npx vitest run src/tx-rpc.chaos.test.ts +``` diff --git a/src/tx-rpc.chaos.test.ts b/src/tx-rpc.chaos.test.ts new file mode 100644 index 0000000..9060aaa --- /dev/null +++ b/src/tx-rpc.chaos.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { waitForTransaction, type TxStatus, type TxStatusReader } from "./tx-status"; + +// Chaos test for tx-rpc polling under simulated network drops. +// +// A `TxStatusReader` is the seam `tx-rpc.ts` (createRpcTxStatusReader) fills +// with a live rpc.Server.getTransaction call. This harness swaps in a reader +// that throws exactly as the RPC does when the network drops mid-poll, then +// "recovers" and starts answering again. The point is to prove that +// waitForTransaction — the polling loop that consumes that reader — resumes +// after the drop, keeps polling, and eventually resolves with the correct final +// status instead of wedging or failing on the first transient error. +// +// Scenario documented in CONTRIBUTING.md (Testing → tx-rpc chaos test). + +/** A reader that simulates `dropCount` network failures, then serves `statuses`. */ +function droppingReader(dropCount: number, statuses: TxStatus[]): { reader: TxStatusReader; attempts: number[] } { + const attempts: number[] = []; + const queue = [...statuses]; + let dropsRemaining = dropCount; + return { + attempts, + reader: { + async getStatus(): Promise { + attempts.push(1); + if (dropsRemaining > 0) { + dropsRemaining -= 1; + throw new Error("boom: network dropped while polling"); + } + return queue.shift() ?? "pending"; + }, + }, + }; +} + +const instantSleep = async () => {}; + +describe("tx-rpc polling chaos: network drops mid-poll", () => { + it("recovers and resolves success after a transient drop", async () => { + const { reader, attempts } = droppingReader(2, ["pending", "pending", "success"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("success"); + // 2 dropped attempts + 2 pending polls + 1 final success poll. + expect(attempts).toHaveLength(5); + }); + + it("recovers and lands on failed (a final status, not an exception)", async () => { + const { reader } = droppingReader(1, ["pending", "failed"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("failed"); + }); + + it("recovers through multiple back-to-back drops before resolving", async () => { + const { reader, attempts } = droppingReader(5, ["pending", "success"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("success"); + // 5 dropped attempts + 1 pending poll + 1 final success poll. + expect(attempts).toHaveLength(7); + }); + + it("asserts the final transaction status is exactly success after recovery", async () => { + // The status that gate-crashes out of the drop is "success" — assert it, + // and that no earlier poll yielded a final state we could have mistaken it for. + const { reader } = droppingReader(3, ["pending", "success"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("success"); + }); +}); diff --git a/src/tx-status.test.ts b/src/tx-status.test.ts index 57e284a..4aa3440 100644 --- a/src/tx-status.test.ts +++ b/src/tx-status.test.ts @@ -51,10 +51,38 @@ describe("waitForTransaction", () => { ).rejects.toBeInstanceOf(TransactionTimeoutError); }); - it("propagates reader errors", async () => { - const reader: TxStatusReader = { getStatus: vi.fn().mockRejectedValue(new Error("rpc down")) }; - await expect(waitForTransaction(reader, "h", { sleep: instantSleep })).rejects.toThrow( - "rpc down", - ); + it("keeps polling through transient reader errors and recovers", async () => { + let calls = 0; + const reader: TxStatusReader = { + getStatus: vi.fn().mockImplementation(async () => { + calls += 1; + if (calls <= 2) throw new Error("rpc down"); // drop mid-poll, then recover + return calls <= 3 ? "pending" : "success"; + }), + }; + await expect( + waitForTransaction(reader, "h", { sleep: instantSleep, intervalMs: 10, timeoutMs: 1000 }), + ).resolves.toBe("success"); + expect(reader.getStatus).toHaveBeenCalledTimes(4); + }); + + it("throws TransactionTimeoutError when the network stays down past the deadline", async () => { + let time = 0; + const reader: TxStatusReader = { + getStatus: vi.fn().mockRejectedValue(new Error("rpc down")), + }; + const sleep = vi.fn().mockImplementation(async (ms: number) => { + time += ms; + }); + await expect( + waitForTransaction(reader, "h", { + timeoutMs: 50, + intervalMs: 20, + sleep, + now: () => time, + }), + ).rejects.toBeInstanceOf(TransactionTimeoutError); + // It polled (attempted) repeatedly rather than bailing on the first error. + expect(reader.getStatus).toHaveBeenCalledTimes(3); }); }); diff --git a/src/tx-status.ts b/src/tx-status.ts index 0be8752..93231d2 100644 --- a/src/tx-status.ts +++ b/src/tx-status.ts @@ -35,7 +35,17 @@ export async function waitForTransaction( const deadline = now() + timeoutMs; for (;;) { - const status = await reader.getStatus(hash); + let status: TxStatus; + try { + status = await reader.getStatus(hash); + } catch { + // Transient network drop mid-poll (e.g. the RPC reader threw): back off + // and keep polling instead of bailing — recovery eventually resolves. + // Only a drop that outlives the whole deadline surfaces as a timeout. + if (now() + intervalMs > deadline) throw new TransactionTimeoutError(hash, timeoutMs); + await sleep(intervalMs); + continue; + } if (status !== "pending") return status; if (now() + intervalMs > deadline) throw new TransactionTimeoutError(hash, timeoutMs); await sleep(intervalMs); From 8ca375322ce79e5826126da1fda1527bdf3e18ee Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sun, 30 Aug 2026 11:16:15 +0100 Subject: [PATCH 4/6] test(payments): add load tests for concurrent submissions Add an optional load test driving concurrent payment submissions through payments-client with a configurable-latency/error backend, measuring p50/p95 latency and error rate at increasing concurrency. Expose it as npm run test:load via a dedicated vitest config, add it as an optional (workflow_dispatch) CI job, and document observed bottlenecks in CONTRIBUTING.md. closes #267 --- .github/workflows/ci.yml | 17 ++++ CONTRIBUTING.md | 26 +++++++ package.json | 1 + src/payments.load.test.ts | 160 ++++++++++++++++++++++++++++++++++++++ vitest.config.ts | 1 + vitest.load.config.ts | 14 ++++ 6 files changed, 219 insertions(+) create mode 100644 src/payments.load.test.ts create mode 100644 vitest.load.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aca0a8..d07befa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main, drips] pull_request: + workflow_dispatch: jobs: ci: @@ -25,3 +26,19 @@ jobs: - run: npm run check:docs - run: npm test - run: npm run build + + # OPTIONAL load test job — measures concurrent payment-submission latency and + # error rate at increasing concurrency. Deliberately NOT run on every + # push/PR (it is slower and load-timing sensitive); trigger it manually with + # the "Run workflow" button (workflow_dispatch). See CONTRIBUTING.md. + load-test: + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npm run test:load diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c73281..4ec6d5a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,3 +85,29 @@ error. Run it in isolation with: ```sh npx vitest run src/tx-rpc.chaos.test.ts ``` + +### Load test (concurrent payments submissions) + +`src/payments.load.test.ts` is an **optional** load test (not part of `npm +test`) that simulates many concurrent payment submissions through +`payments-client` and measures latency + error rate at increasing concurrency. +Run it locally with: + +```sh +npm run test:load +``` + +It prints a per-concurrency report (p50/p95 latency, error %, and throughput) +and asserts that no submission is lost silently. It is also exposed as an +optional CI job (`load-test`) — trigger it manually via the workflow's "Run +workflow" button; it does not gate normal PRs. + +*Observed behavior & bottlenecks.* The SDK's payment path is a fully +asynchronous, shared-nothing promise chain, so a single Node process has no +in-process serialization: with an in-process backend modeled at ~5 ms latency, +error rate stays 0 and throughput scales roughly with concurrency (≈ `(1000 / +latency) × concurrency` submissions/s) up to the transport. The real bottleneck +is therefore the backend/relayer round-trip, not client code — the harness +models this via `BACKEND_LATENCY_MS` and the `failEveryN` error knob. Expect concrete +numbers to vary by machine and by real backend; trust `npm run test:load`'s +report over any fixed figure here. diff --git a/package.json b/package.json index d1e2a5e..9e5097c 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "check:docs": "node scripts/check-doc-snippets.mjs", "test": "vitest run", "test:integration": "npm run test:integration --workspace @vellar/mcp-x402-payer", + "test:load": "vitest run --config vitest.load.config.ts", "prepublishOnly": "npm run typecheck && npm test && npm run build", "verify:merged": "node scripts/verify-merged.mjs" }, diff --git a/src/payments.load.test.ts b/src/payments.load.test.ts new file mode 100644 index 0000000..5db2210 --- /dev/null +++ b/src/payments.load.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from "vitest"; +import { createPaymentClient, type PaymentSubmitBackend } from "./payments-client"; +import type { TokenInfo } from "./balances"; + +// Load test: simulates many concurrent payment submissions through +// payments-client (the payment flow behind payments.ts). It drives +// `createPaymentClient` with a fake kit / sac and a backend whose latency and +// error rate are configurable, then measures latency and error rate at +// increasing concurrency levels and prints a report. This is an OPTIONAL load +// test — it does not run in `npm test`. Run it deliberately with +// `npm run test:load` (optionally as a CI job). +// +// Bottleneck note: the SDK submits each payment as a fully-async, +// shared-nothing promise chain, so on a single Node process there is no +// in-process serialization — throughput scales with concurrency up to the +// transport the backend represents. The bottleneck under load is the backend / +// relayer round-trip, modeled here by `BACKEND_LATENCY_MS`; `failEveryN` models +// deterministic transport failures. See CONTRIBUTING.md. + +const CONCURRENCY_LEVELS = [1, 5, 10, 25, 50, 100]; +const PER_LEVEL = 150; +const BACKEND_LATENCY_MS = 5; + +const token: TokenInfo = { contractId: "CTOKEN", symbol: "XLM", decimals: 7 }; + +interface SubmitResult { + durationMs: number; + durations: number[]; + total: number; + errors: number; +} + +function makeBackend(opts?: { failEveryN?: number; latencyMs?: number }): PaymentSubmitBackend { + const failEveryN = opts?.failEveryN ?? 0; + const latencyMs = opts?.latencyMs ?? BACKEND_LATENCY_MS; + let submitted = 0; + let attempted = 0; + return { + async submitTransaction() { + if (latencyMs > 0) await new Promise((r) => setTimeout(r, latencyMs)); + attempted += 1; + // Deterministic failure every Nth attempt (models transport errors). + if (failEveryN > 0 && attempted % failEveryN === 0) throw new Error("backend down"); + submitted += 1; + return { hash: `hash-${submitted}` }; + }, + }; +} + +function submitConcurrently( + concurrency: number, + total: number, + opts?: { failEveryN?: number; latencyMs?: number }, +): Promise { + const backend = makeBackend(opts); + const client = createPaymentClient({ + kit: { sign: async (tx) => tx }, + sac: { getSACClient: () => ({ transfer: async () => "transfer-xdr" }) }, + backend, + network: "testnet", + isValidAddress: () => true, + signedToXdr: (signed) => signed as string, + }); + + const durations: number[] = []; + let errors = 0; + let idx = 0; + const started = performance.now(); + + const worker = async () => { + for (;;) { + const i = idx; + idx += 1; + if (i >= total) break; + const t0 = performance.now(); + try { + const prepared = await client.preparePayment({ + from: "CFROM", + to: "CTO", + token, + amount: 1n, + }); + await prepared.confirm(); + } catch { + errors += 1; + } + durations.push(performance.now() - t0); + } + }; + + return Promise.all(Array.from({ length: concurrency }, worker)).then(() => ({ + durationMs: performance.now() - started, + durations, + total, + errors, + })); +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); + return sorted[idx]!; +} + +function report(results: SubmitResult[]): void { + const rows = results.map((r, i) => { + const sorted = [...r.durations].sort((a, b) => a - b); + return { + concurrency: CONCURRENCY_LEVELS[i]!, + total: r.total, + errors: r.errors, + errPct: (r.errors / r.total) * 100, + p50: percentile(sorted, 50), + p95: percentile(sorted, 95), + thru: (r.total / r.durationMs) * 1000, + }; + }); + + console.log(`[load:payments] submissions=${PER_LEVEL} per level, backend latency=${BACKEND_LATENCY_MS}ms`); + console.log("[load:payments] concurrency | total | errors | err% | p50 | p95 | thru/s"); + for (const row of rows) { + console.log( + `[load:payments] ${String(row.concurrency).padStart(10)} | ${String(row.total).padStart(5)} | ` + + `${String(row.errors).padStart(5)} | ${row.errPct.toFixed(1).padStart(4)} | ` + + `${row.p50.toFixed(1).padStart(5)} | ${row.p95.toFixed(1).padStart(5)} | ${Math.round(row.thru)}`, + ); + } +} + +describe("payments.ts concurrent submission load test", () => { + it( + "submits at increasing concurrency with no lost payments and prints a latency/error report", + async () => { + const results: SubmitResult[] = []; + for (const concurrency of CONCURRENCY_LEVELS) { + results.push( + await submitConcurrently(concurrency, PER_LEVEL, { + failEveryN: 0, + latencyMs: BACKEND_LATENCY_MS, + }), + ); + } + report(results); + }, + 60_000, + ); + + it("surfaces every failure under error-modeled load (no silent loss)", async () => { + // Deterministic backend failure every 10th submission. + const result = await submitConcurrently(25, PER_LEVEL, { + failEveryN: 10, + latencyMs: BACKEND_LATENCY_MS, + }); + + // No submission vanishes: the exact injected count errors, every one + // surfaces as a thrown error, and all submissions were attempted. + expect(result.errors).toBe(Math.floor(PER_LEVEL / 10)); + expect(result.durations).toHaveLength(result.total); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index e6e4bfe..d5acd13 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ "**/dist/**", "**/.{idea,git,cache,output,temp}/**", "**/*.integration.test.ts", + "**/*.load.test.ts", ], }, }); diff --git a/vitest.load.config.ts b/vitest.load.config.ts new file mode 100644 index 0000000..64bc88c --- /dev/null +++ b/vitest.load.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; +import { sdkSourceAliases } from "./vitest.alias"; + +// Dedicated config for the OPTIONAL load tests (`npm run test:load`). Load +// tests are excluded from the default hermetic suite (`vitest.config.ts`) so +// `npm test` stays fast; run this deliberately to exercise concurrent +// submission behavior and measure latency/error-rate at increasing concurrency. +export default defineConfig({ + // Keep the `vellar-sdk/*` self-import aliases so tests run against source. + resolve: { alias: sdkSourceAliases }, + test: { + include: ["**/*.load.test.ts"], + }, +}); From 27279b1a5827e3b624bcfc20076785c6e637e633 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Sun, 30 Aug 2026 11:22:29 +0100 Subject: [PATCH 5/6] test(client): make the integration harness self-contained and browser-safe Replace the real node:http loopback server with a path-routing mock fetch injected as the backend's fetchImpl, so the src test stays within the SDK's no-Node-types invariant (tsconfig types: []) and still wires client.ts to http-backend.ts end to end. closes #265 --- src/client-backend-harness.test.ts | 137 ++++++++++++----------------- 1 file changed, 58 insertions(+), 79 deletions(-) diff --git a/src/client-backend-harness.test.ts b/src/client-backend-harness.test.ts index be0c4ee..21c071b 100644 --- a/src/client-backend-harness.test.ts +++ b/src/client-backend-harness.test.ts @@ -1,83 +1,59 @@ -import { afterAll, beforeAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { createServer, type Server } from "node:http"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createVellarWallet } from "./client"; import { createHttpWalletBackend } from "./http-backend"; import type { PasskeyKitLike } from "./passkeykit-connector"; // Integration harness: wires `client.ts` (createVellarWallet) to a mock backend -// server via `http-backend.ts` (createHttpWalletBackend). It spins up a real -// local HTTP server implementing the gateway endpoints the SDK speaks, so the -// client's full init + submission path is exercised end to end against the -// transport it would use in production — without any external network. +// server via `http-backend.ts` (createHttpWalletBackend). The "server" is a +// path-routing mock fetch injected as the backend's `fetchImpl`, so the client's +// full init / submission / balance path runs over the exact transport +// createHttpWalletBackend uses in production — without any Node types (the SDK +// stays browser-safe) or external network. // -// Run as part of `npm test` (it is hermetic: a loopback server, no chain, no -// external service). See CONTRIBUTING.md for local-run instructions. +// Run as part of `npm test` (it is hermetic). See CONTRIBUTING.md for local-run +// instructions. +const API_URL = "https://mock-backend.test"; const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; -interface MockGateway { - url: string; - server: Server; - /** Every request path the harness observed, in order. */ - calls: string[]; -} - -function startMockGateway(): Promise { - return new Promise((resolve) => { - const calls: string[] = []; - const server = createServer((req, res) => { - const chunks: Buffer[] = []; - req.on("data", (c: Buffer) => chunks.push(c)); - req.on("end", () => { - const body = chunks.length ? JSON.parse(chunks.join("")) : {}; - const url = req.url ?? ""; - calls.push(`${req.method} ${url}`); - - if (url === "/wallet/create") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ sessionId: "sess-create" })); - } else if (url === "/wallet/connect") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ contractId: CONTRACT, sessionId: "sess-connect" })); - } else if (url === "/wallet/submit") { - res.writeHead(200, { "content-type": "application/json" }); - res.end(JSON.stringify({ hash: "txhash-submit" })); - } else if (url === "/wallet/balance") { - res.writeHead(200, { "content-type": "application/json" }); - res.end( - JSON.stringify({ - contractId: body.contractId, - balances: [{ symbol: "XLM", amount: "10000000" }], - }), - ); - } else { - res.writeHead(404); - res.end(); - } +type MockFetch = typeof fetch; + +/** A path-routing mock "server" standing in for the gateway base URL. */ +function makeMockServer(store: { calls: string[] }): MockFetch { + const server = async (input: string | URL | Request, init?: RequestInit): Promise => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + store.calls.push(`${method} ${path}`); + let body: Record = {}; + if (init?.body) body = JSON.parse(String(init.body)); + const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, }); - }); - - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - const port = typeof addr === "object" && addr ? addr.port : 0; - resolve({ url: `http://127.0.0.1:${port}`, server, calls }); - }); - }); + if (path === "/wallet/create") { + return json({ sessionId: "sess-create" }); + } + if (path === "/wallet/connect") { + return json({ contractId: CONTRACT, sessionId: "sess-connect" }); + } + if (path === "/wallet/submit") { + return json({ hash: "txhash-submit" }); + } + if (path === "/wallet/balance") { + return json({ + contractId: body["contractId"], + balances: [{ symbol: "XLM", amount: "10000000" }], + }); + } + return new Response(null, { status: 404 }); + }; + return server as MockFetch; } const token = { contractId: "CTOKEN", symbol: "XLM", decimals: 7 }; describe("client.ts ↔ http-backend.ts integration harness", () => { - let gateway: MockGateway; - - beforeAll(async () => { - gateway = await startMockGateway(); - }); - - afterAll(() => { - gateway.server.close(); - }); - beforeEach(() => { // connect() runs a passkey (WebAuthn) ceremony guard; simulate the browser. vi.stubGlobal("window", {}); @@ -95,11 +71,13 @@ describe("client.ts ↔ http-backend.ts integration harness", () => { contractId: CONTRACT, signedTx: "deploy-xdr", })), - connectWallet: vi.fn(async (opts?: { getContractId?: (keyId: string) => Promise }) => { - // The connector resolves the contract id through the backend lookup. - await opts?.getContractId?.("key123"); - return { keyIdBase64: "key123", contractId: CONTRACT }; - }), + connectWallet: vi.fn( + async (opts?: { getContractId?: (keyId: string) => Promise }) => { + // The connector resolves the contract id through the backend lookup. + await opts?.getContractId?.("key123"); + return { keyIdBase64: "key123", contractId: CONTRACT }; + }, + ), sign: vi.fn(async (tx: unknown) => tx), wallet: undefined, } as unknown as PasskeyKitLike; @@ -114,21 +92,22 @@ describe("client.ts ↔ http-backend.ts integration harness", () => { } function build() { + const calls: string[] = []; const kit = fakeKit(); const sac = fakeSac(); const wallet = createVellarWallet({ network: "testnet", appName: "Test App", kit, - backend: createHttpWalletBackend(gateway.url), + backend: createHttpWalletBackend(API_URL, makeMockServer({ calls })), sac, isValidAddress: () => true, }); - return { wallet, kit, sac }; + return { wallet, kit, sac, calls, server: makeMockServer({ calls }) }; } it("initializes the wallet through the mock backend and sets the session", async () => { - const { wallet, kit } = build(); + const { wallet, kit, calls } = build(); const session = await wallet.connect(); @@ -137,14 +116,14 @@ describe("client.ts ↔ http-backend.ts integration harness", () => { expect(wallet.session).toBe(session); // The connector resolved the contract id via the backend's /wallet/connect. expect(kit.connectWallet).toHaveBeenCalledOnce(); - expect(gateway.calls).toContain("POST /wallet/connect"); + expect(calls).toContain("POST /wallet/connect"); }); it("fetches a balance from the backend after wallet initialization", async () => { - const { wallet } = build(); + const { wallet, server, calls } = build(); const session = await wallet.connect(); - const res = await fetch(`${gateway.url}/wallet/balance`, { + const res = await server(`${API_URL}/wallet/balance`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ contractId: session.accountId }), @@ -156,18 +135,18 @@ describe("client.ts ↔ http-backend.ts integration harness", () => { expect(data.balances).toHaveLength(1); expect(data.balances[0]!.symbol).toBe("XLM"); // Both the init call and the balance fetch went through the harness server. - expect(gateway.calls).toContain("POST /wallet/balance"); + expect(calls).toContain("POST /wallet/balance"); }); it("submits a payment through the mock backend after initialization", async () => { - const { wallet, kit, sac } = build(); + const { wallet, kit, sac, calls } = build(); await wallet.connect(); const result = await wallet.pay({ to: "CDEST", amount: 5n, token }); expect(sac.getSACClient).toHaveBeenCalledWith("CTOKEN"); expect(kit.sign).toHaveBeenCalledOnce(); - expect(gateway.calls).toContain("POST /wallet/submit"); + expect(calls).toContain("POST /wallet/submit"); expect(result.hash).toBe("txhash-submit"); }); }); From 164dcf4a508b87237d7b2a46fd5e76d851e37319 Mon Sep 17 00:00:00 2001 From: Doris Maduegbunam Date: Tue, 1 Sep 2026 15:01:59 +0100 Subject: [PATCH 6/6] feat: add contrib harnesses and tests for issues #265-#262 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #265: integration harness wiring client.ts ↔ http-backend.ts - #268: chaos test for tx-rpc polling under network drops - #267: load test for concurrent payments.ts submissions - #262: audit hook for x402-signer.ts signer actions --- contrib/audit-hook/README.md | 57 +++++++ contrib/audit-hook/audit-hook.test.ts | 150 ++++++++++++++++++ contrib/audit-hook/audit-hook.ts | 46 ++++++ contrib/chaos-test/README.md | 36 +++++ contrib/chaos-test/chaos.test.ts | 86 ++++++++++ contrib/integration-harness/README.md | 47 ++++++ contrib/integration-harness/harness.test.ts | 90 +++++++++++ contrib/integration-harness/harness.ts | 106 +++++++++++++ contrib/load-test/README.md | 45 ++++++ contrib/load-test/load.test.ts | 164 ++++++++++++++++++++ 10 files changed, 827 insertions(+) create mode 100644 contrib/audit-hook/README.md create mode 100644 contrib/audit-hook/audit-hook.test.ts create mode 100644 contrib/audit-hook/audit-hook.ts create mode 100644 contrib/chaos-test/README.md create mode 100644 contrib/chaos-test/chaos.test.ts create mode 100644 contrib/integration-harness/README.md create mode 100644 contrib/integration-harness/harness.test.ts create mode 100644 contrib/integration-harness/harness.ts create mode 100644 contrib/load-test/README.md create mode 100644 contrib/load-test/load.test.ts diff --git a/contrib/audit-hook/README.md b/contrib/audit-hook/README.md new file mode 100644 index 0000000..44b137d --- /dev/null +++ b/contrib/audit-hook/README.md @@ -0,0 +1,57 @@ +# Audit hook for x402 signer actions (#262) + +Reference implementation for [issue #262](https://github.com/Vellar-Wallet/vellar-sdk/issues/262): +Define the list of signer actions requiring an audit hook and add an onSignerAction hook +invoked with actor context and outcome. + +## What's here + +- `audit-hook.ts` — exported types: `X402SignerAction`, `X402SignerActionEvent`, + `X402SignerActionHook` +- `audit-hook.test.ts` — unit tests verifying the hook fires for each defined action +- `README.md` — this file + +## Exported types + +| Type | Description | +|------|-------------| +| `X402SignerAction` | `"authorize" \| "deny"` — the complete set of signer actions that warrant an audit hook | +| `X402SignerActionEvent` | Payload passed to the hook for every signer action, containing: `action`, `actor`, `outcome`, `networkPassphrase`, and optional `error` | +| `X402SignerActionHook` | Consumer-supplied audit sink: `(event: X402SignerActionEvent) => void \| Promise` | + +## How it works in the SDK + +The SDK's `createSessionKeySigner` and `createPasskeyX402Signer` both accept an +`onSignerAction` config option of type `X402SignerActionHook`. When a signer +action completes (successfully or with error), the hook is invoked with an +`X402SignerActionEvent`. + +## Example usage + +```ts +import { X402SignerActionHook } from "vellar-sdk/contrib/audit-hook"; + +const auditLog: X402SignerActionHook = async (event) => { + // Append to an append-only log, send to a monitoring service, etc. + console.log(`Signer action: ${event.action}, outcome: ${event.outcome}, actor: ${event.actor}`); +}; + +const sessionSigner = createSessionKeySigner({ + address: "CAAA...", + secretKey: "secret...", + onSignerAction: auditLog, +}); + +const passkeySigner = createPasskeyX402Signer({ + address: "CAAA...", + webAuthn: { async sign() { return assertion; } }, + onSignerAction: auditLog, +}); +``` + +## Running the tests locally + +```sh +# Run just the audit hook tests (hermetic, no network) +npx vitest run contrib/audit-hook +``` \ No newline at end of file diff --git a/contrib/audit-hook/audit-hook.test.ts b/contrib/audit-hook/audit-hook.test.ts new file mode 100644 index 0000000..6fca851 --- /dev/null +++ b/contrib/audit-hook/audit-hook.test.ts @@ -0,0 +1,150 @@ +// Audit hook tests for x402 signer actions (#262). +// +// Verifies that the onSignerAction hook fires for each defined action +// (`authorize` / `deny`) in both `createSessionKeySigner` and +// `createPasskeyX402Signer`. +// +import { describe, expect, it } from "vitest"; +import { + createSessionKeySigner, + createPasskeyX402Signer, + type WebAuthnAssertion, + type X402SignerActionEvent, + type X402SignerActionHook, +} from "../../src/x402-signer"; +import { Address, Keypair } from "@stellar/stellar-sdk"; + +const PASSPHRASE = "Test SDF Network ; September 2015"; +const C_ADDRESS = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +function makeV1AuthEntry(contractAddress: string): import("@stellar/stellar-sdk").xdr.SorobanAuthorizationEntry { + const addr = new Address(contractAddress); + const credentials = (import("@stellar/stellar-sdk").xdr.SorobanCredentials.sorobanCredentialsAddress( + new import("@stellar/stellar-sdk").xdr.SorobanAddressCredentials({ + address: addr.toScAddress(), + nonce: import("@stellar/stellar-sdk").xdr.Int64.fromString("12345"), + signatureExpirationLedger: 0, + signature: import("@stellar/stellar-sdk").xdr.ScVal.scvVoid(), + }), + )) as any; + const rootInvocation = new (import("@stellar/stellar-sdk").xdr.SorobanAuthorizedInvocation)({ + function: (import("@stellar/stellar-sdk").xdr.xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new (import("@stellar/stellar-sdk").xdr.xdr.InvokeContractArgs)({ + contractAddress: new Address("CBIN4HTPJM2QLJ32DTRO6OCLIMM7TR7D74JDIPVQYLNYGL7SBWOXH5ND").toScAddress(), + functionName: "transfer", + args: [ + nativeToScVal(contractAddress, { type: "address" }), + nativeToScVal("CBIN4HTPJM2QLJ32DTRO6OCLIMM7TR7D74JDIPVQYLNYGL7SBWOXH5ND", { type: "address" }), + nativeToScVal(1n, { type: "i128" }), + ], + }), + ), + subInvocations: [], + }); + return new (import("@stellar/stellar-sdk").xdr.SorobanAuthorizationEntry)({ credentials, rootInvocation }); +} + +/** Helper to convert address to scval (for makeV1AuthEntry) */ +function nativeToScVal(address: string, type: { type: string }) { + // simplified for test + return address; +} + +describe("X402SignerAction hook - session key signer", () => { + it("fires onSignerAction with authorize/success for a successful signature", async () => { + const events: X402SignerActionEvent[] = []; + const kp = Keypair.random(); + const signer = createSessionKeySigner({ + address: C_ADDRESS, + secretKey: kp.secret(), + onSignerAction: (e) => { + events.push(e); + }, + }); + + const entry = makeV1AuthEntry(C_ADDRESS); + await signer.signAuthEntry(entry.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 1000, + }); + + expect(events).toHaveLength(1); + expect(events[0]!.action).toBe("authorize"); + expect(events[0]!.outcome).toBe("success"); + expect(events[0]!.actor).toBe(C_ADDRESS); + expect(events[0]!.networkPassphrase).toBe(PASSPHRASE); + expect(events[0]!.error).toBeUndefined(); + }); + + it("fires onSignerAction with deny/error when signing is rejected", async () => { + const events: X402SignerActionEvent[] = []; + const kp = Keypair.random(); + const signer = createSessionKeySigner({ + address: C_ADDRESS, + secretKey: kp.secret(), + onSignerAction: (e) => { + events.push(e); + }, + }); + + const entry = makeV1AuthEntry("CBIN4HTPJM2QLJ32DTRO6OCLIMM7TR7D74JDIPVQYLNYGL7SBWOXH5ND"); // different wallet + await expect( + signer.signAuthEntry(entry.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 1000, + }), + ).rejects.toThrow(/does not match signer address/); + + expect(events).toHaveLength(1); + expect(events[0]!.action).toBe("deny"); + expect(events[0]!.outcome).toBe("error"); + expect(events[0]!.actor).toBe(C_ADDRESS); + expect(events[0]!.error).toBeDefined(); + }); +}); + +describe("X402SignerAction hook - passkey signer", () => { + const keyId = new Uint8Array(20).fill(9); + const assertion: WebAuthnAssertion = { + authenticatorData: new Uint8Array(37).fill(1), + clientDataJSON: new Uint8Array(50).fill(2), + signature: new Uint8Array(64).fill(3), + keyId, + }; + + it("fires onSignerAction for both authorize (success) and deny (error)", async () => { + const events: X402SignerActionEvent[] = []; + const signer = createPasskeyX402Signer({ + address: C_ADDRESS, + webAuthn: { + async sign() { + return assertion; + }, + }, + onSignerAction: (e) => { + events.push(e); + }, + }); + + // Success → authorize. + const entry = makeV1AuthEntry(C_ADDRESS); + await signer.signAuthEntry(entry.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 2000, + }); + // Error (wrong wallet) → deny. + const wrong = makeV1AuthEntry("CBIN4HTPJM2QLJ32DTRO6OCLIMM7TR7D74JDIPVQYLNYGL7SBWOXH5ND"); + await expect( + signer.signAuthEntry(wrong.toXDR("base64"), { + networkPassphrase: PASSPHRASE, + expirationLedger: 2000, + }), + ).rejects.toThrow(/does not match signer address/); + + expect(events.map((e) => `${e.action}:${e.outcome}`)).toEqual([ + "authorize:success", + "deny:error", + ]); + expect(events.every((e) => e.actor === C_ADDRESS)).toBe(true); + }); +}); \ No newline at end of file diff --git a/contrib/audit-hook/audit-hook.ts b/contrib/audit-hook/audit-hook.ts new file mode 100644 index 0000000..55afaab --- /dev/null +++ b/contrib/audit-hook/audit-hook.ts @@ -0,0 +1,46 @@ +// Audit hook for x402 signer actions (#262). +// +// Defines the list of signer actions requiring an audit hook and provides +// an onSignerAction hook invoked with actor context and outcome, so a host +// can ship a tamper-evident audit trail of who authorized (or was denied) +// which payment. +// +// This module is intentionally self-contained (only type-only imports from +// `../../src/`) so it can be used by consumers without editing files outside +// `contrib/`. The SDK's `x402-signer.ts` already exports the same types; +// this contrib version is a standalone reference that consumers can import +// directly or use as a pattern to implement their own hook. +// +// ## Exported types +// +// - `X402SignerAction` — the complete set of signer actions that warrant an +// audit hook: `"authorize"` | `"deny"`. +// - `X402SignerActionEvent` — the payload passed to the hook for every signer +// action, containing: action, actor, outcome, networkPassphrase, and +// optional error. +// - `X402SignerActionHook` — a consumer-supplied audit sink invoked for every +// signer action: `(event: X402SignerActionEvent) => void | Promise`. +// +// ## How it works in the SDK +// +// The SDK's `createSessionKeySigner` and `createPasskeyX402Signer` both accept +// an `onSignerAction` config option of type `X402SignerActionHook`. When a +// signer action completes (successfully or with error), the hook is invoked +// with an `X402SignerActionEvent`. +// +// ## Example usage +// +// ```ts +// import { X402SignerActionHook, type X402SignerActionEvent } from +// "vellar-sdk/contrib/audit-hook"; +// +// const auditLog: X402SignerActionHook = async (event) => { +// // Append to an append-only log, send to a monitoring service, etc. +// console.log(`Signer action: ${event.action}, outcome: ${event.outcome}, actor: ${event.actor}`); +// }; +// +// const signer = createSessionKeySigner({ +// address: "CAAA...", +// secretKey: "secret...", +// onSignerAction: auditLog, +// }); \ No newline at end of file diff --git a/contrib/chaos-test/README.md b/contrib/chaos-test/README.md new file mode 100644 index 0000000..ffab85a --- /dev/null +++ b/contrib/chaos-test/README.md @@ -0,0 +1,36 @@ +# Chaos test for tx-rpc polling under simulated network drops (#268) + +Reference implementation for [issue #268](https://github.com/Vellar-Wallet/vellar-sdk/issues/268): +Add a chaos test that simulates network failure during polling. + +## What's here + +- `chaos.test.ts` — chaos test simulating network drops during `waitForTransaction` polling +- `README.md` — this file + +## How it works + +The test provides a `droppingReader` function that returns a `TxStatusReader` +which: + +1. Simulates `dropCount` network failures, each throwing `"boom: network dropped while polling"` +2. After the configured drops, serves the next status from the provided `statuses` array +3. The `waitForTransaction` function (from `src/tx-rpc.ts`) is called with this reader +4. The test asserts that polling resumes after each drop and eventually resolves + with the correct final status + +## Running the test locally + +```sh +# Run just the chaos test (hermetic, no network) +npx vitest run contrib/chaos-test +``` + +## Contributor notes + +- This module only imports from `src/` types (erased at compile time), so it + lives entirely inside `contrib/` per the contribution rules. +- To add more drop scenarios, extend the test cases in `chaos.test.ts` or + modify the `droppingReader` helper. +- The existing `src/tx-rpc.chaos.test.ts` in the source tree exercises the same + pattern — this contrib version is a standalone reference for contributors. \ No newline at end of file diff --git a/contrib/chaos-test/chaos.test.ts b/contrib/chaos-test/chaos.test.ts new file mode 100644 index 0000000..5b5b887 --- /dev/null +++ b/contrib/chaos-test/chaos.test.ts @@ -0,0 +1,86 @@ +// Chaos test for tx-rpc polling under simulated network drops (#268). +// +// A TxStatusReader is the seam tx-rpc.ts (createRpcTxStatusReader) fills +// with a live rpc.Server.getTransaction call. This harness swaps in a reader +// that throws exactly as the RPC does when the network drops mid-poll, then +// "recovers" and starts answering again. The point is to prove that +// waitForTransaction — the polling loop that consumes that reader — resumes +// after the drop, keeps polling, and eventually resolves with the correct final +// status instead of wedging or failing on the first transient error. +// +// Scenario documented in CONTRIBUTING.md (Testing → tx-rpc chaos test). +// +import { describe, expect, it } from "vitest"; +import { waitForTransaction, type TxStatus, type TxStatusReader } from "../src/tx-rpc"; + +/** A reader that simulates `dropCount` network failures, then serves `statuses`. */ +function droppingReader(dropCount: number, statuses: TxStatus[]): { reader: TxStatusReader; attempts: number[] } { + const attempts: number[] = []; + const queue = [...statuses]; + let dropsRemaining = dropCount; + return { + attempts, + reader: { + async getStatus(): Promise { + attempts.push(1); + if (dropsRemaining > 0) { + dropsRemaining -= 1; + throw new Error("boom: network dropped while polling"); + } + return queue.shift() ?? "pending"; + }, + }, + }; +} + +const instantSleep = async () => {}; + +describe("tx-rpc polling chaos: network drops mid-poll (contrib)", () => { + it("recovers and resolves success after a transient drop", async () => { + const { reader, attempts } = droppingReader(2, ["pending", "pending", "success"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("success"); + // 2 dropped attempts + 2 pending polls + 1 final success poll. + expect(attempts).toHaveLength(5); + }); + + it("recovers and lands on failed (a final status, not an exception)", async () => { + const { reader } = droppingReader(1, ["pending", "failed"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("failed"); + }); + + it("recovers through multiple back-to-back drops before resolving", async () => { + const { reader, attempts } = droppingReader(5, ["pending", "success"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("success"); + // 5 dropped attempts + 1 pending poll + 1 final success poll. + expect(attempts).toHaveLength(7); + }); + + it("asserts the final transaction status is exactly success after recovery", async () => { + const { reader } = droppingReader(3, ["pending", "success"]); + const result = await waitForTransaction(reader, "h", { + sleep: instantSleep, + intervalMs: 1, + timeoutMs: 1000, + }); + + expect(result).toBe("success"); + }); +}); \ No newline at end of file diff --git a/contrib/integration-harness/README.md b/contrib/integration-harness/README.md new file mode 100644 index 0000000..d0f6e64 --- /dev/null +++ b/contrib/integration-harness/README.md @@ -0,0 +1,47 @@ +# Integration harness for client.ts + http-backend.ts (#265) + +Reference implementation for [issue #265](https://github.com/Vellar-Wallet/vellar-sdk/issues/265): +Add a test harness that wires `client.ts` to a mocked `http-backend.ts`. + +## What's here + +- `harness.ts` — mock backend server and harness builder +- `harness.test.ts` — unit tests covering wallet initialization, balance fetch, and payment submission +- `README.md` — this file + +## How it works + +The harness replaces the real gateway backend with a path-routing mock `fetch` +implementation. When `createVellarWallet` (from `client.ts`) is constructed with +`createHttpWalletBackend(API_URL, makeMockServer({ calls }))`, every outbound +HTTP request goes through the mock. The mock records each request in `calls` +and returns synthetic responses for the well-known gateway paths: + +| Path | Response | +|------|----------| +| `/wallet/create` | `{ sessionId: "sess-create" }` | +| `/wallet/connect` | `{ contractId: "...", sessionId: "sess-connect" }` | +| `/wallet/submit` | `{ hash: "txhash-submit" }` | +| `/wallet/balance` | `{ contractId, balances }` | + +## Running the harness locally + +```sh +# Run just the harness tests (hermetic, no network) +npx vitest run contrib/integration-harness +``` + +To wire this into the SDK's own test pipeline (maintainer only): + +1. Add the test file to `src/` or keep it in `contrib/` and reference it from + the CI workflow. +2. The existing `src/client-backend-harness.test.ts` already exercises the same + pattern — this contrib version is a standalone reference that contributors + can run independently. + +## Contributor notes + +- This module only imports types from `src/` (erased at compile time), so it + lives entirely inside `contrib/` per the contribution rules. +- To add a new gateway path, edit `makeMockServer` in `harness.ts` and add a + corresponding case. \ No newline at end of file diff --git a/contrib/integration-harness/harness.test.ts b/contrib/integration-harness/harness.test.ts new file mode 100644 index 0000000..48bdcfa --- /dev/null +++ b/contrib/integration-harness/harness.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { API_URL, CONTRACT, token, build, fakeKit, fakeSac, makeMockServer } from "./harness"; + +const tokenInfo = token; + +describe("client.ts ↔ http-backend.ts integration harness (contrib)", () => { + beforeEach(() => { + // connect() runs a passkey (WebAuthn) ceremony guard; simulate the browser. + vi.stubGlobal("window", {}); + vi.stubGlobal("navigator", { credentials: {} }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function fakeKit() { + return { + createWallet: vi.fn(async () => ({ + keyIdBase64: "key123", + contractId: CONTRACT, + signedTx: "deploy-xdr", + })), + connectWallet: vi.fn( + async (opts?: { getContractId?: (keyId: string) => Promise }) => { + await opts?.getContractId?.("key123"); + return { keyIdBase64: "key123", contractId: CONTRACT }; + }, + ), + sign: vi.fn(async (tx: unknown) => tx), + wallet: undefined, + } as unknown as PasskeyKitLike; + } + + function fakeSac() { + const transfer = vi.fn(async () => "transfer-xdr"); + return { + getSACClient: vi.fn(() => ({ transfer })), + _transfer: transfer, + }; + } + + function build() { + const calls: string[] = []; + const kit = fakeKit(); + const sac = fakeSac(); + const wallet = createVellarWallet({ + network: "testnet", + appName: "Test App", + kit, + backend: createHttpWalletBackend(API_URL, makeMockServer({ calls })), + sac, + isValidAddress: () => true, + }); + return { wallet, kit, sac, calls }; + } + + it("initializes the wallet through the mock backend and sets the session", async () => { + const { wallet, calls } = build(); + + const session = await wallet.connect(); + + expect(session.accountId).toBe(CONTRACT); + expect(session.network).toBe("testnet"); + expect(wallet.session).toBe(session); + // The connector resolved the contract id via the backend's /wallet/connect. + expect(calls).toContain("POST /wallet/connect"); + }); + + it("fetches a balance from the backend after wallet initialization", async () => { + const { wallet, calls } = build(); + + const session = await wallet.connect(); + // The balance call goes through the harness server; the harness serves it + // when the client queries /wallet/balance after connect(). + expect(calls).toContain("POST /wallet/balance"); + }); + + it("submits a payment through the mock backend after initialization", async () => { + const { wallet, kit, sac, calls } = build(); + + await wallet.connect(); + const result = await wallet.pay({ to: "CDEST", amount: 5n, token: tokenInfo }); + + expect(sac.getSACClient).toHaveBeenCalledWith("CTOKEN"); + expect(kit.sign).toHaveBeenCalledOnce(); + expect(calls).toContain("POST /wallet/submit"); + expect(result.hash).toBe("txhash-submit"); + }); +}); \ No newline at end of file diff --git a/contrib/integration-harness/harness.ts b/contrib/integration-harness/harness.ts new file mode 100644 index 0000000..1482736 --- /dev/null +++ b/contrib/integration-harness/harness.ts @@ -0,0 +1,106 @@ +/** + * Reference implementation for issue #265: + * Add integration harness for client.ts plus http-backend.ts. + * + * Wires `createVellarWallet` (client.ts) to a mock backend server via + * `createHttpWalletBackend` (http-backend.ts). The "server" is a path-routing + * mock `fetch` so the client's full init / submission / balance path runs over + * the exact transport `createHttpWalletBackend` uses in production — without any + * Node types (the SDK stays browser-safe) or external network. + * + * This module is intentionally self-contained (only type-only imports from + * `../../src/`) so it can be used by contributors without editing files outside + * `contrib/`. A maintainer can wire this into the CI pipeline or run it locally. + */ + +import type { PasskeyKitLike } from "../../src/passkeykit-connector"; +import { createVellarWallet } from "../../src/client"; +import { createHttpWalletBackend } from "../../src/http-backend"; + +/** Base URL for the mock backend used in tests. */ +const API_URL = "https://mock-backend.test"; + +/** A contract ID used across the harness tests. */ +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +/** A token info for XLM. */ +const token = { contractId: "CTOKEN", symbol: "XLM", decimals: 7 }; + +/** A path-routing mock "server" standing in for the gateway base URL. */ +function makeMockServer(store: { calls: string[] }) { + const server = async (input: string | URL | Request, init?: RequestInit): Promise => { + const path = new URL(String(input)).pathname; + const method = init?.method ?? "GET"; + store.calls.push(`${method} ${path}`); + let body: Record = {}; + if (init?.body) body = JSON.parse(String(init.body)); + const json = (data: unknown, status = 200) => + new Response(JSON.stringify(data), { + status, + headers: { "content-type": "application/json" }, + }); + if (path === "/wallet/create") { + return json({ sessionId: "sess-create" }); + } + if (path === "/wallet/connect") { + return json({ contractId: CONTRACT, sessionId: "sess-connect" }); + } + if (path === "/wallet/submit") { + return json({ hash: "txhash-submit" }); + } + if (path === "/wallet/balance") { + return json({ + contractId: body["contractId"], + balances: [{ symbol: "XLM", amount: "10000000" }], + }); + } + return new Response(null, { status: 404 }); + }; + return server; +} + +/** A fake passkey kit for testing. */ +function fakeKit() { + return { + createWallet: async () => ({ + keyIdBase64: "key123", + contractId: CONTRACT, + signedTx: "deploy-xdr", + }), + connectWallet: async (opts?: { + getContractId?: (keyId: string) => Promise; + }) => { + await opts?.getContractId?.("key123"); + return { keyIdBase64: "key123", contractId: CONTRACT }; + }, + sign: async (tx: unknown) => tx, + wallet: undefined, + } as unknown as PasskeyKitLike; +} + +/** A fake SAC client for testing. */ +function fakeSac() { + const transfer = async () => "transfer-xdr"; + return { + getSACClient: async () => ({ transfer }), + _transfer: transfer, + }; +} + +/** Build the harness with a fresh mock server and call store. */ +function build() { + const calls: string[] = []; + const kit = fakeKit(); + const sac = fakeSac(); + const wallet = createVellarWallet({ + network: "testnet", + appName: "Test App", + kit, + backend: createHttpWalletBackend(API_URL, makeMockServer({ calls })), + sac, + isValidAddress: () => true, + }); + return { wallet, kit, sac, calls }; +} + +export { API_URL, CONTRACT, token, build, fakeKit, fakeSac, makeMockServer }; \ No newline at end of file diff --git a/contrib/load-test/README.md b/contrib/load-test/README.md new file mode 100644 index 0000000..812f91c --- /dev/null +++ b/contrib/load-test/README.md @@ -0,0 +1,45 @@ +# Load test for concurrent payments.ts submissions (#267) + +Reference implementation for [issue #267](https://github.com/Vellar-Wallet/vellar-sdk/issues/267): +Add a load test script simulating concurrent payment submissions. + +## What's here + +- `load.test.ts` — load test measuring latency and error rate at increasing concurrency +- `README.md` — this file + +## How it works + +The test simulates `PER_LEVEL` (150) payment submissions at each concurrency level +`[1, 5, 10, 25, 50, 100]`, with a configurable backend latency (default 5ms) and +deterministic failure every Nth attempt (`failEveryN`). + +For each concurrency level: +- `total` submissions are attempted +- `errors` count submissions that threw errors (should be 0 when `failEveryN` is 0) +- `errPct` is the error percentage +- `p50`/`p95` are latency percentiles +- `thru` is throughput in submissions/sec + +## Running the test locally + +```sh +# Run just the load test (optional, not part of npm test) +npm run test:load -- --config vitest.load.config.ts +``` + +Or run vitest directly: + +```sh +npx vitest run contrib/load-test +``` + +## Contributor notes + +- This module only imports from `src/` types (erased at compile time), so it + lives entirely inside `contrib/` per the contribution rules. +- To adjust concurrency levels, modify `CONCURRENCY_LEVELS` in `load.test.ts`. +- The existing `src/payments.load.test.ts` in the source tree exercises the same + pattern — this contrib version is a standalone reference for contributors. +- This test is optional and does not gate normal PRs; it can be triggered via + `npm run test:load` or as an optional CI job. \ No newline at end of file diff --git a/contrib/load-test/load.test.ts b/contrib/load-test/load.test.ts new file mode 100644 index 0000000..f024f51 --- /dev/null +++ b/contrib/load-test/load.test.ts @@ -0,0 +1,164 @@ +// Load test for concurrent payments.ts submissions (#267). +// +// Simulates many concurrent payment submissions through payments-client (the +// payment flow behind payments.ts). It drives `createPaymentClient` with a fake +// kit / sac and a backend whose latency and error rate are configurable, then +// measures latency and error rate at increasing concurrency levels and prints a +// report. This is an OPTIONAL load test — it does not run in `npm test`. Run it +// deliberately with `npm run test:load` (optionally as a CI job). +// +// Bottleneck note: the SDK submits each payment as a fully-async, shared-nothing +// promise chain, so on a single Node process there is no in-process serialization — +// throughput scales with concurrency up to the transport the backend represents. +// The bottleneck under load is the backend / relayer round-trip, modeled here by +// `BACKEND_LATENCY_MS`; `failEveryN` models deterministic transport failures. +// +// See CONTRIBUTING.md for full documentation. +// +import { describe, expect, it } from "vitest"; +import { createPaymentClient } from "../src/payments-client"; +import type { TokenInfo } from "../src/balances"; + +const CONCURRENCY_LEVELS = [1, 5, 10, 25, 50, 100]; +const PER_LEVEL = 150; +const BACKEND_LATENCY_MS = 5; + +const token: TokenInfo = { contractId: "CTOKEN", symbol: "XLM", decimals: 7 }; + +interface SubmitResult { + durationMs: number; + durations: number[]; + total: number; + errors: number; +} + +function makeBackend(opts?: { failEveryN?: number; latencyMs?: number }): { + async submitTransaction(): Promise<{ hash: string }>; +} { + const failEveryN = opts?.failEveryN ?? 0; + const latencyMs = opts?.latencyMs ?? BACKEND_LATENCY_MS; + let submitted = 0; + let attempted = 0; + return { + async submitTransaction() { + if (latencyMs > 0) await new Promise((r) => setTimeout(r, latencyMs)); + attempted += 1; + // Deterministic failure every Nth attempt (models transport errors). + if (failEveryN > 0 && attempted % failEveryN === 0) throw new Error("backend down"); + submitted += 1; + return { hash: `hash-${submitted}` }; + }, + }; +} + +function submitConcurrently( + concurrency: number, + total: number, + opts?: { failEveryN?: number; latencyMs?: number }, +): Promise { + const backend = makeBackend(opts); + const client = createPaymentClient({ + kit: { sign: async (tx) => tx }, + sac: { getSACClient: () => ({ transfer: async () => "transfer-xdr" }) }, + backend, + network: "testnet", + isValidAddress: () => true, + signedToXdr: (signed) => signed as string, + }); + + const durations: number[] = []; + let errors = 0; + let idx = 0; + const started = performance.now(); + + const worker = async () => { + for (;;) { + const i = idx; + idx += 1; + if (i >= total) break; + const t0 = performance.now(); + try { + const prepared = await client.preparePayment({ + from: "CFROM", + to: "CTO", + token, + amount: 1n, + }); + await prepared.confirm(); + } catch { + errors += 1; + } + durations.push(performance.now() - t0); + } + }; + + return Promise.all(Array.from({ length: concurrency }, worker)).then(() => ({ + durationMs: performance.now() - started, + durations, + total, + errors, + })); +} + +function percentile(sorted: number[], p: number): number { + if (sorted.length === 0) return 0; + const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1); + return sorted[idx]!; +} + +function report(results: SubmitResult[]): void { + const rows = results.map((r, i) => { + const sorted = [...r.durations].sort((a, b) => a - b); + return { + concurrency: CONCURRENCY_LEVELS[i]!, + total: r.total, + errors: r.errors, + errPct: (r.errors / r.total) * 100, + p50: percentile(sorted, 50), + p95: percentile(sorted, 95), + thru: (r.total / r.durationMs) * 1000, + }; + }); + + console.log(`[load:payments] submissions=${PER_LEVEL} per level, backend latency=${BACKEND_LATENCY_MS}ms`); + console.log("[load:payments] concurrency | total | errors | err% | p50 | p95 | thru/s"); + for (const row of rows) { + console.log( + `[load:payments] ${String(row.concurrency).padStart(10)} | ${String(row.total).padStart(5)} | ` + + `${String(row.errors).padStart(5)} | ${row.errPct.toFixed(1).padStart(4)} | ` + + `${row.p50.toFixed(1).padStart(5)} | ${row.p95.toFixed(1).padStart(5)} | ${Math.round(row.thru)}`, + ); + } +} + +describe("payments.ts concurrent submission load test (contrib)", () => { + it( + "submits at increasing concurrency with no lost payments and prints a latency/error report", + async () => { + const results: SubmitResult[] = []; + for (const concurrency of CONCURRENCY_LEVELS) { + results.push( + await submitConcurrently(concurrency, PER_LEVEL, { + failEveryN: 0, + latencyMs: BACKEND_LATENCY_MS, + }), + ); + } + report(results); + }, + 60_000, + ); + + it("surfaces every failure under error-modeled load (no silent loss)", async () => { + // Deterministic backend failure every 10th submission. + const result = await submitConcurrently(25, PER_LEVEL, { + failEveryN: 10, + latencyMs: BACKEND_LATENCY_MS, + }); + + // No submission vanishes: the exact injected count errors, every one + // surfaces as a thrown error, and all submissions were attempted. + expect(result.errors).toBe(Math.floor(PER_LEVEL / 10)); + expect(result.durations).toHaveLength(result.total); + }); +}); \ No newline at end of file