Skip to content
Merged
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches: [main, drips]
pull_request:
workflow_dispatch:

jobs:
ci:
Expand All @@ -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
59 changes: 59 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
57 changes: 57 additions & 0 deletions contrib/audit-hook/README.md
Original file line number Diff line number Diff line change
@@ -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<void>` |

## 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
```
150 changes: 150 additions & 0 deletions contrib/audit-hook/audit-hook.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 46 additions & 0 deletions contrib/audit-hook/audit-hook.ts
Original file line number Diff line number Diff line change
@@ -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<void>`.
//
// ## 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,
// });
36 changes: 36 additions & 0 deletions contrib/chaos-test/README.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading