diff --git a/.github/workflows/sdk-test.yml b/.github/workflows/sdk-test.yml index 03e8454..f25a914 100644 --- a/.github/workflows/sdk-test.yml +++ b/.github/workflows/sdk-test.yml @@ -27,8 +27,9 @@ jobs: with: node-version: 22 # --legacy-peer-deps: @stellar/mpp@0.7.1 peer-requires - # @stellar/stellar-sdk@^15.1.0 while this package pins ^14.5.0 - a - # pre-existing mismatch, unrelated to this workflow, not fixed here. + # @stellar/stellar-sdk@^15.1.0 while this package pins ^16.3.0 (what + # @x402/stellar needs) - a pre-existing mismatch, unrelated to this + # workflow, not fixed here. No @stellar/mpp release accepts 16 yet. - run: npm install --no-audit --no-fund --legacy-peer-deps - run: npm run build - run: npm test diff --git a/packages/cli/bin/nirium.js b/packages/cli/bin/nirium.js index daf0e03..fbafb32 100755 --- a/packages/cli/bin/nirium.js +++ b/packages/cli/bin/nirium.js @@ -186,7 +186,7 @@ function scaffoldX402(dir, name) { type: 'module', scripts: { dev: 'tsx watch src/server.ts', build: 'tsc' }, dependencies: { - nirium: '^0.15.0', + nirium: '^0.16.0', express: '^5.1.0', '@x402/express': '^2.17.0', '@x402/core': '^2.17.0', @@ -252,7 +252,7 @@ function scaffoldTS(dir, name) { "build": "tsc" }, dependencies: { - "nirium": "^0.15.0", + "nirium": "^0.16.0", "tsx": "^4.19.0", "typescript": "^5.7.0", "dotenv": "^16.4.5" diff --git a/packages/sdk-python/README.md b/packages/sdk-python/README.md index 51bcd70..22c66ee 100644 --- a/packages/sdk-python/README.md +++ b/packages/sdk-python/README.md @@ -2,7 +2,7 @@ Autonomous treasury and agentic-payments infrastructure for **Nirium Protocol** on Stellar/Soroban — Python client. -Nirium agents rebalance USDC ↔ CETES (tokenized Mexican T-bills via Etherfuse) 24/7 without human intervention. Built for developers who want to integrate autonomous treasury management, agentic payments (x402 + MPP), and real-time market signals into their applications. +Nirium agents rebalance USDC ↔ CETES (tokenized Mexican T-bills via Etherfuse) 24/7 without human intervention. Built for developers who want to integrate autonomous treasury management, agentic payments with x402, and real-time market signals into their applications. ## Install @@ -132,15 +132,15 @@ Environment variables: `STELLAR_SECRET_KEY` (or `STELLAR_TESTNET_SECRET_KEY`), o Runnable example: [`examples/langchain-x402-agent`](../../examples/langchain-x402-agent). -### MPP — Session-Based Budget Delegation -```python -agent.init_mpp( - secret_key="S...", - network="stellar:testnet", -) +### MPP Charge: does not work in this version -response = await agent.mpp_fetch("https://nirium-agent.fly.dev/api/v1/mpp/signals") -``` +`init_mpp()` and `mpp_fetch()` in nirium 0.11.0 cannot pay an MPP Charge server. `mpp_fetch()` raises `ValueError: This is not a valid account:` before sending anything, because it: + +- reads the payment challenge from the JSON body of the `402`, but MPP sends it in the `WWW-Authenticate` header; +- builds a classic Stellar payment, where MPP Charge needs a Soroban SAC transfer; +- sends its proof in an `X-PAYMENT` header, where MPP uses `Authorization: Payment`. + +This is not fixed in the version published today. Use x402 (`init_x402()` / `x402_fetch()`) for paid endpoints. The TypeScript package has an MPP Charge client (experimental, see its README); Nirium's hosted testnet `/api/v1/mpp/*` endpoint also rejected MPP payments when tested on 1 October 2026 (mainnet not tested), so do not rely on the hosted endpoints from any language. ### Endpoint Access Model @@ -150,7 +150,7 @@ response = await agent.mpp_fetch("https://nirium-agent.fly.dev/api/v1/mpp/signal | **Protected** (API key) | `execute`, `market`, `loop/start\|stop\|scan`, `subscriptions`, `skills/install`, `webhooks` | | **WebSocket** (JWT) | `/ws/signals` — real-time signal stream | | **x402 Premium** | `/api/v1/premium/signals` ($0.02 USDC), `/api/v1/premium/market` ($0.05 USDC) | -| **MPP** | `/api/v1/mpp/signals`, `/api/v1/mpp/market` | +| **MPP Charge** | `/api/v1/mpp/signals`, `/api/v1/mpp/market` (testnet endpoint rejected MPP payments when tested, mainnet untested, and `mpp_fetch()` does not work in this version) | ## Payouts @@ -234,7 +234,7 @@ Anchor a **hash** rather than the data itself: IPFS content cannot be deleted, s | WebSocket | `subscribe()`, `on()` decorator | | x402 Payments | `init_x402()`, `x402_fetch()` | | LangChain | `NiriumX402Tool`, `create_nirium_x402_tool()` | -| MPP Payments | `init_mpp()`, `mpp_fetch()` | +| MPP Charge (does not work in this version, see above) | `init_mpp()`, `mpp_fetch()` | ## Requirements diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index b350d88..c41ed21 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,7 +2,23 @@ All notable changes to the `nirium` package are documented here. -## Unreleased +## 0.16.0 - 2026-10-02 + +### Added + +- `initX402({ policy })` (experimental): pre-sign policy hook for `x402Fetch()` ([#96](https://github.com/nirium-protocol/nirium/issues/96)). The policy is asked after the Stellar authorization is built and before anything is signed; only an `ALLOW` bound to that exact authorization (`contextHash`) and still valid reaches the signer. Optional `currentVersion` re-reads the policy version right before signing and refuses a stale `ALLOW` (not atomic with signing). Refusals throw `X402PolicyError` with an `outcome`. A clock that is not a finite number refuses with `CLOCK_INVALID`. The `onDecision` receipts are `ALLOWED` (notice), then `SIGNED` only once the signer returned a signature or `SIGNER_ERROR` if it did not; the observer runs before the final clock and identity checks, and a promise it returns is not awaited and its rejection is swallowed. Without `policy`, behavior is unchanged. New exports: `X402PolicyError` and the `X402Policy*` types. +- `x402Fetch()` throws `X402SpendCapError` when `@x402/core` (2.23.0 and later) refuses a payment for being above its default per-payment cap of $1. It states the amount asked for, the cap and that the signer was not called, instead of the generic `Failed to create payment payload: ... rejected by spendControls.maxAmountPerPayment` error. Other `spendControls` rejections are unchanged. The cap itself is not exposed in `initX402()` yet. New export: `X402SpendCapError`. +- The gate accepts both auth preimage variants stellar-sdk 16 can produce, the legacy one and CAP-71 (`...WithAddress`), the latter only when bound to the signer's address. + +### Fixed + +- `initMpp()` threw `TypeError: Mppx.create is not a function` in 0.15.0: it called the wrong export of `mppx` with a configuration the library does not take. It now builds the client the way `mppx` and `@stellar/mpp` document (`Mppx` from `mppx/client`, `stellar.charge()` from `@stellar/mpp/charge/client`) with `polyfill: false`, so it never replaces `globalThis.fetch` (which would also have intercepted the 402s of `x402Fetch`). `MppConfig.network` is still accepted but no longer used: the network comes from the server's challenge. Checked against the agent's own MPP middleware on testnet in `pull` and `push` mode. MPP Charge is still **not** verified end to end against Nirium's hosted endpoints: the testnet one rejected the payment when tested on 2026-10-01, and the mainnet one was not tested; see the README. + +### Changed + +- `@stellar/stellar-sdk` ^16.3.0 and `@x402/fetch`/`@x402/stellar`/`@x402/core` ^2.28.0. Node.js >= 22.12.0 is now required, and `nirium` declares it in `engines.node` so npm warns up front. The two packages declare `>=22.0.0`, but stellar-sdk 16 pulls in the ESM-only `@noble/hashes` 2.x, and `require()` of an ES module works without a flag only from Node 22.12.0 (on 22.11 `require('nirium')` throws `ERR_REQUIRE_ESM`). + +## 0.15.0 - 2026-09-24 ### Added diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a33ef51..54df07c 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -2,7 +2,7 @@ Autonomous treasury and agentic-payments infrastructure for **Nirium Protocol** on Stellar/Soroban. -Nirium agents rebalance USDC ↔ CETES (tokenized Mexican T-bills via Etherfuse) 24/7 without human intervention. Built for developers who want to integrate autonomous treasury management, agentic payments (x402 + MPP) — in both directions, paying for other APIs with `initX402()` and charging for your own with `x402Serve()` — and real-time market data into their applications. +Nirium agents rebalance USDC ↔ CETES (tokenized Mexican T-bills via Etherfuse) 24/7 without human intervention. Built for developers who want to integrate autonomous treasury management, agentic payments with x402 (in both directions: paying for other APIs with `initX402()` and charging for your own with `x402Serve()`), an experimental MPP Charge client, and real-time market data into their applications. ## Install @@ -61,7 +61,7 @@ agent.subscribe((signal) => { | Admin | `configureLLM()` | | WebSocket | `subscribe()`, `onLog()`, `disconnect()` | | x402 Payments | `initX402()`, `x402Fetch()` | -| MPP Payments | `initMpp()`, `mppFetch()` | +| MPP Charge client (experimental, see below) | `initMpp()`, `mppFetch()` | ## Authentication @@ -93,18 +93,84 @@ const response = await agent.x402Fetch('https://nirium-agent.fly.dev/api/v1/prem const data = await response.json(); ``` -### MPP — Session-Based Budget Delegation +#### Per-payment cap + +`@x402/core` 2.23.0 and later refuse any payment above a default cap of **$1** per request (a default asset such as USDC), before anything is signed. `x402Fetch()` now says so with an `X402SpendCapError` instead of a generic error: + ```typescript -agent.initMpp({ - secretKey: 'S...', - network: 'stellar:testnet', - mode: 'pull', +import { X402SpendCapError } from 'nirium'; + +try { + await agent.x402Fetch(url); +} catch (e) { + if (e instanceof X402SpendCapError) { + console.log(e.formattedAmount, e.cap, e.signerCalled); // '2 USDC' '$1' false + } +} +``` + +The error carries `url`, `amount` (atomic units, the cheapest offer when the server lists several), `formattedAmount`, `asset`, `network` and `cap`, and `signerCalled` is always `false`: nothing was signed or sent. The cap is enforced by `@x402/core` and cannot be changed from `initX402()` yet. Other `spendControls` rejections (for example a non-default asset) still surface as the original error. + +#### Pre-sign policy hook (experimental) + +Pass `policy` to `initX402()` and every `x402Fetch()` asks your policy before anything is signed. The question is asked after the Stellar authorization is built, so the policy sees exactly what would be signed: amount, destination, asset, nonce, expiration ledger and network, decoded from the bytes. + +**Experimental, new in 0.16.0.** The shape of `policy` (the context object, the outcomes, the records `onDecision` receives) can still change in a minor release before 1.0.0. Its scope is the agent-side cases that [#96](https://github.com/nirium-protocol/nirium/issues/96) groups as G1 (they come from the harness of @CodeDeityX), and nothing beyond them. What it does not cover is listed in the last paragraph of this section: code in the same process that holds the raw signer (L02 in that discussion), and pending-capacity reservation (L01: concurrent requests that observe the same remaining capacity). Neither is solved. + +```typescript +import { X402PolicyError } from 'nirium'; + +agent.initX402({ + signer: walletSigner, + network: 'stellar:pubnet', + policy: { + evaluate: async (ctx) => { + const ok = BigInt(ctx.authorization.amount) <= myLimit; + return ok + ? { decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: 'v7', expiresAt: Date.now() + 2_000 } + : { decision: 'DENY', reason: 'over limit' }; + }, + // Optional: the policy version in force right now. + currentVersion: async () => myPolicyStore.version(), + }, }); -const response = await agent.mppFetch('https://nirium-agent.fly.dev/api/v1/mpp/signals'); -const data = await response.json(); +try { + await agent.x402Fetch(url); +} catch (e) { + if (e instanceof X402PolicyError) console.log(e.outcome); // DENY, WAIT, TIMEOUT, STALE, ... +} ``` +Only an `ALLOW` that echoes this authorization's `contextHash`, and is still valid, reaches the signer. Everything else signs nothing: `DENY`, `WAIT`, an exception, no answer before the deadline (`timeoutMs`, default 5 s, never more than half the payment's `maxTimeoutSeconds`), a malformed answer, an `ALLOW` past its `expiresAt`, a signer whose address changed while the policy was being asked, or a clock (`now`) that does not return a finite number (`CLOCK_INVALID`). Without `policy`, `x402Fetch()` behaves exactly as before. + +**Stale ALLOW.** With `currentVersion` set, it is read after the `ALLOW` and right before signing, and the `ALLOW` signs only if its `policyVersion` matches. A rejected, empty or late read signs nothing. This check is **not atomic with signing**: the version can change after it is read, or while the signer runs. It narrows the window to the synchronous step between the read and the signer call; it does not close it. `expiresAt` is checked again after the read. + +**Receipts (`onDecision`).** A payment whose `ALLOW` is accepted produces an `ALLOWED` record. `ALLOWED` does **not** mean every check has passed: it is a notice, sent before the policy-version read and the final clock and identity checks. What follows it is either a refusal from those checks (`STALE`, `EXPIRED`, `TIMEOUT`, `CLOCK_INVALID`, `SIGNER_CHANGED`, ...) or the signer's result: `SIGNED` (the signer returned a signature) or `SIGNER_ERROR` (it rejected, threw, or returned no `signedAuthEntry`; its own error is rethrown untouched). `SIGNED` therefore means a signature exists. The observer cannot change the outcome. A synchronous throw from it is swallowed. If it returns a promise (or any thenable), that is not awaited, so it cannot delay a payment, and its rejection is swallowed so it never becomes an unhandled rejection. It runs before the final clock and identity checks, so what it changes there is detected. + +**Rejected before the policy is consulted.** An authorization that is not a single `transfer(from, to, amount)` matching the selected payment requirements (asset, destination, amount, network, no sub-invocations, `from` equal to the signer) is refused without calling `evaluate`. A CAP-71 preimage must be bound to the signer's own address. + +**Signature check.** The hook only checks that the signer returned a non-empty `signedAuthEntry`; it does not verify the signature itself. That check comes from `@stellar/stellar-sdk`: `authorizeEntry` verifies the signature against sha256 of the preimage before it enters the transaction (verified in 16.3.0, the version this package requires). A signature over different bytes, or by a different key, never reaches the merchant; a test in this package pins that. + +**What this does not do.** It is a check on the agent side, not account-level enforcement: code in the same process that holds the raw signer can still call it directly (the L02 limit in #96; so can code that replaces its `signAuthEntry`, which is looked up at call time), and nothing on-chain enforces the policy. It does not solve pending-capacity reservation (L01 in #96: concurrent requests that observe the same remaining capacity): two calls evaluated at the same time can each fit a limit that together they exceed, and an aggregate cap has to be held by your policy. Discussed in [#96](https://github.com/nirium-protocol/nirium/issues/96), where @CodeDeityX laid out the agent-side cases this hook is built against. + +### MPP Charge (experimental) + +```typescript +agent.initMpp({ secretKey: 'S...', mode: 'pull' }); // 'pull' (default) or 'push' + +const response = await agent.mppFetch('https://your-mpp-server.example/resource'); +``` + +`initMpp()` builds an MPP Charge client: the server answers `402` with a challenge, the client signs a USDC transfer on Stellar, and the server verifies and settles it. The network comes from the server's challenge, so `network` in the config is accepted but unused. + +**Do not use it in production, and do not point it at Nirium's hosted endpoints yet.** + +- The client works against a compliant MPP Charge server. We checked it against Nirium's own MPP middleware running locally against testnet, in `pull` and in `push` mode. +- When we tested on 1 October 2026, Nirium's hosted **testnet** endpoint (`/api/v1/mpp/*`) rejected MPP payments with `402 Verification Failed`, in `pull` and in `push` mode. We have **not tested the mainnet endpoint** (it would spend real USDC), so we cannot tell you it works there. We have not found the cause yet. +- In `push` mode the payment settles on-chain before the server verifies it, so a request the server rejects is **not refunded**. +- Until this is diagnosed, use x402 (`initX402()`) for paid endpoints. The `get_mpp_*` tools of the MCP server have the same limitation. + ### x402Serve() — Charging Your Own API `initX402()` above lets you *pay* for someone else's API. This is the other side: charging for yours. @@ -135,7 +201,7 @@ Runs on **your own server**, not Nirium's — `x402Serve()` is a client-side fun | **Protected** (API key) | `execute`, `market`, `loop/start\|stop\|scan`, `subscriptions`, `skills/install`, `webhooks` | | **WebSocket** (JWT) | `/ws/signals` — real-time signal stream | | **x402 Premium** | `/api/v1/premium/signals` ($0.02 USDC), `/api/v1/premium/market` ($0.05 USDC) | -| **MPP** | `/api/v1/mpp/signals`, `/api/v1/mpp/market` | +| **MPP Charge** | `/api/v1/mpp/signals`, `/api/v1/mpp/market` (testnet endpoint rejected MPP payments when tested, mainnet untested, see [MPP Charge](#mpp-charge-experimental)) | ### x402 Metrics — Observability Wrapper @@ -272,7 +338,7 @@ Anchor a **hash** rather than the data itself: IPFS content cannot be deleted, s ## Requirements -- Node.js >= 18 +- Node.js >= 22.12.0. `@stellar/stellar-sdk` 16 and `@x402/stellar` 2.28 declare `>=22.0.0`, but stellar-sdk 16 depends on `@noble/hashes` 2.x, which is ESM-only, and this package is published as CommonJS: `require('nirium')` only works without a flag from Node 22.12.0 (on 22.11 it throws `ERR_REQUIRE_ESM`) - TypeScript >= 5.0 ## Links diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 9181af7..d053641 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -1,18 +1,18 @@ { "name": "nirium", - "version": "0.14.1", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nirium", - "version": "0.14.1", + "version": "0.16.0", "license": "Apache-2.0", "dependencies": { "@stellar/mpp": "^0.7.1", - "@stellar/stellar-sdk": "^14.5.0", - "@x402/fetch": "^2.17.0", - "@x402/stellar": "^2.17.0", + "@stellar/stellar-sdk": "^16.3.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", "mppx": "^0.6.31", "viem": "^2.55.19", "ws": "^8.21.3" @@ -21,10 +21,13 @@ "@types/jest": "^30.0.0", "@types/node": "^26.2.0", "@types/ws": "^8.18.1", - "@x402/core": "^2.23.0", + "@x402/core": "^2.28.0", "jest": "^30.4.2", "ts-jest": "^29.4.12", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22.12.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -1184,10 +1187,14 @@ } }, "node_modules/@stellar/js-xdr": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", - "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "license": "Apache-2.0" + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", + "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0", + "pnpm": ">=9.0.0" + } }, "node_modules/@stellar/mpp": { "version": "0.7.1", @@ -1205,45 +1212,42 @@ "mppx": "^0.6.29" } }, - "node_modules/@stellar/stellar-base": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-base/-/stellar-base-14.1.0.tgz", - "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", - "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", + "node_modules/@stellar/stellar-sdk": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.3.0.tgz", + "integrity": "sha512-pnB1t4bdnkN8M7tRIcLXnNboq+Hof62NzPMnQrl5MWBNC/d1vUbdrzXgy7yw8I+0hbfz5YN64uVb84BqzIpYiQ==", "license": "Apache-2.0", "dependencies": { - "@noble/curves": "^1.9.6", - "@stellar/js-xdr": "^3.1.2", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.2.0", + "@stellar/js-xdr": "4.0.0", + "axios": "1.18.0", "base32.js": "^0.1.0", - "bignumber.js": "^9.3.1", + "bignumber.js": "^11.1.4", "buffer": "^6.0.3", - "sha.js": "^2.4.12" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@stellar/stellar-sdk": { - "version": "14.6.1", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-14.6.1.tgz", - "integrity": "sha512-A1rQWDLdUasXkMXnYSuhgep+3ZZzyuXJKdt5/KAIc0gkmSp906HTvUpbT4pu+bVr41tu0+J4Ugz9J4BQAGGytg==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-base": "^14.1.0", - "axios": "^1.13.3", - "bignumber.js": "^9.3.1", - "commander": "^14.0.2", - "eventsource": "^2.0.2", + "commander": "^14.0.3", + "eventsource": "^4.1.0", "feaxios": "^0.0.23", - "randombytes": "^2.1.0", - "toml": "^3.0.0", - "urijs": "^1.19.1" + "smol-toml": "^1.6.1", + "uint8array-extras": "^1.5.0" }, "bin": { "stellar-js": "bin/stellar-js" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.0.0" + } + }, + "node_modules/@stellar/stellar-sdk/node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" } }, "node_modules/@toon-format/toon": { @@ -1711,9 +1715,9 @@ ] }, "node_modules/@x402/core": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.23.0.tgz", - "integrity": "sha512-EFeV0nXbTPPe5FXaD6y9vMgqc+k/ujLXCrUPrQXkmHjHyjC3Ir5tTL1e2FPSkx2+GUGzfpt1wVZb36639ygkWw==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.28.0.tgz", + "integrity": "sha512-Da/7zKxCKqpBoB4Msc9tFgkAv7tJTD8nG+DSSm+QdXAyJS4PQsmgVSKe17rDJTtlu0pwNbLcOje6MyQ6kaDkig==", "license": "Apache-2.0", "dependencies": { "zod": "^3.24.2" @@ -1729,105 +1733,27 @@ } }, "node_modules/@x402/fetch": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.23.0.tgz", - "integrity": "sha512-iyUfmnX6eAQa+GxyhC2M/vDP49EuoB/KHCXeOAFJgfL9lhtsyomLfYAMZlF+0xYHgpk/G96QZSp8iyhVlems/A==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.28.0.tgz", + "integrity": "sha512-kF5IjZIz/izg2TD/Zav+SVj0jtC0zBDfE7WcKCnMZWIsZcLCyL2GgvwXCn7CCSsNjpQ/z3Rz4OqTCA4AaeKySQ==", "license": "Apache-2.0", "dependencies": { - "@x402/core": "~2.23.0" + "@x402/core": "~2.28.0" } }, "node_modules/@x402/stellar": { - "version": "2.23.0", - "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.23.0.tgz", - "integrity": "sha512-WMhiTN3k0O2lO4T7g0DcUVF9Sh/YIzY4NMNFqyiRRN2bP3zEZZUmJBx4haH1H6HjgKON6V5ALeBwRDq6E+JLOw==", - "license": "Apache-2.0", - "dependencies": { - "@stellar/stellar-sdk": "^16.0.1", - "@x402/core": "~2.23.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@x402/stellar/node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@x402/stellar/node_modules/@stellar/js-xdr": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-4.0.0.tgz", - "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==", - "license": "Apache-2.0", - "engines": { - "node": ">=20.0.0", - "pnpm": ">=9.0.0" - } - }, - "node_modules/@x402/stellar/node_modules/@stellar/stellar-sdk": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-16.2.0.tgz", - "integrity": "sha512-FV/Rm11QvrFzR5X9fIfb6Pg30KyYyrRkNezELGFmYDAYrzoPTjqo7vklnEPd2HEontzjJmZizWk8CjyIh5vp0w==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/@x402/stellar/-/stellar-2.28.0.tgz", + "integrity": "sha512-H/P46YVVEvJmnXlss8fJBNGgjaLkp3pWJ6PH2uOJY+jVemugUoQPArwtQKNovEV2BnF0gnJ+sdaRg+4Gucu6jQ==", "license": "Apache-2.0", "dependencies": { - "@noble/ed25519": "^3.1.0", - "@noble/hashes": "^2.2.0", - "@stellar/js-xdr": "4.0.0", - "axios": "1.18.0", - "base32.js": "^0.1.0", - "bignumber.js": "^11.1.4", - "buffer": "^6.0.3", - "commander": "^14.0.3", - "eventsource": "^4.1.0", - "feaxios": "^0.0.23", - "smol-toml": "^1.6.1", - "uint8array-extras": "^1.5.0" - }, - "bin": { - "stellar-js": "bin/stellar-js" + "@stellar/stellar-sdk": "^16.3.0", + "@x402/core": "~2.28.0" }, "engines": { "node": ">=22.0.0" } }, - "node_modules/@x402/stellar/node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/@x402/stellar/node_modules/bignumber.js": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", - "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", - "license": "MIT" - }, - "node_modules/@x402/stellar/node_modules/eventsource": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", - "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/abitype": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.3.0.tgz", @@ -1949,29 +1875,14 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", + "form-data": "^4.0.5", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } @@ -2125,13 +2036,10 @@ } }, "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-11.1.5.tgz", + "integrity": "sha512-6WmzCNtUnfKpbozq+hOgWaZMMzORmYBwF1xZScyoIX3QRYWeKTtxxwDOW5tIz7C9BdjkIYHGTcelCLkXg0mndw==", + "license": "MIT" }, "node_modules/brace-expansion": { "version": "2.1.4", @@ -2231,24 +2139,6 @@ "dev": true, "license": "MIT" }, - "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2262,22 +2152,6 @@ "node": ">= 0.4" } }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2577,23 +2451,6 @@ "node": ">=0.10.0" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2757,12 +2614,15 @@ "license": "MIT" }, "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/eventsource-parser": { @@ -2893,21 +2753,6 @@ } } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3125,18 +2970,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3294,6 +3127,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/is-arrayish": { @@ -3303,18 +3137,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -3360,27 +3182,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -4687,15 +4488,6 @@ "node": ">=8" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/pretty-format": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", @@ -4751,15 +4543,6 @@ ], "license": "MIT" }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react-is-18": { "name": "react-is", "version": "18.3.1", @@ -4809,26 +4592,6 @@ "node": ">=8" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -4839,43 +4602,6 @@ "semver": "bin/semver.js" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/sha.js": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "license": "(MIT AND BSD-3-Clause)", - "dependencies": { - "inherits": "^2.0.4", - "safe-buffer": "^5.2.1", - "to-buffer": "^1.2.0" - }, - "bin": { - "sha.js": "bin.js" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5246,32 +4972,12 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/to-buffer": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "license": "MIT", - "dependencies": { - "isarray": "^2.0.5", - "safe-buffer": "^5.2.1", - "typed-array-buffer": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/tokenx": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/tokenx/-/tokenx-1.6.0.tgz", "integrity": "sha512-CKTjk345ajvBAUp5xUI9a5KKN0zU0lBueVHQbCskH1Hp6WkUKsPW2qGCYNs0pxNyfzxfo+IIjdt2W4sMbw/qBw==", "license": "MIT" }, - "node_modules/toml": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "license": "MIT" - }, "node_modules/ts-jest": { "version": "29.4.12", "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.12.tgz", @@ -5382,20 +5088,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -5512,12 +5204,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -5676,27 +5362,6 @@ "node": ">= 8" } }, - "node_modules/which-typed-array": { - "version": "1.1.22", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", - "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.9", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 39d0d66..7119a01 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "nirium", - "version": "0.15.0", - "description": "Autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP)", + "version": "0.16.0", + "description": "Autonomous treasury and agentic-payments infrastructure for Stellar (x402)", "main": "dist/index.js", "types": "dist/index.d.ts", "homepage": "https://nirium.xyz", @@ -22,6 +22,9 @@ "dist", "README.md" ], + "engines": { + "node": ">=22.12.0" + }, "scripts": { "build": "tsc", "prepublishOnly": "npm run build", @@ -31,9 +34,9 @@ }, "dependencies": { "@stellar/mpp": "^0.7.1", - "@stellar/stellar-sdk": "^14.5.0", - "@x402/fetch": "^2.17.0", - "@x402/stellar": "^2.17.0", + "@stellar/stellar-sdk": "^16.3.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", "mppx": "^0.6.31", "viem": "^2.55.19", "ws": "^8.21.3" @@ -42,7 +45,7 @@ "@types/jest": "^30.0.0", "@types/node": "^26.2.0", "@types/ws": "^8.18.1", - "@x402/core": "^2.23.0", + "@x402/core": "^2.28.0", "jest": "^30.4.2", "ts-jest": "^29.4.12", "typescript": "^5.9.3" diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a5a0f00..67b30c7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,8 +12,16 @@ import { x402Client as X402ClientClass, wrapFetchWithPayment } from '@x402/fetch import { createEd25519Signer } from '@x402/stellar'; // @ts-ignore import { ExactStellarScheme } from '@x402/stellar/exact/client'; -import * as MppxModule from 'mppx'; +// @ts-ignore: ESM subpath imports (mismo patrón que los de @x402 de arriba) +import { Mppx as MppxClient } from 'mppx/client'; +// @ts-ignore +import { stellar as mppStellar } from '@stellar/mpp/charge/client'; import { checkReplay, checkRateLimit, type X402GuardConfig } from './x402-guard'; +import { + assertValidPolicyHook, createPolicyGatedSigner, X402PolicyError, + type X402BaseSigner, type X402PolicyHook, type X402PaymentRequirementsView, +} from './x402-policy'; +import { captureRequirements, toSpendCapError, type X402OfferedRequirement } from './x402-spend-cap'; export interface AgentConfig { apiKey: string; @@ -27,6 +35,10 @@ export interface AgentConfig { * SEP-43 signer. Only `address` and `signAuthEntry` are required — that is all * x402 needs, and it is what browser wallets expose (Freighter, Stellar Wallets * Kit, Pollar). Lets the SDK pay from a browser without ever holding a secret. + * + * Note: what `signAuthEntry` receives is the base64 `HashIdPreimage` of the + * Soroban authorization (the bytes whose sha256 gets signed), not the + * `SorobanAuthorizationEntry` itself. * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0043.md */ export interface X402Signer { @@ -54,6 +66,13 @@ export interface X402Config { network?: string; /** Soroban RPC endpoint override (defaults per network) */ rpcUrl?: string; + /** + * Pre-sign policy hook (#96). Runs after the Stellar authorization is + * built and before anything is signed; only an ALLOW bound to that exact + * authorization, and still valid, reaches the signer. Without it, + * x402Fetch behaves exactly as before. See X402PolicyHook. + */ + policy?: X402PolicyHook; } export interface MppConfig { @@ -568,7 +587,8 @@ export class Agent { private token: string | null = null; - private x402Client: { fetch: typeof fetch } | null = null; + private x402Client: { client: any; scheme: any; baseFetch: typeof fetch } | null = null; + private x402Policy: { base: X402BaseSigner; rpcUrl: string; policy: X402PolicyHook } | null = null; private mppClient: { fetch: typeof fetch } | null = null; constructor(config: AgentConfig) { @@ -1157,6 +1177,7 @@ export class Agent { 'initX402 got both `secretKey` and `signer` — pass only one, so it is unambiguous which key signs.', ); } + if (config.policy !== undefined) assertValidPolicyHook(config.policy); const signer = config.signer ?? (createEd25519Signer as any)(config.secretKey, network); // Pubnet: el SDF NO corre RPC público de mainnet — soroban.stellar.org no // existe. Default al RPC público de gateway.fm (mismo default que @@ -1164,23 +1185,73 @@ export class Agent { const rpcUrl = config.rpcUrl || (network.includes('testnet') ? 'https://soroban-testnet.stellar.org' : 'https://soroban-rpc.mainnet.stellar.gateway.fm'); - const client = new (X402ClientClass as any)().register( - 'stellar:*', - new (ExactStellarScheme as any)(signer, { url: rpcUrl }) - ); - this.x402Client = { fetch: wrapFetchWithPayment(fetch, client) } as any; + if (config.policy) { + // Con policy el cliente se arma en cada x402Fetch: el signer de + // stellar-sdk no recibe la URL, y un closure por llamada ata URL, + // método y requisitos a ese pago sin estado compartido. + this.x402Policy = { base: signer, rpcUrl, policy: config.policy }; + this.x402Client = null; + return; + } + this.x402Policy = null; + const scheme = new (ExactStellarScheme as any)(signer, { url: rpcUrl }); + const client = new (X402ClientClass as any)().register('stellar:*', scheme); + // El cliente es uno solo; el fetch envuelto se arma en cada llamada + // (un closure barato) para que lo capturado de cada 402 sea de esa llamada. + this.x402Client = { client, scheme, baseFetch: fetch }; } /** * Fetch a paid resource via x402 protocol. * The client automatically handles 402 negotiation, auth-entry signing, and payment. * Returns the Response object — call .json() or .text() for the payload. + * + * Throws `X402SpendCapError` when @x402/core refuses a payment for being above + * its per-payment cap (before anything is signed), and `X402PolicyError` when + * a `policy` refuses it. */ async x402Fetch(url: string, init?: RequestInit): Promise { + if (this.x402Policy) return this.x402FetchWithPolicy(this.x402Policy, url, init); if (!this.x402Client) { throw new Error('x402 client not initialized. Call agent.initX402() first.'); } - return this.x402Client.fetch(url, init); + const { client, scheme, baseFetch } = this.x402Client; + const sink: { offered?: X402OfferedRequirement[] } = {}; + try { + return await wrapFetchWithPayment(captureRequirements(baseFetch, sink), client)(url, init); + } catch (e) { + throw toSpendCapError(e, url, sink.offered, scheme.findDefaultAsset?.bind(scheme)) ?? e; + } + } + + private async x402FetchWithPolicy( + cfg: { base: X402BaseSigner; rpcUrl: string; policy: X402PolicyHook }, + url: string, init?: RequestInit, + ): Promise { + const slot: { requirements?: X402PaymentRequirementsView; error?: X402PolicyError } = {}; + const gated = createPolicyGatedSigner( + cfg.base, cfg.policy, + () => ({ url, method: (init?.method ?? 'GET').toUpperCase(), requirements: slot.requirements }), + (err) => { slot.error = err; }, + ); + const scheme = new (ExactStellarScheme as any)(gated, { url: cfg.rpcUrl }); + const sink: { offered?: X402OfferedRequirement[] } = {}; + const client = new (X402ClientClass as any)() + .register('stellar:*', scheme) + .onBeforePaymentCreation(async ({ selectedRequirements: r }: any) => { + slot.requirements = { + scheme: r.scheme, network: r.network, asset: r.asset, + payTo: r.payTo, amount: r.amount, maxTimeoutSeconds: r.maxTimeoutSeconds, + }; + }); + // NUNCA registrar onPaymentCreationFailure aquí: puede devolver un + // payload sin pasar por el signer, y el gate quedaría puenteado. + try { + return await wrapFetchWithPayment(captureRequirements(globalThis.fetch, sink), client)(url, init); + } catch (e) { + // @x402/fetch reenvuelve el error y pierde la clase. + throw slot.error ?? toSpendCapError(e, url, sink.offered, scheme.findDefaultAsset?.bind(scheme)) ?? e; + } } // ─── MPP Protocol (Charge Mode) ──────────────────────────── @@ -1188,6 +1259,12 @@ export class Agent { /** * Initialize the MPP Charge client for per-request Soroban SAC payments. * Uses canonical @stellar/mpp charge mode with mppx. + * + * EXPERIMENTAL. The client works against a compliant MPP Charge server, but + * Nirium's hosted testnet `/api/v1/mpp/*` endpoint rejected MPP payments when + * tested on 2026-10-01 (`402 Verification Failed`), and in `push` mode the + * payment has already settled on-chain when that happens. The mainnet + * endpoint was not tested. Use `initX402()` for paid endpoints. See the README. * In pull mode, the server assembles and broadcasts the transaction. * * @example @@ -1197,17 +1274,14 @@ export class Agent { * ``` */ initMpp(config: MppConfig): void { - const Mppx = (MppxModule as any).default || MppxModule; - const mppx = Mppx.create({ - stellar: { - charge: { - secretKey: config.secretKey, - network: config.network || 'stellar:testnet', - mode: config.mode || 'pull', - }, - }, + // La red la dicta el reto 402 del servidor (methodDetails.network), no el cliente: + // `config.network` se acepta por compatibilidad pero ya no se usa. + // polyfill:false es obligatorio: sin él mppx reemplaza globalThis.fetch y + // pasaría a interceptar también los 402 de x402Fetch. + this.mppClient = MppxClient.create({ + methods: [mppStellar.charge({ secretKey: config.secretKey, mode: config.mode || 'pull' })], + polyfill: false, }); - this.mppClient = mppx; } /** @@ -1536,6 +1610,12 @@ export function x402Serve(config: X402ServeConfig): any { // `.js` extension required even though the source is `.ts`: `module: // ESNext` emits these specifiers verbatim, and Node's native ESM resolver // (unlike a bundler) needs the real extension to find the compiled file. +export { X402PolicyError } from './x402-policy'; +export { X402SpendCapError } from './x402-spend-cap'; +export type { + X402PolicyHook, X402PolicyContext, X402PolicyDecision, X402PolicyVerdict, + X402PolicyOutcome, X402PolicyRecord, X402PaymentRequirementsView, +} from './x402-policy'; export { x402Metrics } from './metrics'; export type { X402MetricsResult, MetricsSnapshot } from './metrics'; export type { X402GuardStore, X402GuardConfig, GuardRequest, GuardDenied } from './x402-guard'; diff --git a/packages/sdk/src/x402-policy.ts b/packages/sdk/src/x402-policy.ts new file mode 100644 index 0000000..7f575cd --- /dev/null +++ b/packages/sdk/src/x402-policy.ts @@ -0,0 +1,463 @@ +// ═══════════════════════════════════════════════════════════════ +// Pre-sign policy hook for x402Fetch (#96). +// +// El punto de evaluación envuelve al signer SEP-43 porque es el único lugar +// donde existe la autorización que se va a firmar: ExactStellarScheme la arma +// dentro de createPaymentPayload (simulación RPC incluida) y stellar-sdk la +// entrega a signAuthEntry como HashIdPreimage en base64. El hook +// onBeforePaymentCreation de @x402/core corre ANTES de eso: ve los requisitos, +// pero todavía no hay nonce, expiración ni invocación que atar. +// ═══════════════════════════════════════════════════════════════ + +import { xdr, hash, Networks, Address, scValToNative } from '@stellar/stellar-sdk'; + +export type X402PolicyVerdict = 'ALLOW' | 'DENY' | 'WAIT'; + +export interface X402PaymentRequirementsView { + scheme: string; + network: string; + asset: string; + payTo: string; + amount: string; + maxTimeoutSeconds: number; +} + +export interface X402PolicyContext { + /** Version of this object's shape, so an engine knows what it is reading. */ + contextVersion: 1; + /** URL and method exactly as passed to x402Fetch. */ + url: string; + method: string; + /** The requirements the server asked for and x402 selected. */ + requirements: Readonly; + /** What is actually about to be signed, decoded from the bytes. */ + authorization: Readonly<{ + preimageXdr: string; + /** 'legacy' (ENVELOPE_TYPE_SOROBAN_AUTHORIZATION) or 'cap71' (…WithAddress). */ + preimageType: 'legacy' | 'cap71'; + /** sha256(preimage) in hex: exactly the payload ed25519 signs. */ + payloadHash: string; + networkPassphrase: string; + nonce: string; + signatureExpirationLedger: number; + contractId: string; + functionName: string; + from: string; + to: string; + amount: string; + }>; + signer: string; + /** sha256 of {url, method, payloadHash}. An ALLOW must echo it back unchanged. */ + contextHash: string; +} + +export interface X402PolicyDecision { + decision: X402PolicyVerdict; + /** Required on ALLOW: binds the decision to this authorization and no other. */ + contextHash?: string; + reason?: string; + /** + * Opaque to Nirium. Preserved in errors and onDecision. Required on ALLOW + * when `currentVersion` is configured: it is what gets compared. + */ + policyVersion?: string; + /** Epoch ms. If it has passed by signing time, nothing is signed. Equality counts as expired. */ + expiresAt?: number; + decisionId?: string; +} + +export type X402PolicyOutcome = + | 'ALLOWED' | 'SIGNED' | 'SIGNER_ERROR' | 'DENY' | 'WAIT' | 'TIMEOUT' | 'ENGINE_ERROR' + | 'EXPIRED' | 'UNBOUND' | 'MALFORMED' | 'CONTEXT_MISMATCH' + | 'STALE' | 'VERSION_UNAVAILABLE' | 'SIGNER_CHANGED' | 'CLOCK_INVALID'; + +/** + * `ALLOWED`: the engine's ALLOW is well formed, bound to this authorization + * and not expired yet. It is a notice, not a result: the observer runs here, + * and the freshness and identity checks that follow it can still refuse + * (STALE, EXPIRED, TIMEOUT, CLOCK_INVALID, VERSION_UNAVAILABLE, SIGNER_CHANGED). + * `SIGNED`: the signer returned a signature. `SIGNER_ERROR`: the signer + * rejected, threw, or returned no `signedAuthEntry`. Every other value is a + * refusal and nothing was signed. + * Sequence: `ALLOWED`, then either a refusal, or exactly one of `SIGNED` or + * `SIGNER_ERROR`. Refusals that happen before the ALLOW is accepted (DENY, WAIT, + * UNBOUND, MALFORMED, TIMEOUT or ENGINE_ERROR while evaluating, CONTEXT_MISMATCH) + * are a single record with no `ALLOWED`. + */ +export interface X402PolicyRecord { + outcome: X402PolicyOutcome; + context?: X402PolicyContext; + decision?: X402PolicyDecision; + error?: string; +} + +/** + * Pre-sign policy hook (#96). Runs after the Stellar authorization is built + * and before anything is signed. Only an ALLOW bound to this exact + * authorization, and still valid, reaches the signer. Anything else (DENY, + * WAIT, error, timeout, malformed or stale answer) signs nothing. Nirium never + * stores or owns the policy; it asks and obeys. + * + * Authorizations that are not a single `transfer(from, to, amount)` matching + * the selected requirements are rejected before the policy is consulted. + */ +export interface X402PolicyHook { + evaluate: (ctx: X402PolicyContext, opts: { signal: AbortSignal }) => Promise; + /** + * Optional source of the policy version currently in force. When set, it is + * read after an ALLOW and right before signing; the ALLOW signs only if its + * `policyVersion` equals what this returns. A rejection, an empty answer or + * running past the deadline signs nothing. + * + * This check is NOT atomic with signing: the version can change after it is + * read, or while the signer runs. It narrows the stale-ALLOW window to the + * synchronous step between this read and the signer call; it does not close it. + * The `onDecision` observer runs before this read, so a version change it + * causes is seen by it. + */ + currentVersion?: (ctx: X402PolicyContext, opts: { signal: AbortSignal }) => Promise; + /** + * Hard deadline for the whole pre-sign phase (evaluate + currentVersion). + * Default 5000 ms, and never more than half the payment's maxTimeoutSeconds. + */ + timeoutMs?: number; + /** If true, an ALLOW without expiresAt is treated as unsignable. Default false. */ + requireExpiry?: boolean; + /** + * Observer for receipts (see X402PolicyRecord for the sequence). Cannot + * change the outcome. A synchronous throw is swallowed. If it returns a + * promise (or any thenable) it is not awaited, so it cannot delay or block a + * payment, and its rejection is swallowed so it never becomes an + * unhandledRejection. + */ + onDecision?: (record: X402PolicyRecord) => void; + /** + * Injectable clock (epoch ms), for tests. It must return a finite number + * every time it is read: anything else (NaN, undefined, a string, a throw) + * refuses the payment with CLOCK_INVALID, because every comparison against + * NaN is false and a deadline that can never be reached would sign. + */ + now?: () => number; +} + +/** Outcomes that mean no signature was released (the payment is refused). */ +export type X402PolicyRefusal = Exclude; + +export class X402PolicyError extends Error { + readonly outcome: X402PolicyRefusal; + readonly decision?: X402PolicyDecision; + readonly contextHash?: string; + constructor( + outcome: X402PolicyRefusal, + message: string, + extra: { decision?: X402PolicyDecision; contextHash?: string } = {}, + ) { + super(`x402 policy ${outcome}: ${message}`); + this.name = 'X402PolicyError'; + this.outcome = outcome; + this.decision = extra.decision; + this.contextHash = extra.contextHash; + } +} + +const PASSPHRASES: Record = { + 'stellar:testnet': Networks.TESTNET, + 'stellar:pubnet': Networks.PUBLIC, +}; + +const DEFAULT_TIMEOUT_MS = 5000; + +const hex = (b: Buffer | Uint8Array) => Buffer.from(b).toString('hex'); +const sha256hex = (s: string) => hex(hash(Buffer.from(s, 'utf8'))); +const nonempty = (v: unknown): v is string => typeof v === 'string' && v.trim().length > 0; + +type SignOpts = { networkPassphrase?: string; address?: string }; +type SignFn = (authEntry: string, opts?: SignOpts) => + Promise<{ signedAuthEntry: string; signerAddress?: string }>; +export type X402BaseSigner = { address: string; signAuthEntry: SignFn }; + +/** Validates a policy hook at initX402 time, so bad config fails before any payment. */ +export function assertValidPolicyHook(policy: X402PolicyHook): void { + if (!policy || typeof policy !== 'object' || typeof policy.evaluate !== 'function') { + throw new Error('initX402: `policy.evaluate` must be a function.'); + } + if (policy.currentVersion !== undefined && typeof policy.currentVersion !== 'function') { + throw new Error('initX402: `policy.currentVersion`, when set, must be a function.'); + } + if (policy.timeoutMs !== undefined + && (!Number.isSafeInteger(policy.timeoutMs) || policy.timeoutMs <= 0)) { + throw new Error('initX402: `policy.timeoutMs` must be a positive integer (ms).'); + } + if (policy.now !== undefined && typeof policy.now !== 'function') { + throw new Error('initX402: `policy.now`, when set, must be a function returning epoch milliseconds.'); + } +} + +/** + * Decodes the preimage and checks it describes exactly the payment x402 + * selected. This is not policy (that belongs to the engine): it is context + * integrity, so that what the engine evaluates is what gets signed. + */ +export function describeAuthorization( + preimageXdr: string, + requirements: X402PaymentRequirementsView, + signerAddress: string, +): X402PolicyContext['authorization'] { + let pre: any; + try { + pre = xdr.HashIdPreimage.fromXDR(preimageXdr, 'base64'); + } catch (e: any) { + throw new X402PolicyError('CONTEXT_MISMATCH', `undecodable auth preimage (${e?.message})`); + } + // Dos variantes: la clásica, y la de CAP-71 que además ata la dirección + // dentro de lo firmado. En la segunda, esa dirección tiene que ser el signer. + let auth: any; + let preimageType: 'legacy' | 'cap71'; + switch (pre.switch().name) { + case 'envelopeTypeSorobanAuthorization': + auth = pre.sorobanAuthorization(); + preimageType = 'legacy'; + break; + case 'envelopeTypeSorobanAuthorizationWithAddress': + auth = pre.sorobanAuthorizationWithAddress(); + preimageType = 'cap71'; + if (Address.fromScAddress(auth.address()).toString() !== signerAddress) { + throw new X402PolicyError('CONTEXT_MISMATCH', 'CAP-71 preimage is bound to a different address than the signer'); + } + break; + default: + throw new X402PolicyError('CONTEXT_MISMATCH', `unsupported preimage type ${pre.switch().name}`); + } + const passphrase = PASSPHRASES[requirements.network]; + if (!passphrase) throw new X402PolicyError('CONTEXT_MISMATCH', `unknown network ${requirements.network}`); + if (hex(auth.networkId()) !== hex(hash(Buffer.from(passphrase)))) { + throw new X402PolicyError('CONTEXT_MISMATCH', 'auth entry is for a different network than requirements.network'); + } + const inv = auth.invocation(); + if (inv.subInvocations().length !== 0) { + throw new X402PolicyError('CONTEXT_MISMATCH', 'auth entry carries sub-invocations'); + } + const fn = inv.function(); + if (fn.switch().name !== 'sorobanAuthorizedFunctionTypeContractFn') { + throw new X402PolicyError('CONTEXT_MISMATCH', `unexpected authorized function ${fn.switch().name}`); + } + const call = fn.contractFn(); + const contractId = Address.fromScAddress(call.contractAddress()).toString(); + const functionName = call.functionName().toString(); + const rawArgs = call.args(); + if (functionName !== 'transfer' || rawArgs.length !== 3) { + throw new X402PolicyError('CONTEXT_MISMATCH', `expected transfer(from,to,amount), got ${functionName}/${rawArgs.length}`); + } + if (rawArgs[0].switch().name !== 'scvAddress' || rawArgs[1].switch().name !== 'scvAddress' + || rawArgs[2].switch().name !== 'scvI128') { + throw new X402PolicyError('CONTEXT_MISMATCH', 'transfer arguments have unexpected types'); + } + const from = Address.fromScAddress(rawArgs[0].address()).toString(); + const to = Address.fromScAddress(rawArgs[1].address()).toString(); + const amount = BigInt(scValToNative(rawArgs[2])).toString(); + if (contractId !== requirements.asset) throw new X402PolicyError('CONTEXT_MISMATCH', 'asset contract differs from requirements.asset'); + if (to !== requirements.payTo) throw new X402PolicyError('CONTEXT_MISMATCH', 'destination differs from requirements.payTo'); + if (amount !== requirements.amount) throw new X402PolicyError('CONTEXT_MISMATCH', 'amount differs from requirements.amount'); + if (from !== signerAddress) throw new X402PolicyError('CONTEXT_MISMATCH', 'from differs from signer address'); + return Object.freeze({ + preimageXdr, + preimageType, + payloadHash: hex(hash(pre.toXDR())), + networkPassphrase: passphrase, + nonce: auth.nonce().toString(), + signatureExpirationLedger: auth.signatureExpirationLedger(), + contractId, functionName, from, to, amount, + }); +} + +/** min(timeoutMs ?? 5000, maxTimeoutSeconds * 1000 / 2): a slow engine must not eat the payment window. */ +export function effectiveTimeoutMs(policy: X402PolicyHook, maxTimeoutSeconds?: number): number { + const base = policy.timeoutMs ?? DEFAULT_TIMEOUT_MS; + if (typeof maxTimeoutSeconds === 'number' && Number.isFinite(maxTimeoutSeconds) && maxTimeoutSeconds > 0) { + return Math.max(1, Math.min(base, Math.floor((maxTimeoutSeconds * 1000) / 2))); + } + return base; +} + +/** + * Wraps a signer so it only signs with a current ALLOW bound to this auth + * entry. Anything else ends without a signature. + * + * `getCall` supplies the per-call context (URL, method, requirements): one + * gated signer is created per x402Fetch, so two concurrent payments never + * share state. `onRefused` receives the error before it is thrown, because + * @x402/fetch re-wraps errors and loses their class. + * + * Not a security boundary against code in the same process: anyone holding + * the base signer can still call it directly. Callbacks (`evaluate`, + * `currentVersion`, `onDecision`) all run before the final clock and identity + * checks, so what they change in the clock, the version source or the base + * signer's `address` is detected. Replacing `base.signAuthEntry` itself is not: + * it is looked up at call time, after those checks. + */ +export function createPolicyGatedSigner( + base: X402BaseSigner, + policy: X402PolicyHook, + getCall: () => { url: string; method: string; requirements?: X402PaymentRequirementsView }, + onRefused?: (error: X402PolicyError) => void, +): X402BaseSigner { + // La identidad se fija aquí. Si el signer base cambia de dirección a mitad + // del pago, no se firma: lo evaluado se evaluó para esta cuenta. + const account = base.address; + const now = policy.now ?? Date.now; + // Un reloj que no devuelve un número finito cierra: NaN hace falsa cada + // comparación (`t >= deadline`), y un plazo que nunca se alcanza firmaría. + const readClock = (context?: X402PolicyContext, decision?: X402PolicyDecision): number => { + let t: unknown; + try { t = now(); } catch (e: any) { + return refuse('CLOCK_INVALID', `clock threw (${e?.message ?? String(e)})`, context, decision); + } + if (typeof t !== 'number' || !Number.isFinite(t)) { + return refuse('CLOCK_INVALID', `clock returned ${String(t)}, not epoch milliseconds`, context, decision); + } + return t; + }; + const report = (r: X402PolicyRecord) => { + try { + const out: unknown = policy.onDecision?.(r); + // Un observador async que rechaza no puede tumbar el proceso con un + // unhandledRejection. No se espera: el signer no depende de él. + if (out !== null && (typeof out === 'object' || typeof out === 'function')) { + Promise.resolve(out).catch(() => { /* idem */ }); + } + } catch { /* un observador no decide nada */ } + }; + const refuse = ( + outcome: X402PolicyRefusal, msg: string, + context?: X402PolicyContext, decision?: X402PolicyDecision, + ): never => { + report({ outcome, context, decision, error: msg }); + const err = new X402PolicyError(outcome, msg, { decision, contextHash: context?.contextHash }); + try { onRefused?.(err); } catch { /* idem */ } + throw err; + }; + + const signAuthEntry: SignFn = async (preimageXdr, opts) => { + const call = getCall(); + if (!call.requirements) refuse('CONTEXT_MISMATCH', 'no payment requirements bound to this signing call'); + if (base.address !== account) refuse('SIGNER_CHANGED', 'base signer address changed after the gate was created'); + if (opts?.address && opts.address !== account) refuse('CONTEXT_MISMATCH', 'asked to sign for a different address'); + + let authorization: X402PolicyContext['authorization']; + try { + authorization = describeAuthorization(preimageXdr, call.requirements!, account); + } catch (e: any) { + return refuse(e instanceof X402PolicyError ? e.outcome : 'CONTEXT_MISMATCH', e?.message ?? String(e)); + } + const contextHash = sha256hex(JSON.stringify({ v: 1, url: call.url, method: call.method, payloadHash: authorization.payloadHash })); + // Congelado: el engine lee, no reescribe lo que se firma. + const context: X402PolicyContext = Object.freeze({ + contextVersion: 1 as const, url: call.url, method: call.method, + requirements: Object.freeze({ ...call.requirements! }), + authorization, + signer: account, contextHash, + }); + + const timeoutMs = effectiveTimeoutMs(policy, call.requirements!.maxTimeoutSeconds); + const controller = new AbortController(); + // Un solo plazo para toda la fase previa a la firma (evaluate + currentVersion). + const deadline = readClock(context) + timeoutMs; + let timer: ReturnType | undefined; + const TIMED_OUT = Symbol('timeout'); + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { controller.abort(); reject(TIMED_OUT); }, timeoutMs); + }); + timeout.catch(() => { /* se consume aquí; cada race lo vuelve a observar */ }); + + try { + let decision: X402PolicyDecision; + try { + decision = await Promise.race([ + Promise.resolve().then(() => policy.evaluate(context, { signal: controller.signal })), + timeout, + ]); + } catch (e: any) { + if (e === TIMED_OUT) return refuse('TIMEOUT', `no decision within ${timeoutMs} ms`, context); + return refuse('ENGINE_ERROR', e?.message ?? String(e), context); + } + + // Todo lo que no sea un ALLOW bien formado cierra. + if (!decision || typeof decision !== 'object') return refuse('MALFORMED', 'engine returned no decision object', context); + if (decision.decision === 'DENY') return refuse('DENY', decision.reason ?? 'denied', context, decision); + if (decision.decision === 'WAIT') return refuse('WAIT', decision.reason ?? 'wait', context, decision); + if (decision.decision !== 'ALLOW') return refuse('MALFORMED', `unknown verdict ${String((decision as any).decision)}`, context, decision); + // Se copian los campos antes de cualquier await: mutar el objeto del + // engine después no extiende ni cambia esta decisión. + const allow: X402PolicyDecision = Object.freeze({ ...decision }); + if (allow.contextHash !== contextHash) return refuse('UNBOUND', 'ALLOW is not bound to this authorization', context, allow); + if (allow.expiresAt !== undefined + && (typeof allow.expiresAt !== 'number' || !Number.isFinite(allow.expiresAt))) { + return refuse('MALFORMED', 'expiresAt must be epoch milliseconds', context, allow); + } + if (allow.expiresAt === undefined && policy.requireExpiry) { + return refuse('EXPIRED', 'ALLOW without expiresAt while requireExpiry is set', context, allow); + } + const checkClock = () => { + const t = readClock(context, allow); + if (t >= deadline) refuse('TIMEOUT', 'decision arrived after the deadline', context, allow); + if (allow.expiresAt !== undefined && t >= allow.expiresAt) refuse('EXPIRED', 'ALLOW expired before signing', context, allow); + }; + if (policy.currentVersion && !nonempty(allow.policyVersion)) { + return refuse('MALFORMED', 'ALLOW has no policyVersion to check against currentVersion', context, allow); + } + checkClock(); + + // El observador corre AQUI y no junto al signer: todo lo que provoque + // (adelantar el reloj, cambiar la versión de la política, cambiar la + // dirección del signer) lo ven las comprobaciones que siguen. Lo que + // hace un callback después de la última de ellas ya no existe: entre + // la última comprobación y el signer no corre código ajeno. + report({ outcome: 'ALLOWED', context, decision: allow }); + + if (policy.currentVersion) { + let current: unknown; + try { + current = await Promise.race([ + Promise.resolve().then(() => policy.currentVersion!(context, { signal: controller.signal })), + timeout, + ]); + } catch (e: any) { + if (e === TIMED_OUT) return refuse('TIMEOUT', `no current policy version within ${timeoutMs} ms`, context, allow); + return refuse('VERSION_UNAVAILABLE', `currentVersion failed (${e?.message ?? String(e)})`, context, allow); + } + if (!nonempty(current)) return refuse('VERSION_UNAVAILABLE', 'currentVersion returned no version', context, allow); + if (current !== allow.policyVersion) { + return refuse('STALE', `ALLOW was issued under policy ${allow.policyVersion}, current is ${current}`, context, allow); + } + } + + // Comprobaciones finales, siempre, y sin ningún await antes del signer: + // el reloj se vuelve a leer (después del observador y del await de la + // versión) y la identidad se compara por última vez. + checkClock(); + if (base.address !== account) return refuse('SIGNER_CHANGED', 'base signer address changed while the policy was evaluated', context, allow); + // La fase previa terminó: la firma no tiene plazo propio, así que el + // temporizador ya no pinta nada. + if (timer) clearTimeout(timer); + // SIGNED solo existe si el signer devolvió una firma. Los bytes son + // los del parámetro original, no una copia que alguien pudo tocar. + let signed: Awaited>; + try { + signed = await base.signAuthEntry(preimageXdr, opts); + } catch (e: any) { + // El error del signer se relanza tal cual: no es una negativa de la política. + report({ outcome: 'SIGNER_ERROR', context, decision: allow, error: e?.message ?? String(e) }); + throw e; + } + if (!signed || typeof signed !== 'object' || !nonempty(signed.signedAuthEntry)) { + return refuse('SIGNER_ERROR', 'signer returned no signedAuthEntry', context, allow); + } + report({ outcome: 'SIGNED', context, decision: allow }); + return signed; + } finally { + if (timer) clearTimeout(timer); + } + }; + + return { address: account, signAuthEntry }; +} diff --git a/packages/sdk/src/x402-spend-cap.ts b/packages/sdk/src/x402-spend-cap.ts new file mode 100644 index 0000000..57a3a84 --- /dev/null +++ b/packages/sdk/src/x402-spend-cap.ts @@ -0,0 +1,155 @@ +// ═══════════════════════════════════════════════════════════════ +// Error claro para el tope por pago de @x402/core. +// +// Desde @x402/core 2.23.0 el cliente rechaza, al seleccionar los requisitos +// y antes de crear el payload, todo pago por encima de un tope por defecto +// ($1). Lo hace con un Error genérico que x402Fetch re-envuelve +// ("Failed to create payment payload: All payment requirements were +// rejected by spendControls.maxAmountPerPayment (...)"), sin decir cuánto se +// pedía ni que nadie firmó nada. Este módulo reconoce ese caso y lo convierte +// en un error que lo dice. No cambia el tope ni lo expone: solo lo explica. +// ═══════════════════════════════════════════════════════════════ + +/** One payment option from the server's 402 response, as x402 v2 sends it. */ +export interface X402OfferedRequirement { + scheme?: string; + network?: string; + asset?: string; + amount?: string; +} + +export interface X402SpendCapErrorDetails { + url: string; + /** Atomic units the server asked for (the cheapest offer when there are several). */ + amount?: string; + asset?: string; + network?: string; + /** The cap as @x402/core states it, e.g. "$1". */ + cap?: string; + /** Human reading of `amount` when the asset is a known default asset, e.g. "2 USDC". */ + formattedAmount?: string; + cause?: unknown; +} + +/** + * x402Fetch refused to pay because the amount the server asked for is above + * the per-payment cap enforced by @x402/core. Thrown before the signer is + * reached: nothing was signed, nothing was sent. + */ +export class X402SpendCapError extends Error { + readonly code = 'X402_SPEND_CAP_EXCEEDED' as const; + /** Always false for this error: the cap is checked before any signing. */ + readonly signerCalled = false as const; + readonly url: string; + readonly amount?: string; + readonly asset?: string; + readonly network?: string; + readonly cap?: string; + readonly formattedAmount?: string; + + constructor(d: X402SpendCapErrorDetails) { + const asked = d.formattedAmount + ? `${d.formattedAmount} (${d.amount} atomic units)` + : d.amount !== undefined + ? `${d.amount} atomic units${d.asset ? ` of ${d.asset}` : ''}` + : 'more than the cap'; + const cap = d.cap ? `the per-payment cap is ${d.cap}` : 'it is above the per-payment cap'; + super( + `x402Fetch refused to pay ${d.url}: the server asked for ${asked} and ${cap}. ` + + 'The signer was not called; nothing was signed or sent. ' + + 'The cap is enforced by @x402/core and cannot be changed from initX402() yet.', + d.cause !== undefined ? { cause: d.cause } : undefined, + ); + this.name = 'X402SpendCapError'; + this.url = d.url; + this.amount = d.amount; + this.asset = d.asset; + this.network = d.network; + this.cap = d.cap; + this.formattedAmount = d.formattedAmount; + } +} + +const isAtomic = (v: unknown): v is string => typeof v === 'string' && /^\d+$/.test(v); + +/** `20000000` with 7 decimals and `USDC` -> `2 USDC`. */ +export function formatAtomic(amount: string, decimals: number, symbol: string): string { + const raw = BigInt(amount); + const base = 10n ** BigInt(decimals); + const whole = raw / base; + const frac = (raw % base).toString().padStart(decimals, '0').replace(/0+$/, ''); + return `${frac ? `${whole}.${frac}` : whole.toString()} ${symbol}`; +} + +/** + * Reads the `accepts` list out of a 402 response's PAYMENT-REQUIRED header + * (base64 JSON, x402 v2). Returns undefined if it is absent or unreadable; + * the error is still produced, just without the amount. + */ +export function readOfferedRequirements(response: { headers: { get(name: string): string | null } }): X402OfferedRequirement[] | undefined { + try { + const header = response.headers.get('payment-required'); + if (!header) return undefined; + const bytes = Uint8Array.from(atob(header), (c) => c.charCodeAt(0)); + const accepts = JSON.parse(new TextDecoder().decode(bytes))?.accepts; + return Array.isArray(accepts) ? accepts : undefined; + } catch { + return undefined; + } +} + +/** + * Wraps a fetch so the requirements of every 402 it sees are written to + * `sink`. Passive: the response is returned untouched. + */ +export function captureRequirements( + base: typeof fetch, + sink: { offered?: X402OfferedRequirement[] }, +): typeof fetch { + return (async (input: any, init?: any) => { + const response: Response = await base(input, init); + if (response.status === 402) { + const offered = readOfferedRequirements(response); + if (offered) sink.offered = offered; + } + return response; + }) as typeof fetch; +} + +// El mensaje de @x402/core que reconocemos. Si una versión futura lo cambia, +// el error genérico vuelve a salir tal cual (sin perder nada) y el test que lo +// fija (test/x402-spend-cap.test.ts) falla al subir la dependencia. +const CAP_REJECTION = /rejected by spendControls\.maxAmountPerPayment(?: \(([^)]*)\))?/; + +/** + * If `error` is @x402/core refusing a payment for being above its + * per-payment USD cap, returns the clear error; otherwise null so the caller + * rethrows the original. Other spendControls rejections (for example a + * non-default asset) are deliberately left alone. + */ +export function toSpendCapError( + error: unknown, + url: string, + offered: X402OfferedRequirement[] | undefined, + describeAsset?: (asset: string, network: string) => { decimals: number; symbol: string } | undefined, +): X402SpendCapError | null { + const message = error instanceof Error ? error.message : ''; + const hit = CAP_REJECTION.exec(message); + if (!hit) return null; + // "($1, including USDC)" -> "$1" + const cap = hit[1]?.split(',')[0]?.trim() || undefined; + const cheapest = (offered ?? []) + .filter((o) => isAtomic(o.amount)) + .sort((a, b) => (BigInt(a.amount!) < BigInt(b.amount!) ? -1 : BigInt(a.amount!) > BigInt(b.amount!) ? 1 : 0))[0]; + let formattedAmount: string | undefined; + if (cheapest?.asset && cheapest.network) { + try { + const info = describeAsset?.(cheapest.asset, cheapest.network); + if (info) formattedAmount = formatAtomic(cheapest.amount!, info.decimals, info.symbol); + } catch { /* sin formato legible: se informan las unidades atómicas */ } + } + return new X402SpendCapError({ + url, cap, formattedAmount, cause: error, + amount: cheapest?.amount, asset: cheapest?.asset, network: cheapest?.network, + }); +} diff --git a/packages/sdk/src/x402serve-smoke.test.ts b/packages/sdk/src/x402serve-smoke.test.ts index 5fcda74..bfcd966 100644 --- a/packages/sdk/src/x402serve-smoke.test.ts +++ b/packages/sdk/src/x402serve-smoke.test.ts @@ -22,8 +22,16 @@ jest.mock('@x402/stellar', () => ({ jest.mock('@x402/stellar/exact/client', () => ({ ExactStellarScheme: class {}, })); -jest.mock('mppx', () => ({ - default: { create: () => ({}) }, +jest.mock('mppx/client', () => ({ + Mppx: { create: () => ({ fetch: async () => new Response('{}') }) }, +})); +jest.mock('@stellar/mpp/charge/client', () => ({ + stellar: { charge: () => ({}) }, +})); +// stellar-sdk 16 loads ESM-only @noble/hashes; index.ts pulls it in through +// x402-policy.ts. Only the constants read at module load are needed here. +jest.mock('@stellar/stellar-sdk', () => ({ + Networks: { TESTNET: 'Test SDF Network ; September 2015', PUBLIC: 'Public Global Stellar Network ; September 2015' }, })); import { x402Serve } from './index'; diff --git a/packages/sdk/test/helpers/x402-edges.ts b/packages/sdk/test/helpers/x402-edges.ts new file mode 100644 index 0000000..0f95790 --- /dev/null +++ b/packages/sdk/test/helpers/x402-edges.ts @@ -0,0 +1,108 @@ +/** + * Shared fixtures for the end-to-end x402 tests: the real @x402/fetch, + * @x402/core and @x402/stellar ExactStellarScheme run unmodified, and only the + * edges are simulated in memory (merchant HTTP responses, Soroban RPC latest + * ledger + simulation, Horizon ledger times). Keys are random and unfunded; + * nothing is broadcast and no real network is touched. + * + * Importing this module patches stellar-sdk and globalThis.fetch for the whole + * process, so only the e2e test files import it (node:test runs each file in + * its own process). Needs `npm run build` first: it loads dist/. + */ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +export const S = require('@stellar/stellar-sdk'); +const http = require('@x402/core/http'); +export const { USDC_TESTNET_ADDRESS: asset } = require('@x402/stellar'); +const dist = require('../../dist/index.js'); +export const { Agent, X402PolicyError, X402SpendCapError } = dist; + +export const MERCHANT = 'https://merchant.invalid'; +export const payTo = S.Keypair.random().publicKey(); +let nonce = 1000; +export const counters = { paidRetries: 0, challenges: 0 }; + +// ── Simulated edges (patched on the same stellar-sdk instance x402 uses) ── +S.rpc.Server.prototype.getLatestLedger = async function () { + return { sequence: 100, protocolVersion: 23, id: 'fixture' }; +}; +S.Horizon.Server.prototype.ledgers = function () { + const q: any = { + limit: () => q, order: () => q, + call: async () => ({ records: [{ closed_at: '2026-01-01T00:00:05Z' }, { closed_at: '2026-01-01T00:00:00Z' }] }), + }; + return q; +}; +S.rpc.Server.prototype.simulateTransaction = async function (tx: any) { + const op = tx.operations[0]; + const call = op.func.invokeContract(); + let auth = op.auth ?? []; + if (!auth.length) { + const from = S.Address.fromScAddress(call.args()[0].address()).toString(); + auth = [new S.xdr.SorobanAuthorizationEntry({ + credentials: S.xdr.SorobanCredentials.sorobanCredentialsAddress(new S.xdr.SorobanAddressCredentials({ + address: new S.Address(from).toScAddress(), + nonce: S.xdr.Int64.fromString(String(++nonce)), + signatureExpirationLedger: 0, + signature: S.xdr.ScVal.scvVoid(), + })), + rootInvocation: new S.xdr.SorobanAuthorizedInvocation({ + function: S.xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn(call), + subInvocations: [], + }), + })]; + } + return { + _parsed: true, id: 'fixture', latestLedger: 100, events: [], minResourceFee: '0', + transactionData: new S.SorobanDataBuilder(), result: { auth, retval: S.xdr.ScVal.scvVoid() }, + }; +}; +S.rpc.Server.prototype.sendTransaction = async function () { throw new Error('BROADCAST_FORBIDDEN'); }; + +globalThis.fetch = async (input: any, init?: any) => { + const req = new Request(input, init); + if (new URL(req.url).origin !== MERCHANT) throw new Error(`NETWORK_FORBIDDEN ${req.url}`); + const amount = new URL(req.url).searchParams.get('amount') ?? '499'; + const offeredAsset = new URL(req.url).searchParams.get('asset') ?? asset; + if (req.headers.has('PAYMENT-SIGNATURE')) { + counters.paidRetries++; + const payment = http.decodePaymentSignatureHeader(req.headers.get('PAYMENT-SIGNATURE')); + const tx = S.TransactionBuilder.fromXDR(payment.payload.transaction, S.Networks.TESTNET); + const entry = tx.operations[0].auth[0]; + const cred = entry.credentials().address(); + const pre = S.buildAuthorizationEntryPreimage(entry, cred.signatureExpirationLedger(), S.Networks.TESTNET); + const sig = S.scValToNative(cred.signature())[0]; + const pk = S.StrKey.encodeEd25519PublicKey(Buffer.from(sig.public_key)); + assert.ok(S.Keypair.fromPublicKey(pk).verify(S.hash(pre.toXDR()), Buffer.from(sig.signature)), 'merchant got a bad signature'); + return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + counters.challenges++; + const requirements = { + x402Version: 2, + resource: { url: req.url, description: 'local fixture', mimeType: 'application/json' }, + accepts: [{ scheme: 'exact', network: 'stellar:testnet', asset: offeredAsset, amount, payTo, maxTimeoutSeconds: 60, extra: { areFeesSponsored: true } }], + }; + return new Response(JSON.stringify(requirements), { + status: 402, headers: { 'PAYMENT-REQUIRED': http.encodePaymentRequiredHeader(requirements), 'content-type': 'application/json' }, + }); +}; + +export function keySigner(kp = S.Keypair.random(), opts: { mutate?: (p: string) => string } = {}) { + const calls: string[] = []; + return { + calls, kp, + signer: { + address: kp.publicKey(), + signAuthEntry: async (preimageXdr: string) => { + calls.push(preimageXdr); + const bytes = Buffer.from(opts.mutate ? opts.mutate(preimageXdr) : preimageXdr, 'base64'); + return { signedAuthEntry: kp.sign(S.hash(bytes)).toString('base64'), signerAddress: kp.publicKey() }; + }, + }, + }; +} +export const agent = () => new Agent({ apiKey: 'test-placeholder', baseUrl: 'https://backend.invalid' }); +export const init = (a: any, cfg: any) => a.initX402({ network: 'stellar:testnet', rpcUrl: 'https://rpc.invalid', ...cfg }); +export const paid = (a: any, amount = '499', extra = '') => a.x402Fetch(`${MERCHANT}/resource?amount=${amount}${extra}`); diff --git a/packages/sdk/test/x402-mpp-init.test.ts b/packages/sdk/test/x402-mpp-init.test.ts new file mode 100644 index 0000000..1cb3d71 --- /dev/null +++ b/packages/sdk/test/x402-mpp-init.test.ts @@ -0,0 +1,36 @@ +/** + * initMpp()/mppFetch() construction, offline. Runs the built package (run `npm run build` first). + * + * A real payment needs a live Soroban RPC and a server that verifies it, so the paid round trip is + * not here: it was checked by hand against the agent's MPP middleware on testnet (pull and push). + * What these pin down is what was broken in nirium 0.15.0: initMpp() threw + * "Mppx.create is not a function" because it called the wrong export with a made-up config. + */ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const { Keypair } = require('@stellar/stellar-sdk'); +const { Agent } = require('../dist/index.js'); +const agent = () => new Agent({ apiKey: 'test-placeholder', baseUrl: 'https://backend.invalid' }); + +test('initMpp builds a client with a secret key, in pull and push mode', () => { + for (const mode of ['pull', 'push', undefined] as const) { + assert.doesNotThrow(() => agent().initMpp({ secretKey: Keypair.random().secret(), network: 'stellar:testnet', mode })); + } +}); + +test('initMpp does not replace globalThis.fetch (it would also intercept x402Fetch)', () => { + const before = globalThis.fetch; + agent().initMpp({ secretKey: Keypair.random().secret() }); + assert.equal(globalThis.fetch, before); +}); + +test('initMpp rejects a malformed secret key', () => { + assert.throws(() => agent().initMpp({ secretKey: 'not-a-stellar-secret' })); +}); + +test('mppFetch before initMpp fails with the documented message', async () => { + await assert.rejects(agent().mppFetch('https://merchant.invalid/x'), /Call agent\.initMpp\(\) first/); +}); diff --git a/packages/sdk/test/x402-policy-fetch.test.ts b/packages/sdk/test/x402-policy-fetch.test.ts new file mode 100644 index 0000000..b33347f --- /dev/null +++ b/packages/sdk/test/x402-policy-fetch.test.ts @@ -0,0 +1,101 @@ +/** + * Pre-sign policy hook (#96) - end to end through Agent.x402Fetch. + * Fixtures: test/helpers/x402-edges.ts. + */ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { S, MERCHANT, payTo, X402PolicyError, counters, keySigner, agent, init, paid } from './helpers/x402-edges.ts'; + +beforeEach(() => { counters.paidRetries = 0; counters.challenges = 0; }); + +test('without policy, x402Fetch pays exactly as before', async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer }); + assert.equal((await paid(a)).status, 200); + assert.equal(calls.length, 1); + assert.equal(counters.paidRetries, 1); +}); + +test('ALLOW bound to the authorization pays; the engine saw URL, method and amount', async () => { + const { calls, signer } = keySigner(); + const seen: any[] = []; + const a = agent(); + init(a, { signer, policy: { evaluate: async (ctx: any) => { seen.push(ctx); return { decision: 'ALLOW', contextHash: ctx.contextHash }; } } }); + assert.equal((await paid(a, '777')).status, 200); + assert.equal(calls.length, 1); + assert.equal(counters.paidRetries, 1); + assert.equal(seen[0].url, `${MERCHANT}/resource?amount=777`); + assert.equal(seen[0].method, 'GET'); + assert.equal(seen[0].authorization.amount, '777'); + assert.equal(seen[0].authorization.to, payTo); + assert.equal(seen[0].authorization.preimageType, 'legacy'); +}); + +test('ALLOW also gates a raw secretKey signer', async () => { + const a = agent(); + let asked = 0; + init(a, { secretKey: S.Keypair.random().secret(), policy: { evaluate: async (ctx: any) => { asked++; return { decision: 'ALLOW', contextHash: ctx.contextHash }; } } }); + assert.equal((await paid(a)).status, 200); + assert.equal(asked, 1); + assert.equal(counters.paidRetries, 1); +}); + +test('DENY: zero signer calls, no paid retry, X402PolicyError class survives @x402/fetch', async () => { + const { calls, signer } = keySigner(); + const a = agent(); + init(a, { signer, policy: { evaluate: async () => ({ decision: 'DENY', reason: 'over budget' }) } }); + const err = await paid(a).catch((e: any) => e); + assert.ok(err instanceof X402PolicyError, String(err)); + assert.equal(err.outcome, 'DENY'); + assert.equal(calls.length, 0); + assert.equal(counters.paidRetries, 0); + assert.equal(counters.challenges, 1); +}); + +test('stale ALLOW (policy revised before signing): STALE, zero signer calls, no paid retry', async () => { + const { calls, signer } = keySigner(); + let current = 'v7'; + const a = agent(); + init(a, { signer, policy: { + evaluate: async (ctx: any) => { current = 'v8'; return { decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: 'v7' }; }, + currentVersion: async () => current, + } }); + const err = await paid(a).catch((e: any) => e); + assert.ok(err instanceof X402PolicyError, String(err)); + assert.equal(err.outcome, 'STALE'); + assert.equal(calls.length, 0); + assert.equal(counters.paidRetries, 0); +}); + +test('signer identity change during evaluation: SIGNER_CHANGED, zero signer calls', async () => { + const { calls, signer } = keySigner(); + const a = agent(); + init(a, { signer, policy: { evaluate: async (ctx: any) => { signer.address = S.Keypair.random().publicKey(); return { decision: 'ALLOW', contextHash: ctx.contextHash }; } } }); + const err = await paid(a).catch((e: any) => e); + assert.equal(err.outcome, 'SIGNER_CHANGED'); + assert.equal(calls.length, 0); + assert.equal(counters.paidRetries, 0); +}); + +// Garantía que da stellar-sdk >= 16 (authorizeEntry verifica la firma contra +// sha256(preimage)), no el gate. Este test la ata al camino real de x402Fetch. +test('a signature over altered bytes never reaches the merchant (stellar-sdk check)', async () => { + const mutate = (p: string) => { + const pre = S.xdr.HashIdPreimage.fromXDR(p, 'base64'); + pre.sorobanAuthorization().invocation().function().contractFn().args()[2] = S.nativeToScVal('1', { type: 'i128' }); + return pre.toXDR('base64'); + }; + const { calls, signer } = keySigner(undefined, { mutate }); + const a = agent(); + init(a, { signer, policy: { evaluate: async (ctx: any) => ({ decision: 'ALLOW', contextHash: ctx.contextHash }) } }); + await assert.rejects(paid(a), (e: any) => e.message.includes("signature doesn't match payload")); + assert.equal(calls.length, 1); + assert.equal(counters.paidRetries, 0); +}); + +test('initX402 rejects a malformed policy before any payment', () => { + const { signer } = keySigner(); + assert.throws(() => init(agent(), { signer, policy: {} }), /policy\.evaluate/); + assert.throws(() => init(agent(), { signer, policy: { evaluate: async () => ({}), currentVersion: 'v7' } }), /currentVersion/); + assert.throws(() => init(agent(), { signer, policy: { evaluate: async () => ({}), timeoutMs: 0 } }), /timeoutMs/); +}); diff --git a/packages/sdk/test/x402-policy.test.ts b/packages/sdk/test/x402-policy.test.ts new file mode 100644 index 0000000..f775f7d --- /dev/null +++ b/packages/sdk/test/x402-policy.test.ts @@ -0,0 +1,908 @@ +/** + * Pre-sign policy hook (#96) - unit tests against the real stellar-sdk. + * + * No RPC, no network, nothing broadcast: each test builds the auth entry that + * ExactStellarScheme would ask to sign and runs it through `authorizeEntry`, + * which is what `AssembledTransaction.signAuthEntries` calls internally (and + * where stellar-sdk verifies the returned signature against sha256(preimage)). + */ +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + Keypair, Networks, xdr, Address, nativeToScVal, authorizeEntry, hash, +} from '@stellar/stellar-sdk'; +import { + createPolicyGatedSigner, effectiveTimeoutMs, assertValidPolicyHook, X402PolicyError, +} from '../src/x402-policy.ts'; +import type { + X402PolicyHook, X402PaymentRequirementsView, X402PolicyContext, X402PolicyRecord, +} from '../src/x402-policy.ts'; + +const payer = Keypair.random(); +const merchant = Keypair.random().publicKey(); +const USDC_TESTNET = 'CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA'; +const LEDGER = 1_000_000; + +const reqs = (over: Partial = {}): X402PaymentRequirementsView => ({ + scheme: 'exact', network: 'stellar:testnet', asset: USDC_TESTNET, payTo: merchant, + amount: '200000', maxTimeoutSeconds: 60, ...over, +}); + +function invocation(opts: { amount?: string; to?: string; fn?: string; sub?: boolean }, nested = false): xdr.SorobanAuthorizedInvocation { + return new xdr.SorobanAuthorizedInvocation({ + function: xdr.SorobanAuthorizedFunction.sorobanAuthorizedFunctionTypeContractFn( + new xdr.InvokeContractArgs({ + contractAddress: new Address(USDC_TESTNET).toScAddress(), + functionName: opts.fn ?? 'transfer', + args: [ + nativeToScVal(payer.publicKey(), { type: 'address' }), + nativeToScVal(opts.to ?? merchant, { type: 'address' }), + nativeToScVal(opts.amount ?? '200000', { type: 'i128' }), + ], + }), + ), + subInvocations: opts.sub && !nested ? [invocation({}, true)] : [], + }); +} + +let nonceSeq = 1n; +/** transfer(from,to,amount) auth entry, as the simulation would return it. */ +function transferEntry(opts: { amount?: string; to?: string; fn?: string; nonce?: bigint; sub?: boolean } = {}) { + return new xdr.SorobanAuthorizationEntry({ + credentials: xdr.SorobanCredentials.sorobanCredentialsAddress(new xdr.SorobanAddressCredentials({ + address: new Address(payer.publicKey()).toScAddress(), + nonce: xdr.Int64.fromString(String(opts.nonce ?? nonceSeq++)), + signatureExpirationLedger: 0, + signature: xdr.ScVal.scvVoid(), + })), + rootInvocation: invocation(opts), + }); +} + +/** Instrumented base signer: counts calls and records the exact bytes. */ +function instrumentedSigner(opts: { mutate?: (preimageXdr: string) => string } = {}) { + const calls: string[] = []; + const signer = { + address: payer.publicKey(), + signAuthEntry: async (preimageXdr: string) => { + calls.push(preimageXdr); + const signed = opts.mutate ? opts.mutate(preimageXdr) : preimageXdr; + const pre = xdr.HashIdPreimage.fromXDR(signed, 'base64'); + return { signedAuthEntry: payer.sign(hash(pre.toXDR())).toString('base64'), signerAddress: payer.publicKey() }; + }, + }; + return { calls, signer }; +} + +/** What stellar-sdk 16 `signAuthEntries` does with each entry. */ +async function sign(gated: { signAuthEntry: (...a: any[]) => Promise }, entry = transferEntry(), passphrase = Networks.TESTNET) { + return authorizeEntry(entry, async (preimage: any) => { + const { signedAuthEntry } = await gated.signAuthEntry(preimage.toXDR('base64'), { address: payer.publicKey() }); + return Buffer.from(signedAuthEntry, 'base64'); + }, LEDGER, passphrase); +} + +const call = (url = 'https://api.example/paid', requirements = reqs()) => () => ({ url, method: 'GET', requirements }); +const allow = (extra = {}): X402PolicyHook['evaluate'] => async (ctx) => ({ decision: 'ALLOW', contextHash: ctx.contextHash, ...extra }); +const outcomeOf = async (p: Promise) => { + try { await p; return 'SIGNED'; } catch (e) { + if (!(e instanceof X402PolicyError)) throw e; + return e.outcome; + } +}; +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +describe('decision binding (G1)', () => { + test('bound, current ALLOW signs once, over the evaluated bytes', async () => { + const { calls, signer } = instrumentedSigner(); + let seen: X402PolicyContext | undefined; + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => { seen = ctx; return { decision: 'ALLOW', contextHash: ctx.contextHash }; }, + }, call()); + const signed = await sign(gated); + assert.equal(calls.length, 1); + assert.equal(calls[0], seen!.authorization.preimageXdr); + assert.equal(seen!.authorization.amount, '200000'); + assert.equal(seen!.authorization.to, merchant); + assert.equal(seen!.authorization.preimageType, 'legacy'); + assert.equal(seen!.url, 'https://api.example/paid'); + assert.equal(signed.credentials().address().signatureExpirationLedger(), LEDGER); + }); + + for (const verdict of ['DENY', 'WAIT'] as const) test(`${verdict} never reaches the signer`, async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => ({ decision: verdict, contextHash: ctx.contextHash, reason: 'x' }), + }, call()); + assert.equal(await outcomeOf(sign(gated)), verdict); + assert.equal(calls.length, 0); + }); + + test('engine exception fails closed', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: async () => { throw new Error('engine down'); } }, call()); + assert.equal(await outcomeOf(sign(gated)), 'ENGINE_ERROR'); + assert.equal(calls.length, 0); + }); + + test('timeout: a late ALLOW produces no signature, not even afterwards', async () => { + const { calls, signer } = instrumentedSigner(); + let aborted = false; + const gated = createPolicyGatedSigner(signer, { + timeoutMs: 20, + evaluate: (ctx, { signal }) => new Promise((r) => { + signal.addEventListener('abort', () => { aborted = true; }); + setTimeout(() => r({ decision: 'ALLOW', contextHash: ctx.contextHash }), 80); + }), + }, call()); + const err = await sign(gated).catch((e) => e); + assert.ok(err instanceof X402PolicyError); + assert.equal(err.outcome, 'TIMEOUT'); + assert.equal(err.message, 'x402 policy TIMEOUT: no decision within 20 ms'); + await delay(120); + assert.equal(calls.length, 0); + assert.equal(aborted, true); + }); + + test('decision resolved but the clock is already past the deadline: no signature', async () => { + const { calls, signer } = instrumentedSigner(); + let t = 0; + const gated = createPolicyGatedSigner(signer, { + timeoutMs: 1000, now: () => t, + evaluate: async (ctx) => { t = 5000; return { decision: 'ALLOW', contextHash: ctx.contextHash }; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'TIMEOUT'); + assert.equal(calls.length, 0); + }); + + test('a decision for one auth entry does not authorize another (engine cache/replay)', async () => { + const { calls, signer } = instrumentedSigner(); + let cached: string | undefined; + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => { cached ??= ctx.contextHash; return { decision: 'ALLOW', contextHash: cached }; }, + }, call()); + assert.equal(await outcomeOf(sign(gated, transferEntry({ nonce: 1n }))), 'SIGNED'); + assert.equal(await outcomeOf(sign(gated, transferEntry({ nonce: 2n }))), 'UNBOUND'); + assert.equal(calls.length, 1); + }); + + test('ALLOW without contextHash: no signature', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: async () => ({ decision: 'ALLOW' }) }, call()); + assert.equal(await outcomeOf(sign(gated)), 'UNBOUND'); + assert.equal(calls.length, 0); + }); + + for (const [label, bad] of [ + ['null', null], ['empty', {}], ['lowercase', { decision: 'allow' }], + ] as const) test(`malformed answer (${label}): no signature`, async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: async () => bad as any }, call()); + assert.equal(await outcomeOf(sign(gated)), 'MALFORMED'); + assert.equal(calls.length, 0); + }); + + test('expiresAt that is not epoch ms: no signature', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: allow({ expiresAt: '2099-01-01' }) }, call()); + assert.equal(await outcomeOf(sign(gated)), 'MALFORMED'); + assert.equal(calls.length, 0); + }); + + test('the engine cannot rewrite what gets signed (frozen context)', async () => { + const { calls, signer } = instrumentedSigner(); + let original = ''; + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => { + original = ctx.authorization.preimageXdr; + assert.throws(() => { (ctx.authorization as any).preimageXdr = 'AAAA'; }); + return { decision: 'ALLOW', contextHash: ctx.contextHash }; + }, + }, call()); + await sign(gated); + assert.equal(calls[0], original); + }); + + test('mutating the decision object after returning it cannot extend it', async () => { + const { calls, signer } = instrumentedSigner(); + let t = 1000; + const decision: any = {}; + const gated = createPolicyGatedSigner(signer, { + now: () => t, + evaluate: async (ctx) => Object.assign(decision, { decision: 'ALLOW', contextHash: ctx.contextHash, policyVersion: 'v7', expiresAt: 2000 }), + currentVersion: async () => { decision.expiresAt = 9999; t = 2000; return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'EXPIRED'); + assert.equal(calls.length, 0); + }); + + test('a throwing onDecision observer does not change the outcome', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: async () => ({ decision: 'DENY' }), onDecision: () => { throw new Error('boom'); }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'DENY'); + assert.equal(calls.length, 0); + }); + + test('concurrency: each call stays bound to its own decision', async () => { + const { calls, signer } = instrumentedSigner(); + const hashes = new Map(); + // Engine that tries to hand B the decision made for A. + const engine: X402PolicyHook['evaluate'] = async (ctx) => { + hashes.set(ctx.url, ctx.contextHash); + await delay(5); + return { decision: 'ALLOW', contextHash: hashes.get('https://a.example')! }; + }; + const a = createPolicyGatedSigner(signer, { evaluate: engine }, call('https://a.example')); + const b = createPolicyGatedSigner(signer, { evaluate: engine }, call('https://b.example')); + const out = await Promise.all([outcomeOf(sign(a)), outcomeOf(sign(b))]); + assert.deepEqual(out, ['SIGNED', 'UNBOUND']); + assert.equal(calls.length, 1); + }); + + test('fractionation: the gate obeys an aggregate cap held by the engine', async () => { + const { calls, signer } = instrumentedSigner(); + let spent = 0n; + const engine: X402PolicyHook['evaluate'] = async (ctx) => { + const amt = BigInt(ctx.authorization.amount); + if (spent + amt > 2000n) return { decision: 'DENY', reason: 'aggregate cap' }; + spent += amt; + return { decision: 'ALLOW', contextHash: ctx.contextHash }; + }; + const outs: string[] = []; + for (let i = 0; i < 5; i++) { + const gated = createPolicyGatedSigner(signer, { evaluate: engine }, call('https://api.example/paid', reqs({ amount: '499' }))); + outs.push(await outcomeOf(sign(gated, transferEntry({ amount: '499' })))); + } + assert.deepEqual(outs, ['SIGNED', 'SIGNED', 'SIGNED', 'SIGNED', 'DENY']); + assert.equal(calls.length, 4); + }); +}); + +describe('expiry (stale ALLOW, without a version source)', () => { + test('expired ALLOW does not sign and keeps policyVersion on the error', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: allow({ policyVersion: 'v7', expiresAt: Date.now() - 1 }) }, call()); + const err = await sign(gated).catch((e) => e); + assert.equal(err.outcome, 'EXPIRED'); + assert.equal(err.decision.policyVersion, 'v7'); + assert.equal(calls.length, 0); + }); + + test('expiresAt equal to now counts as expired', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { now: () => 2000, evaluate: allow({ expiresAt: 2000 }) }, call()); + assert.equal(await outcomeOf(sign(gated)), 'EXPIRED'); + assert.equal(calls.length, 0); + }); + + test('requireExpiry: ALLOW without expiresAt does not sign; with a future one it does', async () => { + const a = instrumentedSigner(); + assert.equal(await outcomeOf(sign(createPolicyGatedSigner(a.signer, { requireExpiry: true, evaluate: allow() }, call()))), 'EXPIRED'); + assert.equal(a.calls.length, 0); + const b = instrumentedSigner(); + assert.equal(await outcomeOf(sign(createPolicyGatedSigner(b.signer, { requireExpiry: true, evaluate: allow({ expiresAt: Date.now() + 10_000 }) }, call()))), 'SIGNED'); + assert.equal(b.calls.length, 1); + }); + + test('without currentVersion, policyVersion is opaque and not required', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: allow() }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNED'); + assert.equal(calls.length, 1); + }); +}); + +describe('currentVersion: stale-ALLOW check right before signing', () => { + const versioned = (extra = {}) => allow({ policyVersion: 'v7', ...extra }); + + test('ALLOW under the current version signs once, and the source is read once', async () => { + const { calls, signer } = instrumentedSigner(); + let reads = 0; + const gated = createPolicyGatedSigner(signer, { + evaluate: versioned(), currentVersion: async () => { reads++; return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNED'); + assert.equal(reads, 1); + assert.equal(calls.length, 1); + }); + + test('version changed between decision and signing: STALE, zero signer calls', async () => { + const { calls, signer } = instrumentedSigner(); + let current = 'v7'; + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => { const d = await versioned()(ctx, { signal: new AbortController().signal }); current = 'v8'; return d; }, + currentVersion: async () => current, + }, call()); + const err = await sign(gated).catch((e) => e); + assert.equal(err.outcome, 'STALE'); + assert.equal(err.decision.policyVersion, 'v7'); + assert.equal(calls.length, 0); + }); + + test('source rejects: VERSION_UNAVAILABLE, zero signer calls', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: versioned(), currentVersion: async () => { throw new Error('source offline'); }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'VERSION_UNAVAILABLE'); + assert.equal(calls.length, 0); + }); + + for (const [label, value] of [['empty', ''], ['blank', ' '], ['not a string', 7]] as const) test(`source returns ${label}: VERSION_UNAVAILABLE`, async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: versioned(), currentVersion: async () => value as any, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'VERSION_UNAVAILABLE'); + assert.equal(calls.length, 0); + }); + + test('source never resolves: TIMEOUT within the same deadline, zero signer calls', async () => { + const { calls, signer } = instrumentedSigner(); + let aborted = false; + const gated = createPolicyGatedSigner(signer, { + timeoutMs: 20, evaluate: versioned(), + currentVersion: (_c, { signal }) => new Promise(() => { signal.addEventListener('abort', () => { aborted = true; }); }), + }, call()); + const err = await sign(gated).catch((e) => e); + assert.equal(err.outcome, 'TIMEOUT'); + assert.equal(err.message, 'x402 policy TIMEOUT: no current policy version within 20 ms'); + assert.equal(calls.length, 0); + assert.equal(aborted, true); + }); + + test('ALLOW without policyVersion when a source is configured: MALFORMED, source not read', async () => { + const { calls, signer } = instrumentedSigner(); + let reads = 0; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), currentVersion: async () => { reads++; return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'MALFORMED'); + assert.equal(reads, 0); + assert.equal(calls.length, 0); + }); + + test('expiry is rechecked after the source is read', async () => { + const { calls, signer } = instrumentedSigner(); + let t = 1000; + const gated = createPolicyGatedSigner(signer, { + now: () => t, evaluate: versioned({ expiresAt: 2000 }), + currentVersion: async () => { t = 2000; return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'EXPIRED'); + assert.equal(calls.length, 0); + }); + + test('already-expired ALLOW is refused before the source is read', async () => { + const { calls, signer } = instrumentedSigner(); + let reads = 0; + const gated = createPolicyGatedSigner(signer, { + now: () => 3000, evaluate: versioned({ expiresAt: 2000 }), + currentVersion: async () => { reads++; return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'EXPIRED'); + assert.equal(reads, 0); + assert.equal(calls.length, 0); + }); + + test('concurrent revision: A checked at v7 signs, B checked at v8 does not', async () => { + const { calls, signer } = instrumentedSigner(); + let current = 'v7'; + const gate: Record void> = {}; + const source: X402PolicyHook['currentVersion'] = (ctx) => + new Promise((r) => { gate[ctx.url] = () => r(current); }); + const a = createPolicyGatedSigner(signer, { evaluate: versioned(), currentVersion: source }, call('https://a.example')); + const b = createPolicyGatedSigner(signer, { evaluate: versioned(), currentVersion: source }, call('https://b.example')); + const pa = outcomeOf(sign(a)); + const pb = outcomeOf(sign(b)); + while (!gate['https://a.example'] || !gate['https://b.example']) await delay(1); + gate['https://a.example'](); + assert.equal(await pa, 'SIGNED'); + current = 'v8'; + gate['https://b.example'](); + assert.equal(await pb, 'STALE'); + assert.equal(calls.length, 1); + }); +}); + +describe('signer identity', () => { + test('base signer address changes while the policy is evaluated: SIGNER_CHANGED, zero signer calls', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => { signer.address = Keypair.random().publicKey(); return { decision: 'ALLOW', contextHash: ctx.contextHash }; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNER_CHANGED'); + assert.equal(calls.length, 0); + }); + + test('base signer address changes while the version source is read: SIGNER_CHANGED', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: allow({ policyVersion: 'v7' }), + currentVersion: async () => { signer.address = Keypair.random().publicKey(); return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNER_CHANGED'); + assert.equal(calls.length, 0); + }); + + test('base signer address changed before the call: SIGNER_CHANGED without consulting the policy', async () => { + const { calls, signer } = instrumentedSigner(); + let asked = 0; + const gated = createPolicyGatedSigner(signer, { evaluate: async (ctx) => { asked++; return { decision: 'ALLOW', contextHash: ctx.contextHash }; } }, call()); + signer.address = Keypair.random().publicKey(); + assert.equal(await outcomeOf(sign(gated)), 'SIGNER_CHANGED'); + assert.equal(asked, 0); + assert.equal(calls.length, 0); + }); +}); + +describe('rejected before the policy is consulted', () => { + test('authorization that does not match the requirements', async () => { + const { calls, signer } = instrumentedSigner(); + let asked = 0; + const gated = createPolicyGatedSigner(signer, { evaluate: async (ctx) => { asked++; return { decision: 'ALLOW', contextHash: ctx.contextHash }; } }, call()); + assert.equal(await outcomeOf(sign(gated, transferEntry({ amount: '999999999' }))), 'CONTEXT_MISMATCH'); + assert.equal(await outcomeOf(sign(gated, transferEntry({ to: Keypair.random().publicKey() }))), 'CONTEXT_MISMATCH'); + assert.equal(await outcomeOf(sign(gated, transferEntry({ sub: true }))), 'CONTEXT_MISMATCH'); + assert.equal(await outcomeOf(sign(gated, transferEntry(), Networks.PUBLIC)), 'CONTEXT_MISMATCH'); + assert.equal(asked, 0); + assert.equal(calls.length, 0); + }); + + test('self-escalation: an invocation that is not transfer (e.g. set_admin)', async () => { + const { calls, signer } = instrumentedSigner(); + let asked = 0; + const gated = createPolicyGatedSigner(signer, { evaluate: async (ctx) => { asked++; return { decision: 'ALLOW', contextHash: ctx.contextHash }; } }, call()); + assert.equal(await outcomeOf(sign(gated, transferEntry({ fn: 'set_admin' }))), 'CONTEXT_MISMATCH'); + assert.equal(asked, 0); + assert.equal(calls.length, 0); + }); + + test('transfer.from different from the signer', async () => { + const { calls, signer } = instrumentedSigner(); + const other = Keypair.random(); + const gated = createPolicyGatedSigner({ ...signer, address: other.publicKey() }, { evaluate: allow() }, call()); + const pre = xdr.HashIdPreimage.envelopeTypeSorobanAuthorization(new xdr.HashIdPreimageSorobanAuthorization({ + networkId: hash(Buffer.from(Networks.TESTNET)), nonce: xdr.Int64.fromString('5'), + signatureExpirationLedger: LEDGER, invocation: invocation({}), + })).toXDR('base64'); + assert.equal(await outcomeOf(gated.signAuthEntry(pre, { address: other.publicKey() })), 'CONTEXT_MISMATCH'); + assert.equal(calls.length, 0); + }); +}); + +describe('CAP-71 preimage (…WithAddress, stellar-sdk >= 16)', () => { + const cap71 = (address: string) => xdr.HashIdPreimage.envelopeTypeSorobanAuthorizationWithAddress( + new xdr.HashIdPreimageSorobanAuthorizationWithAddress({ + networkId: hash(Buffer.from(Networks.TESTNET)), + nonce: xdr.Int64.fromString('7'), + invocation: invocation({}), + address: new Address(address).toScAddress(), + signatureExpirationLedger: LEDGER, + }), + ).toXDR('base64'); + + test('bound to the signer: decoded and evaluated', async () => { + const { calls, signer } = instrumentedSigner(); + let seen: X402PolicyContext | undefined; + const gated = createPolicyGatedSigner(signer, { evaluate: async (ctx) => { seen = ctx; return { decision: 'ALLOW', contextHash: ctx.contextHash }; } }, call()); + assert.equal(await outcomeOf(gated.signAuthEntry(cap71(payer.publicKey()), { address: payer.publicKey() })), 'SIGNED'); + assert.equal(seen!.authorization.preimageType, 'cap71'); + assert.equal(calls.length, 1); + }); + + test('bound to another address: rejected before the policy', async () => { + const { calls, signer } = instrumentedSigner(); + let asked = 0; + const gated = createPolicyGatedSigner(signer, { evaluate: async (ctx) => { asked++; return { decision: 'ALLOW', contextHash: ctx.contextHash }; } }, call()); + assert.equal(await outcomeOf(gated.signAuthEntry(cap71(Keypair.random().publicKey()), { address: payer.publicKey() })), 'CONTEXT_MISMATCH'); + assert.equal(asked, 0); + assert.equal(calls.length, 0); + }); +}); + +// El gate no verifica la firma que devuelve el signer: esa garantía la da +// stellar-sdk >= 16, cuyo authorizeEntry comprueba la firma contra +// sha256(preimage) antes de meterla en el entry. Si alguien baja el piso de +// stellar-sdk o cambia el camino de firma, esto tiene que fallar. +describe('signature/preimage mismatch is rejected by stellar-sdk, not by the gate', () => { + const mutateAmount = (preimageXdr: string) => { + const p = xdr.HashIdPreimage.fromXDR(preimageXdr, 'base64'); + p.sorobanAuthorization().invocation().function().contractFn().args()[2] = nativeToScVal('999', { type: 'i128' }); + return p.toXDR('base64'); + }; + + test('a signature over altered bytes never becomes a signed entry', async () => { + const { calls, signer } = instrumentedSigner({ mutate: mutateAmount }); + const gated = createPolicyGatedSigner(signer, { evaluate: allow() }, call()); + await assert.rejects(sign(gated), (e: any) => e.message.includes("signature doesn't match payload")); + assert.equal(calls.length, 1); + }); + + test('a signature by a different key never becomes a signed entry', async () => { + const intruder = Keypair.random(); + const signer = { + address: payer.publicKey(), + signAuthEntry: async (preimageXdr: string) => ({ + signedAuthEntry: intruder.sign(hash(xdr.HashIdPreimage.fromXDR(preimageXdr, 'base64').toXDR())).toString('base64'), + }), + }; + const gated = createPolicyGatedSigner(signer, { evaluate: allow() }, call()); + await assert.rejects(sign(gated), (e: any) => e.message.includes("signature doesn't match payload")); + }); +}); + +describe('timeout budget', () => { + test('never more than half the payment window', () => { + assert.equal(effectiveTimeoutMs({ evaluate: allow() }, 60), 5000); + assert.equal(effectiveTimeoutMs({ evaluate: allow() }, 4), 2000); + assert.equal(effectiveTimeoutMs({ evaluate: allow(), timeoutMs: 100 }, 60), 100); + assert.equal(effectiveTimeoutMs({ evaluate: allow() }, undefined), 5000); + }); +}); + +describe('clock (fails closed when it is not a finite number)', () => { + // Antes: now() => NaN dejaba falsas todas las comparaciones y el pago se firmaba. + const bad: Array<[string, unknown]> = [ + ['NaN', NaN], ['undefined', undefined], ['null', null], ['a numeric string', '123'], + ['Infinity', Infinity], ['-Infinity', -Infinity], + ]; + + for (const [label, value] of bad) test(`now() returns ${label} from the start: CLOCK_INVALID, policy not consulted, zero signer calls`, async () => { + const { calls, signer } = instrumentedSigner(); + let asked = 0; + const gated = createPolicyGatedSigner(signer, { + now: () => value as any, + evaluate: async (ctx) => { asked++; return { decision: 'ALLOW', contextHash: ctx.contextHash }; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'CLOCK_INVALID'); + assert.equal(asked, 0); + assert.equal(calls.length, 0); + }); + + for (const [label, value] of bad) for (const withExpiry of [false, true]) { + test(`now() turns ${label} after the policy answered${withExpiry ? ' (ALLOW carries expiresAt)' : ''}: CLOCK_INVALID, zero signer calls`, async () => { + const { calls, signer } = instrumentedSigner(); + let reads = 0; + const gated = createPolicyGatedSigner(signer, { + now: () => (++reads === 1 ? 1000 : (value as any)), + evaluate: allow(withExpiry ? { expiresAt: 2000 } : {}), + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'CLOCK_INVALID'); + assert.equal(calls.length, 0); + }); + } + + test('now() turns NaN after the version source was read: CLOCK_INVALID, zero signer calls', async () => { + const { calls, signer } = instrumentedSigner(); + let t: number = 1000; + const gated = createPolicyGatedSigner(signer, { + now: () => t, evaluate: allow({ policyVersion: 'v7', expiresAt: 5000 }), + currentVersion: async () => { t = NaN; return 'v7'; }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'CLOCK_INVALID'); + assert.equal(calls.length, 0); + }); + + for (const when of ['first read', 'later read'] as const) test(`now() throws on the ${when}: CLOCK_INVALID, not a raw error`, async () => { + const { calls, signer } = instrumentedSigner(); + let reads = 0; + const gated = createPolicyGatedSigner(signer, { + now: () => { if (++reads === (when === 'first read' ? 1 : 2)) throw new Error('clock offline'); return 1000; }, + evaluate: allow(), + }, call()); + const err = await sign(gated).catch((e) => e); + assert.ok(err instanceof X402PolicyError); + assert.equal(err.outcome, 'CLOCK_INVALID'); + assert.match(err.message, /clock threw \(clock offline\)/); + assert.equal(calls.length, 0); + }); + + test('the refusal reaches the observer and onRefused', async () => { + const { signer } = instrumentedSigner(); + const records: X402PolicyRecord[] = []; + const refused: X402PolicyError[] = []; + const gated = createPolicyGatedSigner(signer, { + now: () => NaN, evaluate: allow(), onDecision: (r) => records.push(r), + }, call(), (e) => refused.push(e)); + await sign(gated).catch(() => {}); + assert.deepEqual(records.map((r) => r.outcome), ['CLOCK_INVALID']); + assert.equal(refused.length, 1); + assert.equal(refused[0].outcome, 'CLOCK_INVALID'); + }); + + test('a finite clock still signs', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { now: () => 1000, evaluate: allow({ expiresAt: 2000 }) }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNED'); + assert.equal(calls.length, 1); + }); + + test('a `now` that is not a function is rejected when the hook is configured', () => { + assert.throws(() => assertValidPolicyHook({ evaluate: allow(), now: 5 as any }), /policy\.now/); + assert.doesNotThrow(() => assertValidPolicyHook({ evaluate: allow(), now: () => 1 })); + }); +}); + +describe('receipts: SIGNED only exists once the signer returned a signature', () => { + // Antes: SIGNED se registraba justo ANTES de llamar al signer, aunque luego rechazara o fallara. + const watch = () => { + const records: X402PolicyRecord[] = []; + const refused: X402PolicyError[] = []; + return { records, refused, outcomes: () => records.map((r) => r.outcome), onDecision: (r: X402PolicyRecord) => { records.push(r); } }; + }; + const customSigner = (signAuthEntry: (p: string) => any) => { + const calls: string[] = []; + return { calls, signer: { address: payer.publicKey(), signAuthEntry: (p: string) => { calls.push(p); return signAuthEntry(p); } } as any }; + }; + + test('success: ALLOWED, then SIGNED only after the signer resolved', async () => { + const w = watch(); + let release!: () => void; + const gate = new Promise((r) => { release = r; }); + const base = instrumentedSigner(); + const slow = { address: base.signer.address, signAuthEntry: async (p: string) => { await gate; return base.signer.signAuthEntry(p); } }; + const gated = createPolicyGatedSigner(slow, { evaluate: allow(), onDecision: w.onDecision }, call()); + const pending = sign(gated); + while (base.calls.length === 0 && w.outcomes().length < 1) await delay(1); + await delay(10); + assert.deepEqual(w.outcomes(), ['ALLOWED'], 'signer still running: nothing may say SIGNED yet'); + release(); + await pending; + assert.deepEqual(w.outcomes(), ['ALLOWED', 'SIGNED']); + assert.equal(w.records[1].context!.url, 'https://api.example/paid'); + assert.equal(w.records[1].decision!.decision, 'ALLOW'); + }); + + test('signer rejects: ALLOWED then SIGNER_ERROR, never SIGNED, and its own error comes through untouched', async () => { + const w = watch(); + const { calls, signer } = customSigner(async () => { throw new Error('hsm offline'); }); + const gated = createPolicyGatedSigner(signer, { evaluate: allow(), onDecision: w.onDecision }, call(), (e) => w.refused.push(e)); + const err = await sign(gated).catch((e) => e); + assert.ok(!(err instanceof X402PolicyError), 'a signer failure is not a policy refusal'); + assert.match(err.message, /hsm offline/); + assert.equal(calls.length, 1); + assert.deepEqual(w.outcomes(), ['ALLOWED', 'SIGNER_ERROR']); + assert.equal(w.records[1].error, 'hsm offline'); + assert.equal(w.refused.length, 0); + }); + + test('signer throws synchronously: same records, same untouched error', async () => { + const w = watch(); + const { calls, signer } = customSigner(() => { throw new Error('sync boom'); }); + const gated = createPolicyGatedSigner(signer, { evaluate: allow(), onDecision: w.onDecision }, call()); + const err = await sign(gated).catch((e) => e); + assert.ok(!(err instanceof X402PolicyError)); + assert.match(err.message, /sync boom/); + assert.equal(calls.length, 1); + assert.deepEqual(w.outcomes(), ['ALLOWED', 'SIGNER_ERROR']); + }); + + const unusable: Array<[string, unknown]> = [ + ['undefined', undefined], ['null', null], ['an empty object', {}], + ['an empty signedAuthEntry', { signedAuthEntry: '' }], ['a non-string signedAuthEntry', { signedAuthEntry: 7 }], + ]; + for (const [label, value] of unusable) test(`signer resolves with ${label}: SIGNER_ERROR refusal, never SIGNED`, async () => { + const w = watch(); + const { calls, signer } = customSigner(async () => value); + const gated = createPolicyGatedSigner(signer, { evaluate: allow(), onDecision: w.onDecision }, call(), (e) => w.refused.push(e)); + const err = await sign(gated).catch((e) => e); + assert.ok(err instanceof X402PolicyError); + assert.equal(err.outcome, 'SIGNER_ERROR'); + assert.equal(err.message, 'x402 policy SIGNER_ERROR: signer returned no signedAuthEntry'); + assert.equal(calls.length, 1); + assert.deepEqual(w.outcomes(), ['ALLOWED', 'SIGNER_ERROR']); + assert.equal(w.refused.length, 1); + }); + + test('a refusal before the signer is one record and never mentions ALLOWED or SIGNED', async () => { + for (const [engine, expected] of [ + [async (ctx: X402PolicyContext) => ({ decision: 'DENY' as const, contextHash: ctx.contextHash }), 'DENY'], + [async () => ({ decision: 'ALLOW' as const, contextHash: 'nope' }), 'UNBOUND'], + ] as const) { + const w = watch(); + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { evaluate: engine, onDecision: w.onDecision }, call()); + assert.equal(await outcomeOf(sign(gated)), expected); + assert.deepEqual(w.outcomes(), [expected]); + assert.equal(calls.length, 0); + } + }); +}); + +describe('observer runs before the final checks (what it provokes is detected)', () => { + // Antes: el observador corría DESPUES de la última comprobación y justo antes del signer, + // así que un callback síncrono podía mover el reloj, la versión o la identidad sin que nada lo viera. + const onAllowed = (fn: () => void, sink: X402PolicyRecord[] = []) => (r: X402PolicyRecord) => { + sink.push(r); + if (r.outcome === 'ALLOWED') fn(); + }; + const outcomes = (rs: X402PolicyRecord[]) => rs.map((r) => r.outcome); + + test('observer pushes the injected clock past expiresAt: EXPIRED, zero signer calls', async () => { + const { calls, signer } = instrumentedSigner(); + const rs: X402PolicyRecord[] = []; + let t = 1000; + const gated = createPolicyGatedSigner(signer, { + now: () => t, evaluate: allow({ expiresAt: 2000 }), onDecision: onAllowed(() => { t = 2000; }, rs), + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'EXPIRED'); + assert.equal(calls.length, 0); + assert.deepEqual(outcomes(rs), ['ALLOWED', 'EXPIRED']); + }); + + test('observer pushes the clock past the deadline (no currentVersion configured): TIMEOUT', async () => { + const { calls, signer } = instrumentedSigner(); + let t = 1000; + const gated = createPolicyGatedSigner(signer, { + timeoutMs: 50, now: () => t, evaluate: allow(), onDecision: onAllowed(() => { t = 1050; }), + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'TIMEOUT'); + assert.equal(calls.length, 0); + }); + + test('observer that makes the clock NaN: CLOCK_INVALID', async () => { + const { calls, signer } = instrumentedSigner(); + let t = 1000; + const gated = createPolicyGatedSigner(signer, { + now: () => t, evaluate: allow({ expiresAt: 2000 }), onDecision: onAllowed(() => { t = NaN; }), + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'CLOCK_INVALID'); + assert.equal(calls.length, 0); + }); + + test('observer changes the policy version: the later read sees it, STALE', async () => { + const { calls, signer } = instrumentedSigner(); + const rs: X402PolicyRecord[] = []; + let current = 'v7'; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow({ policyVersion: 'v7' }), currentVersion: async () => current, + onDecision: onAllowed(() => { current = 'v8'; }, rs), + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'STALE'); + assert.equal(calls.length, 0); + assert.deepEqual(outcomes(rs), ['ALLOWED', 'STALE']); + }); + + for (const withVersion of [false, true]) test(`observer changes the base signer's address${withVersion ? ' (with currentVersion)' : ''}: SIGNER_CHANGED`, async () => { + const { calls, signer } = instrumentedSigner(); + const rs: X402PolicyRecord[] = []; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(withVersion ? { policyVersion: 'v7' } : {}), + ...(withVersion ? { currentVersion: async () => 'v7' } : {}), + onDecision: onAllowed(() => { signer.address = Keypair.random().publicKey(); }, rs), + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNER_CHANGED'); + assert.equal(calls.length, 0); + assert.deepEqual(outcomes(rs), ['ALLOWED', 'SIGNER_CHANGED']); + }); + + test('a throwing observer is still swallowed and the payment is still decided by the checks', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), onDecision: (r) => { if (r.outcome === 'ALLOWED') throw new Error('observer bug'); }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNED'); + assert.equal(calls.length, 1); + }); + + test('a harmless observer changes nothing: ALLOWED, SIGNED, one signer call', async () => { + const { calls, signer } = instrumentedSigner(); + const rs: X402PolicyRecord[] = []; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow({ policyVersion: 'v7', expiresAt: Date.now() + 60_000 }), currentVersion: async () => 'v7', + onDecision: (r) => { rs.push(r); }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'SIGNED'); + assert.equal(calls.length, 1); + assert.deepEqual(outcomes(rs), ['ALLOWED', 'SIGNED']); + }); + + test('a malformed ALLOW (no policyVersion with currentVersion set) is refused before ALLOWED is reported', async () => { + const { calls, signer } = instrumentedSigner(); + const rs: X402PolicyRecord[] = []; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), currentVersion: async () => 'v7', onDecision: (r) => { rs.push(r); }, + }, call()); + assert.equal(await outcomeOf(sign(gated)), 'MALFORMED'); + assert.deepEqual(outcomes(rs), ['MALFORMED']); + assert.equal(calls.length, 0); + }); +}); + +describe('observer that returns a promise (never awaited, never unhandled)', () => { + // Antes: un onDecision async que rechazaba escapaba del try/catch y producia un + // unhandledRejection, que en Node 22 termina el proceso por defecto. + async function unhandledDuring(run: () => Promise): Promise { + const seen: unknown[] = []; + const on = (reason: unknown) => { seen.push(reason); }; + process.on('unhandledRejection', on); + try { + await run(); + await delay(30); // las rechazadas sin manejar se notifican tras el microtask y un tick + await new Promise((r) => setImmediate(r)); + } finally { process.off('unhandledRejection', on); } + return seen; + } + + test('async observer that rejects on every record: the payment signs, nothing is unhandled', async () => { + const { calls, signer } = instrumentedSigner(); + const outs: string[] = []; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), + onDecision: async (r) => { outs.push(r.outcome); throw new Error('async observer bug'); }, + }, call()); + let result: string = ''; + const unhandled = await unhandledDuring(async () => { result = await outcomeOf(sign(gated)); }); + assert.deepEqual(unhandled, []); + assert.equal(result, 'SIGNED'); + assert.equal(calls.length, 1); + assert.deepEqual(outs, ['ALLOWED', 'SIGNED']); + }); + + test('async observer that rejects on a refusal: the refusal is unchanged, nothing is unhandled', async () => { + const { calls, signer } = instrumentedSigner(); + const refused: X402PolicyError[] = []; + const gated = createPolicyGatedSigner(signer, { + evaluate: async (ctx) => ({ decision: 'DENY', contextHash: ctx.contextHash, reason: 'no' }), + onDecision: () => Promise.reject(new Error('async observer bug')), + }, call(), (e) => refused.push(e)); + let result: string = ''; + const unhandled = await unhandledDuring(async () => { result = await outcomeOf(sign(gated)); }); + assert.deepEqual(unhandled, []); + assert.equal(result, 'DENY'); + assert.equal(refused.length, 1); + assert.equal(calls.length, 0); + }); + + test('async observer that rejects after a signer failure: the signer error still comes through', async () => { + const failing = { address: payer.publicKey(), signAuthEntry: async () => { throw new Error('hsm offline'); } } as any; + const gated = createPolicyGatedSigner(failing, { evaluate: allow(), onDecision: async () => { throw new Error('async observer bug'); } }, call()); + let err: any; + const unhandled = await unhandledDuring(async () => { err = await sign(gated).catch((e) => e); }); + assert.deepEqual(unhandled, []); + assert.match(err.message, /hsm offline/); + assert.ok(!(err instanceof X402PolicyError)); + }); + + test('a thenable that rejects, and one whose then() throws: swallowed, payment unchanged', async () => { + const { calls, signer } = instrumentedSigner(); + const thenables: unknown[] = [ + { then: (_res: unknown, rej: (e: unknown) => void) => rej(new Error('thenable rejected')) }, + { then: () => { throw new Error('then() threw'); } }, + { get then(): never { throw new Error('then getter threw'); } }, + ]; + let i = 0; + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), onDecision: () => thenables[i++ % thenables.length] as any, + }, call()); + let result: string = ''; + const unhandled = await unhandledDuring(async () => { result = await outcomeOf(sign(gated)); }); + assert.deepEqual(unhandled, []); + assert.equal(result, 'SIGNED'); + assert.equal(calls.length, 1); + }); + + test('an observer promise that never settles does not delay or block the payment', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), onDecision: () => new Promise(() => {}), + }, call()); + const outcome = await Promise.race([outcomeOf(sign(gated)), delay(1000).then(() => 'BLOCKED')]); + assert.equal(outcome, 'SIGNED'); + assert.equal(calls.length, 1); + }); + + test('a synchronous throw on every record is swallowed exactly as before', async () => { + const { calls, signer } = instrumentedSigner(); + const gated = createPolicyGatedSigner(signer, { + evaluate: allow(), onDecision: () => { throw new Error('sync observer bug'); }, + }, call()); + const unhandled = await unhandledDuring(async () => { assert.equal(await outcomeOf(sign(gated)), 'SIGNED'); }); + assert.deepEqual(unhandled, []); + assert.equal(calls.length, 1); + }); +}); + diff --git a/packages/sdk/test/x402-spend-cap.test.ts b/packages/sdk/test/x402-spend-cap.test.ts new file mode 100644 index 0000000..805eb5b --- /dev/null +++ b/packages/sdk/test/x402-spend-cap.test.ts @@ -0,0 +1,164 @@ +/** + * Clear error when @x402/core's per-payment cap blocks x402Fetch. + * + * End to end through Agent.x402Fetch on the built package (fixtures in + * test/helpers/x402-edges.ts), plus unit tests of the matcher. The e2e cases + * also pin the exact message @x402/core produces today: if a dependency bump + * rewords it, they fail here instead of silently degrading to the generic error. + */ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + S, MERCHANT, asset, X402PolicyError, X402SpendCapError, + counters, keySigner, agent, init, paid, +} from './helpers/x402-edges.ts'; +import { toSpendCapError, formatAtomic, readOfferedRequirements } from '../src/x402-spend-cap.ts'; + +beforeEach(() => { counters.paidRetries = 0; counters.challenges = 0; }); + +const allowAll = { evaluate: async (ctx: any) => ({ decision: 'ALLOW', contextHash: ctx.contextHash }) }; + +test('above the cap: X402SpendCapError with amount, cap, asset, and no signer call', async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer }); + const err = await paid(a, '20000000').catch((e: any) => e); + assert.ok(err instanceof X402SpendCapError, String(err)); + assert.equal(err.code, 'X402_SPEND_CAP_EXCEEDED'); + assert.equal(err.signerCalled, false); + assert.equal(err.amount, '20000000'); + assert.equal(err.formattedAmount, '2 USDC'); + assert.equal(err.cap, '$1'); + assert.equal(err.asset, asset); + assert.equal(err.network, 'stellar:testnet'); + assert.equal(err.url, `${MERCHANT}/resource?amount=20000000`); + assert.match(err.message, /server asked for 2 USDC \(20000000 atomic units\)/); + assert.match(err.message, /per-payment cap is \$1/); + assert.match(err.message, /signer was not called; nothing was signed or sent/); + assert.match(err.cause.message, /rejected by spendControls\.maxAmountPerPayment/); + assert.equal(calls.length, 0); + assert.equal(counters.paidRetries, 0); + assert.equal(counters.challenges, 1); +}); + +test('the boundary: exactly $1 still pays, one atomic unit more is flagged', async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer }); + assert.equal((await paid(a, '10000000')).status, 200); + assert.equal(calls.length, 1); + const err = await paid(a, '10000001').catch((e: any) => e); + assert.ok(err instanceof X402SpendCapError, String(err)); + assert.equal(err.formattedAmount, '1.0000001 USDC'); + assert.equal(calls.length, 1); +}); + +test('with a policy configured, the cap error comes first and the policy is not consulted', async () => { + const { calls, signer } = keySigner(); + let asked = 0; + const a = agent(); + init(a, { signer, policy: { evaluate: async (ctx: any) => { asked++; return allowAll.evaluate(ctx); } } }); + const err = await paid(a, '20000000').catch((e: any) => e); + assert.ok(err instanceof X402SpendCapError, String(err)); + assert.ok(!(err instanceof X402PolicyError)); + assert.equal(err.amount, '20000000'); + assert.equal(asked, 0); + assert.equal(calls.length, 0); + // and a payment under the cap still goes through the policy as before + assert.equal((await paid(a, '777')).status, 200); + assert.equal(asked, 1); +}); + +test('concurrent calls each report their own amount', async () => { + const { signer } = keySigner(); + const a = agent(); init(a, { signer }); + const [x, y] = await Promise.all([ + paid(a, '20000000').catch((e: any) => e), + paid(a, '30000000').catch((e: any) => e), + ]); + assert.ok(x instanceof X402SpendCapError && y instanceof X402SpendCapError); + assert.equal(x.amount, '20000000'); + assert.equal(y.amount, '30000000'); +}); + +test('other spendControls rejections stay generic (non-default asset)', async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer }); + const other = S.StrKey.encodeContract(Buffer.alloc(32, 9)); + const err = await paid(a, '499', `&asset=${other}`).catch((e: any) => e); + assert.ok(err instanceof Error); + assert.ok(!(err instanceof X402SpendCapError)); + assert.match(err.message, /only default assets/); + assert.equal(calls.length, 0); +}); + +test('unrelated failures are rethrown untouched', async () => { + const { signer } = keySigner(); + const a = agent(); init(a, { signer }); + const err = await a.x402Fetch('https://elsewhere.invalid/x').catch((e: any) => e); + assert.ok(!(err instanceof X402SpendCapError)); + assert.match(err.message, /NETWORK_FORBIDDEN/); +}); + +test('the error class is exported from the package entry point', () => { + assert.equal(typeof X402SpendCapError, 'function'); + assert.equal(new X402SpendCapError({ url: 'u' }).name, 'X402SpendCapError'); +}); + +// ── matcher, no network ────────────────────────────────────────────────────── + +const coreMessage = 'Failed to create payment payload: All payment requirements were rejected by spendControls.maxAmountPerPayment ($1, including USDC). Raise maxAmountPerPayment, set it to false to disable, set allowedAssets[].maxAmountPerPayment for a per-asset atomic cap, or set spendControls: false to disable all spend controls.'; + +test('matcher: without the offered amount the message does not invent one', () => { + const err = toSpendCapError(new Error(coreMessage), 'https://x.example/p', undefined)!; + // src/ and dist/ are different module instances here, so compare name/code, not instanceof. + assert.equal(err.name, 'X402SpendCapError'); + assert.equal(err.code, 'X402_SPEND_CAP_EXCEEDED'); + assert.equal(err.amount, undefined); + assert.equal(err.cap, '$1'); + assert.doesNotMatch(err.message, /undefined/); + assert.match(err.message, /server asked for more than the cap/); +}); + +test('matcher: several offers report the cheapest one, in atomic units when the asset is unknown', () => { + const offered = [ + { scheme: 'exact', network: 'stellar:testnet', asset: 'CAAA', amount: '50000000' }, + { scheme: 'exact', network: 'stellar:testnet', asset: 'CBBB', amount: '20000000' }, + { scheme: 'exact', network: 'stellar:testnet', asset: 'CCCC', amount: 'not-a-number' }, + ]; + const err = toSpendCapError(new Error(coreMessage), 'u', offered)!; + assert.equal(err.amount, '20000000'); + assert.equal(err.asset, 'CBBB'); + assert.equal(err.formattedAmount, undefined); + assert.match(err.message, /20000000 atomic units of CBBB/); +}); + +test('matcher: a describeAsset that throws only costs the readable amount', () => { + const offered = [{ network: 'stellar:testnet', asset: 'CAAA', amount: '20000000' }]; + const err = toSpendCapError(new Error(coreMessage), 'u', offered, () => { throw new Error('boom'); })!; + assert.equal(err.amount, '20000000'); + assert.equal(err.formattedAmount, undefined); +}); + +test('matcher: anything else returns null', () => { + assert.equal(toSpendCapError(new Error('Payment already attempted'), 'u', undefined), null); + assert.equal(toSpendCapError('rejected by spendControls.maxAmountPerPayment', 'u', undefined), null); + assert.equal(toSpendCapError(new Error('rejected by spendControls: only default assets'), 'u', undefined), null); + assert.equal(toSpendCapError(new Error('rejected by spendControls.allowedAssets maxAmountPerPayment. Raise the per-asset cap'), 'u', undefined), null); +}); + +test('formatAtomic', () => { + assert.equal(formatAtomic('20000000', 7, 'USDC'), '2 USDC'); + assert.equal(formatAtomic('10000001', 7, 'USDC'), '1.0000001 USDC'); + assert.equal(formatAtomic('499', 7, 'USDC'), '0.0000499 USDC'); + assert.equal(formatAtomic('0', 7, 'USDC'), '0 USDC'); +}); + +test('readOfferedRequirements tolerates a missing or broken header', () => { + const h = (v: string | null) => ({ headers: { get: () => v } }); + assert.equal(readOfferedRequirements(h(null)), undefined); + assert.equal(readOfferedRequirements(h('!!!not base64!!!')), undefined); + assert.equal(readOfferedRequirements(h(Buffer.from('{"x":1}').toString('base64'))), undefined); + assert.deepEqual( + readOfferedRequirements(h(Buffer.from(JSON.stringify({ accepts: [{ amount: '1' }] })).toString('base64'))), + [{ amount: '1' }], + ); +});