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 e3eab2b..0805ad8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,6 +52,65 @@ 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 +``` + +### 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 +``` + +### 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. ## Integration testing Hermetic (`npm test`) never touches the network. A separate, deliberate suite 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 diff --git a/package.json b/package.json index 2fbba98..dda63b1 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,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/client-backend-harness.test.ts b/src/client-backend-harness.test.ts new file mode 100644 index 0000000..21c071b --- /dev/null +++ b/src/client-backend-harness.test.ts @@ -0,0 +1,152 @@ +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). 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). See CONTRIBUTING.md for local-run +// instructions. + +const API_URL = "https://mock-backend.test"; +const CONTRACT = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4"; + +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" }, + }); + 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", () => { + 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 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, server: makeMockServer({ calls }) }; + } + + it("initializes the wallet through the mock backend and sets the session", async () => { + const { wallet, kit, 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(kit.connectWallet).toHaveBeenCalledOnce(); + expect(calls).toContain("POST /wallet/connect"); + }); + + it("fetches a balance from the backend after wallet initialization", async () => { + const { wallet, server, calls } = build(); + + const session = await wallet.connect(); + const res = await server(`${API_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(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 }); + + expect(sac.getSACClient).toHaveBeenCalledWith("CTOKEN"); + expect(kit.sign).toHaveBeenCalledOnce(); + expect(calls).toContain("POST /wallet/submit"); + expect(result.hash).toBe("txhash-submit"); + }); +}); 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/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); diff --git a/src/x402-signer.test.ts b/src/x402-signer.test.ts index 1174cae..a49e468 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"; import { CapabilityDeniedError, InvalidCapabilityRuleError } from "./x402-signer-capabilities"; @@ -117,6 +118,55 @@ 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("capability scoping (#224)", () => { it("signs as before when no capabilities are configured (backward compatible)", async () => { const kp = Keypair.random(); @@ -234,11 +284,48 @@ describe("createPasskeyX402Signer", () => { 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); describe("capability scoping (#224)", () => { 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); keyId: new Uint8Array(20).fill(9), }; diff --git a/src/x402-signer.ts b/src/x402-signer.ts index cb32e40..e5e584c 100644 --- a/src/x402-signer.ts +++ b/src/x402-signer.ts @@ -31,6 +31,34 @@ import { type CapabilityRule, } from "./x402-signer-capabilities"; +// ── 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]). @@ -195,6 +223,11 @@ export interface SessionKeySignerConfig { */ 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; * Client-side capability scoping (#224): restrict which resource * type (contract) + action (function name) combinations this signer will * sign, independent of the on-chain policy. Omit for no scoping (signs @@ -223,12 +256,32 @@ 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 }); const capabilities = config.capabilities ?? []; assertValidCapabilityRules(capabilities); return { address: config.address, async signAuthEntry(entryXdr, { networkPassphrase, expirationLedger }) { + 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; + } const entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64"); assertEntryAddress(entry, config.address); const request = capabilityRequestFor(entry); @@ -268,6 +321,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; /** Client-side capability scoping (#224) — see * {@link SessionKeySignerConfig.capabilities}. Same semantics apply here. */ capabilities?: readonly CapabilityRule[]; @@ -285,12 +343,37 @@ 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 }); const capabilities = config.capabilities ?? []; assertValidCapabilityRules(capabilities); return { address: config.address, async signAuthEntry(entryXdr, { networkPassphrase, expirationLedger }) { + 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; + } const entry = xdr.SorobanAuthorizationEntry.fromXDR(entryXdr, "base64"); assertEntryAddress(entry, config.address); const request = capabilityRequestFor(entry); 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"], + }, +}); 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`.