From 5a26bead57650d51232cdef51ad13b42db396303 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 19:10:03 -0600 Subject: [PATCH 01/22] chore(sdk): bump @stellar/stellar-sdk to ^16.3.0 @x402/stellar 2.28.0 requires @stellar/stellar-sdk ^16.3.0. The SDK's own code does not import stellar-sdk directly; the bump matters because 16.x decodes the CAP-71 auth preimage variant, which the upcoming pre-sign policy hook needs to read what is actually being signed. @stellar/mpp 0.7.1 still peer-requires ^15.1.0 and no release accepts 16, so the install keeps --legacy-peer-deps. CI comment updated to say so. Suite: 56 jest + 2 node, all passing. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sdk-test.yml | 5 +- packages/sdk/package-lock.json | 444 ++++----------------------------- packages/sdk/package.json | 2 +- 3 files changed, 57 insertions(+), 394 deletions(-) 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/sdk/package-lock.json b/packages/sdk/package-lock.json index 9181af7..c0a4994 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -1,16 +1,16 @@ { "name": "nirium", - "version": "0.14.1", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nirium", - "version": "0.14.1", + "version": "0.15.0", "license": "Apache-2.0", "dependencies": { "@stellar/mpp": "^0.7.1", - "@stellar/stellar-sdk": "^14.5.0", + "@stellar/stellar-sdk": "^16.3.0", "@x402/fetch": "^2.17.0", "@x402/stellar": "^2.17.0", "mppx": "^0.6.31", @@ -1184,10 +1184,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 +1209,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": { @@ -1750,84 +1751,6 @@ "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==", - "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" - }, - "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 +1872,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 +2033,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 +2136,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 +2149,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 +2448,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 +2611,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 +2750,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 +2967,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 +3124,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 +3134,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 +3179,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 +4485,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 +4540,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 +4589,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 +4599,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 +4969,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 +5085,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 +5201,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 +5359,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..7ce245b 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -31,7 +31,7 @@ }, "dependencies": { "@stellar/mpp": "^0.7.1", - "@stellar/stellar-sdk": "^14.5.0", + "@stellar/stellar-sdk": "^16.3.0", "@x402/fetch": "^2.17.0", "@x402/stellar": "^2.17.0", "mppx": "^0.6.31", From 869c406c01c7e73319e021d60faf1e0ed0979ff3 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 19:10:24 -0600 Subject: [PATCH 02/22] chore(sdk): bump @x402/fetch, @x402/stellar and @x402/core to ^2.28.0 Keeps the paying client on the current x402 release before the pre-sign policy hook is built on top of it. @x402/stellar 2.28.0 is the release that asks for @stellar/stellar-sdk ^16.3.0 (previous commit), so the tree now resolves a single stellar-sdk 16.3.0. packages/mcp still pins ^2.17.0; left for a separate change. Suite: 56 jest + 2 node, all passing. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/package-lock.json | 30 +++++++++++++++--------------- packages/sdk/package.json | 6 +++--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index c0a4994..d6bb85e 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "@stellar/mpp": "^0.7.1", "@stellar/stellar-sdk": "^16.3.0", - "@x402/fetch": "^2.17.0", - "@x402/stellar": "^2.17.0", + "@x402/fetch": "^2.28.0", + "@x402/stellar": "^2.28.0", "mppx": "^0.6.31", "viem": "^2.55.19", "ws": "^8.21.3" @@ -21,7 +21,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" @@ -1712,9 +1712,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" @@ -1730,22 +1730,22 @@ } }, "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==", + "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": { - "@stellar/stellar-sdk": "^16.0.1", - "@x402/core": "~2.23.0" + "@stellar/stellar-sdk": "^16.3.0", + "@x402/core": "~2.28.0" }, "engines": { "node": ">=22.0.0" diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 7ce245b..8683c75 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -32,8 +32,8 @@ "dependencies": { "@stellar/mpp": "^0.7.1", "@stellar/stellar-sdk": "^16.3.0", - "@x402/fetch": "^2.17.0", - "@x402/stellar": "^2.17.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 +42,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" From bd25e2b3348fb9cea689f767630296c5212dec5e Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 19:18:24 -0600 Subject: [PATCH 03/22] docs(sdk): require Node.js >= 22, not 18 @stellar/stellar-sdk 16.3.0 (now a direct dependency) and @x402/stellar 2.28.0 both declare engines.node >=22.0.0. The README still said >= 18. Under Node 22, stellar-sdk 16's CJS build also loads ESM-only @noble/hashes through require(esm), which older Node lines lack. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index a33ef51..d6a3431 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -272,7 +272,7 @@ Anchor a **hash** rather than the data itself: IPFS content cannot be deleted, s ## Requirements -- Node.js >= 18 +- Node.js >= 22 (required by `@stellar/stellar-sdk` 16 and `@x402/stellar` 2.28, both declare `engines.node >=22.0.0`) - TypeScript >= 5.0 ## Links From 30327fba8ba0bb96775b98bbad601c74cc1afd8e Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 19:19:36 -0600 Subject: [PATCH 04/22] feat(sdk): pre-sign policy hook for x402Fetch initX402({ policy }) asks a caller-supplied policy before anything is signed. The question is asked inside the signer, after ExactStellarScheme has built the Soroban authorization, so the policy sees what would actually be signed: amount, destination, asset, nonce, expiration ledger and network, decoded from the preimage bytes. Only an ALLOW that echoes this authorization's contextHash and is still valid reaches the signer; DENY, WAIT, an exception, a timeout, a malformed or expired answer sign nothing. Without `policy`, x402Fetch is unchanged. - currentVersion (optional): read after the ALLOW and right before signing; the ALLOW signs only if its policyVersion matches. A failed, empty or late read signs nothing. Documented as not atomic with signing. - The base signer's address is fixed when the gate is built; if it changes while the policy is asked, nothing is signed (SIGNER_CHANGED). - Authorizations that are not one transfer(from,to,amount) matching the selected requirements are refused before the policy is consulted. Both preimage variants stellar-sdk 16 produces are decoded; CAP-71 only when bound to the signer. - The gate does not check the returned signature: stellar-sdk's authorizeEntry does. A test drives a bad signature through the real ExactStellarScheme to pin that. - One x402 client per call when a policy is set, so concurrent payments never share decision state. onPaymentCreationFailure is never registered on it (it can produce a payload without the signer). X402PolicyError keeps its class through @x402/fetch's re-wrap. Tests: 43 unit (real stellar-sdk authorizeEntry) and 8 end to end through Agent.x402Fetch on the built package, with merchant, RPC and Horizon simulated in memory. The x402Serve smoke test now mocks stellar-sdk, which jest cannot load (ESM-only @noble/hashes). Version 0.16.0. Co-Authored-By: Claude Opus 5.5 --- packages/sdk/CHANGELOG.md | 7 + packages/sdk/README.md | 39 ++ packages/sdk/package-lock.json | 4 +- packages/sdk/package.json | 2 +- packages/sdk/src/index.ts | 60 +++ packages/sdk/src/x402-policy.ts | 385 ++++++++++++++ packages/sdk/src/x402serve-smoke.test.ts | 5 + packages/sdk/test/x402-policy-fetch.test.ts | 200 ++++++++ packages/sdk/test/x402-policy.test.ts | 542 ++++++++++++++++++++ 9 files changed, 1241 insertions(+), 3 deletions(-) create mode 100644 packages/sdk/src/x402-policy.ts create mode 100644 packages/sdk/test/x402-policy-fetch.test.ts create mode 100644 packages/sdk/test/x402-policy.test.ts diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index b350d88..e92b892 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -6,6 +6,13 @@ All notable changes to the `nirium` package are documented here. ### Added +- `initX402({ policy })`: 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`. Without `policy`, behavior is unchanged. New exports: `X402PolicyError` and the `X402Policy*` types. +- 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. + +### Changed + +- `@stellar/stellar-sdk` ^16.3.0 and `@x402/fetch`/`@x402/stellar`/`@x402/core` ^2.28.0. Node.js >= 22 is now required (both declare `engines.node >=22.0.0`). + - `x402Serve()`: optional `guard` config for replay protection and rate limiting, off by default. Providing `guard.store` turns on single-use protection against a replayed payment proof (`X-PAYMENT`/`PAYMENT-SIGNATURE`); adding `guard.rateLimit` also turns on a sliding-window rate limit per caller IP. Fails closed (503) for replay protection and open for rate limiting if the store is unavailable, matching the design already proven in a real production `x402Serve()` deployment. New exports: `X402GuardStore`, `X402GuardConfig`, `createUpstashX402GuardStore()` (a reference Upstash-backed store). See [#91](https://github.com/nirium-protocol/nirium/issues/91). The store interface and this feature's fail-closed/fail-open split are generalized from - and credited to - a real production integrator's own implementation: **Edgadafi/remesa-liquidez** (commit `1e0cbd5902cb224d3c6a2320cc009cf4df84f513`, `backend/src/middleware/paymentGuard.ts` and `backend/src/middleware/rateLimit.ts`). The code in this package is our own, not copied from theirs. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d6a3431..869f994 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -93,6 +93,45 @@ const response = await agent.x402Fetch('https://nirium-agent.fly.dev/api/v1/prem const data = await response.json(); ``` +#### Pre-sign policy hook + +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. + +```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(), + }, +}); + +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`, or a signer whose address changed while the policy was being asked. 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. + +**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 does not inspect the signature the signer returns. 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, and nothing on-chain enforces the policy. It does not reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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 — Session-Based Budget Delegation ```typescript agent.initMpp({ diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index d6bb85e..8a4c2dc 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "nirium", - "version": "0.15.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nirium", - "version": "0.15.0", + "version": "0.16.0", "license": "Apache-2.0", "dependencies": { "@stellar/mpp": "^0.7.1", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 8683c75..caa7ecf 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "nirium", - "version": "0.15.0", + "version": "0.16.0", "description": "Autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index a5a0f00..da613af 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -14,6 +14,10 @@ import { createEd25519Signer } from '@x402/stellar'; import { ExactStellarScheme } from '@x402/stellar/exact/client'; import * as MppxModule from 'mppx'; import { checkReplay, checkRateLimit, type X402GuardConfig } from './x402-guard'; +import { + assertValidPolicyHook, createPolicyGatedSigner, X402PolicyError, + type X402BaseSigner, type X402PolicyHook, type X402PaymentRequirementsView, +} from './x402-policy'; export interface AgentConfig { apiKey: string; @@ -27,6 +31,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 +62,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 { @@ -569,6 +584,7 @@ export class Agent { private token: string | null = null; private x402Client: { fetch: 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 +1173,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,6 +1181,15 @@ export class Agent { const rpcUrl = config.rpcUrl || (network.includes('testnet') ? 'https://soroban-testnet.stellar.org' : 'https://soroban-rpc.mainnet.stellar.gateway.fm'); + 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 client = new (X402ClientClass as any)().register( 'stellar:*', new (ExactStellarScheme as any)(signer, { url: rpcUrl }) @@ -1177,12 +1203,41 @@ export class Agent { * Returns the Response object — call .json() or .text() for the payload. */ 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); } + 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 client = new (X402ClientClass as any)() + .register('stellar:*', new (ExactStellarScheme as any)(gated, { url: cfg.rpcUrl })) + .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(globalThis.fetch, client)(url, init); + } catch (e) { + // @x402/fetch reenvuelve el error y pierde la clase. + throw slot.error ?? e; + } + } + // ─── MPP Protocol (Charge Mode) ──────────────────────────── /** @@ -1536,6 +1591,11 @@ 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 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..d37d732 --- /dev/null +++ b/packages/sdk/src/x402-policy.ts @@ -0,0 +1,385 @@ +// ═══════════════════════════════════════════════════════════════ +// 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 = + | 'SIGNED' | 'DENY' | 'WAIT' | 'TIMEOUT' | 'ENGINE_ERROR' + | 'EXPIRED' | 'UNBOUND' | 'MALFORMED' | 'CONTEXT_MISMATCH' + | 'STALE' | 'VERSION_UNAVAILABLE' | 'SIGNER_CHANGED'; + +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. + */ + 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. Cannot change the outcome; its errors are swallowed. */ + onDecision?: (record: X402PolicyRecord) => void; + /** Injectable clock (epoch ms), for tests. */ + now?: () => number; +} + +export class X402PolicyError extends Error { + readonly outcome: Exclude; + readonly decision?: X402PolicyDecision; + readonly contextHash?: string; + constructor( + outcome: Exclude, + 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).'); + } +} + +/** + * 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. + */ +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; + const report = (r: X402PolicyRecord) => { + try { policy.onDecision?.(r); } catch { /* un observador no decide nada */ } + }; + const refuse = ( + outcome: Exclude, 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 = now() + 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 = now(); + 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); + }; + checkClock(); + + if (policy.currentVersion) { + if (!nonempty(allow.policyVersion)) { + return refuse('MALFORMED', 'ALLOW has no policyVersion to check against currentVersion', context, allow); + } + 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); + } + // El reloj se vuelve a leer después del await. + checkClock(); + } + + // Último chequeo antes de firmar, sin ningún await en medio. + if (base.address !== account) return refuse('SIGNER_CHANGED', 'base signer address changed while the policy was evaluated', context, allow); + report({ outcome: 'SIGNED', context, decision: allow }); + // Los bytes del parámetro original, no una copia que alguien pudo tocar. + return base.signAuthEntry(preimageXdr, opts); + } finally { + if (timer) clearTimeout(timer); + } + }; + + return { address: account, signAuthEntry }; +} diff --git a/packages/sdk/src/x402serve-smoke.test.ts b/packages/sdk/src/x402serve-smoke.test.ts index 5fcda74..22adc63 100644 --- a/packages/sdk/src/x402serve-smoke.test.ts +++ b/packages/sdk/src/x402serve-smoke.test.ts @@ -25,6 +25,11 @@ jest.mock('@x402/stellar/exact/client', () => ({ jest.mock('mppx', () => ({ default: { create: () => ({}) }, })); +// 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'; import { x402Metrics } from './metrics'; 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..c6370ba --- /dev/null +++ b/packages/sdk/test/x402-policy-fetch.test.ts @@ -0,0 +1,200 @@ +/** + * Pre-sign policy hook (#96) - end to end through Agent.x402Fetch. + * + * Runs the built package (dist/, run `npm run build` first) with the real + * @x402/fetch, @x402/core and @x402/stellar ExactStellarScheme. Only the edges + * are simulated, in memory: the merchant's HTTP responses, Soroban RPC + * (latest ledger + simulation) and Horizon ledger times. Keys are random and + * unfunded; nothing is broadcast and no real network is touched. + */ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const S = require('@stellar/stellar-sdk'); +const http = require('@x402/core/http'); +const { USDC_TESTNET_ADDRESS: asset } = require('@x402/stellar'); +const { Agent, X402PolicyError } = require('../dist/index.js'); + +const MERCHANT = 'https://merchant.invalid'; +const payTo = S.Keypair.random().publicKey(); +let nonce = 1000; +let paidRetries = 0; +let 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'; + if (req.headers.has('PAYMENT-SIGNATURE')) { + 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' } }); + } + challenges++; + const requirements = { + x402Version: 2, + resource: { url: req.url, description: 'local fixture', mimeType: 'application/json' }, + accepts: [{ scheme: 'exact', network: 'stellar:testnet', asset, 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' }, + }); +}; + +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() }; + }, + }, + }; +} +const agent = () => new Agent({ apiKey: 'test-placeholder', baseUrl: 'https://backend.invalid' }); +const init = (a: any, cfg: any) => a.initX402({ network: 'stellar:testnet', rpcUrl: 'https://rpc.invalid', ...cfg }); +const paid = (a: any, amount = '499') => a.x402Fetch(`${MERCHANT}/resource?amount=${amount}`); + +beforeEach(() => { paidRetries = 0; 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(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(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(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(paidRetries, 0); + assert.equal(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(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(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(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..acec569 --- /dev/null +++ b/packages/sdk/test/x402-policy.test.ts @@ -0,0 +1,542 @@ +/** + * 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, X402PolicyError, +} from '../src/x402-policy.ts'; +import type { X402PolicyHook, X402PaymentRequirementsView, X402PolicyContext } 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); + }); +}); From 075b17966c9ca5616338e59138b69adb552e4c96 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 19:30:21 -0600 Subject: [PATCH 05/22] docs(sdk): CHANGELOG: x402Serve guard shipped in 0.15.0, not Unreleased The guard entry was still listed under "Unreleased", but nirium 0.15.0 was published on 2026-09-24 and its dist/ contains x402-guard.js (checked against the tarball from the registry). The previous commit also left the entry sitting under the "### Changed" heading it had just added. Move it to its own "0.15.0 - 2026-09-24" section. Only the entry that already existed is moved; no other 0.15.0 content is claimed. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index e92b892..53f7b53 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -13,6 +13,10 @@ All notable changes to the `nirium` package are documented here. - `@stellar/stellar-sdk` ^16.3.0 and `@x402/fetch`/`@x402/stellar`/`@x402/core` ^2.28.0. Node.js >= 22 is now required (both declare `engines.node >=22.0.0`). +## 0.15.0 - 2026-09-24 + +### Added + - `x402Serve()`: optional `guard` config for replay protection and rate limiting, off by default. Providing `guard.store` turns on single-use protection against a replayed payment proof (`X-PAYMENT`/`PAYMENT-SIGNATURE`); adding `guard.rateLimit` also turns on a sliding-window rate limit per caller IP. Fails closed (503) for replay protection and open for rate limiting if the store is unavailable, matching the design already proven in a real production `x402Serve()` deployment. New exports: `X402GuardStore`, `X402GuardConfig`, `createUpstashX402GuardStore()` (a reference Upstash-backed store). See [#91](https://github.com/nirium-protocol/nirium/issues/91). The store interface and this feature's fail-closed/fail-open split are generalized from - and credited to - a real production integrator's own implementation: **Edgadafi/remesa-liquidez** (commit `1e0cbd5902cb224d3c6a2320cc009cf4df84f513`, `backend/src/middleware/paymentGuard.ts` and `backend/src/middleware/rateLimit.ts`). The code in this package is our own, not copied from theirs. From 59714fe01c0b9f58634652d840d55ad3a76ee67b Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 19:31:02 -0600 Subject: [PATCH 06/22] fix(cli): scaffold templates pin nirium ^0.16.0 Under semver 0.x, ^0.15.0 is >=0.15.0 <0.16.0, so it excludes the 0.16 this branch versions the SDK as. Left alone, `nirium create x402` and `nirium create bot` would keep installing 0.15.x and miss the pre-sign policy hook. Both templates in bin/nirium.js updated (checked by running the CLI: `create x402` and `create bot` now write "nirium": "^0.16.0"). Publish order matters: the CLI must not go out before nirium 0.16.0 exists on npm, or a fresh scaffold fails to install (ETARGET). Not touched: the CLI's own dependency on nirium (package.json) and its package-lock.json, which still records nirium@0.14.1 and already fails `npm ci` against ^0.15.0 on main. Co-Authored-By: Claude Sonnet 5.5 --- packages/cli/bin/nirium.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From 60923653f9bae92f2450c3b19e264d5052e8a9a2 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 20:06:00 -0600 Subject: [PATCH 07/22] feat(sdk): x402Fetch says so when core's per-payment cap blocks a payment @x402/core 2.23.0+ refuses any payment above a default $1 cap while selecting requirements, before a payload exists. x402Fetch surfaced that as "Failed to create payment payload: All payment requirements were rejected by spendControls.maxAmountPerPayment ($1, including USDC)": no amount, and nothing saying the signer was never reached. nirium@0.15.0 already behaves this way on fresh installs. x402Fetch now throws X402SpendCapError with the amount asked for (cheapest offer, atomic units and a readable form such as "2 USDC"), the cap as core states it, the asset, network and URL, and signerCalled: false. Both paths (with and without `policy`) do this; with a policy the cap error comes first and the policy is not consulted. - The offered amount is read from the PAYMENT-REQUIRED header of the 402 by a pass-through fetch wrapper built per call, so concurrent calls never mix amounts. No import from @x402/core (not a declared dependency). - Only the top-level USD cap is recognised. Other spendControls rejections (non-default asset, per-asset caps) and unrelated errors are rethrown as they were. - Detection matches core's message. If a future version rewords it, the generic error comes back unchanged and the e2e tests fail on the bump. - The cap is not exposed or changed; initX402 still has no spendControls. - The default path now builds the wrapped fetch per call around one shared client instead of once in initX402. Behaviour without a cap hit is the same (existing e2e tests unchanged apart from sharing fixtures). Tests: 13 new (e2e through Agent.x402Fetch plus matcher units); the e2e fixtures moved to test/helpers/x402-edges.ts so both e2e files share them. Breaking the matcher regex on purpose makes 7 of them fail. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 1 + packages/sdk/README.md | 18 +++ packages/sdk/src/index.ts | 34 ++-- packages/sdk/src/x402-spend-cap.ts | 155 ++++++++++++++++++ packages/sdk/test/helpers/x402-edges.ts | 108 +++++++++++++ packages/sdk/test/x402-policy-fetch.test.ts | 121 ++------------- packages/sdk/test/x402-spend-cap.test.ts | 164 ++++++++++++++++++++ 7 files changed, 481 insertions(+), 120 deletions(-) create mode 100644 packages/sdk/src/x402-spend-cap.ts create mode 100644 packages/sdk/test/helpers/x402-edges.ts create mode 100644 packages/sdk/test/x402-spend-cap.test.ts diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 53f7b53..e365c49 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the `nirium` package are documented here. ### Added - `initX402({ policy })`: 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`. 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. ### Changed diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 869f994..d5be781 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -93,6 +93,24 @@ const response = await agent.x402Fetch('https://nirium-agent.fly.dev/api/v1/prem const data = await response.json(); ``` +#### 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 +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 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. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index da613af..46d6f33 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -18,6 +18,7 @@ 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; @@ -583,7 +584,7 @@ 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; @@ -1190,24 +1191,34 @@ export class Agent { return; } this.x402Policy = null; - const client = new (X402ClientClass as any)().register( - 'stellar:*', - new (ExactStellarScheme as any)(signer, { url: rpcUrl }) - ); - this.x402Client = { fetch: wrapFetchWithPayment(fetch, client) } as any; + 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( @@ -1220,8 +1231,10 @@ export class Agent { () => ({ 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:*', new (ExactStellarScheme as any)(gated, { url: cfg.rpcUrl })) + .register('stellar:*', scheme) .onBeforePaymentCreation(async ({ selectedRequirements: r }: any) => { slot.requirements = { scheme: r.scheme, network: r.network, asset: r.asset, @@ -1231,10 +1244,10 @@ export class Agent { // NUNCA registrar onPaymentCreationFailure aquí: puede devolver un // payload sin pasar por el signer, y el gate quedaría puenteado. try { - return await wrapFetchWithPayment(globalThis.fetch, client)(url, init); + return await wrapFetchWithPayment(captureRequirements(globalThis.fetch, sink), client)(url, init); } catch (e) { // @x402/fetch reenvuelve el error y pierde la clase. - throw slot.error ?? e; + throw slot.error ?? toSpendCapError(e, url, sink.offered, scheme.findDefaultAsset?.bind(scheme)) ?? e; } } @@ -1592,6 +1605,7 @@ export function x402Serve(config: X402ServeConfig): any { // 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, 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/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-policy-fetch.test.ts b/packages/sdk/test/x402-policy-fetch.test.ts index c6370ba..b33347f 100644 --- a/packages/sdk/test/x402-policy-fetch.test.ts +++ b/packages/sdk/test/x402-policy-fetch.test.ts @@ -1,118 +1,19 @@ /** * Pre-sign policy hook (#96) - end to end through Agent.x402Fetch. - * - * Runs the built package (dist/, run `npm run build` first) with the real - * @x402/fetch, @x402/core and @x402/stellar ExactStellarScheme. Only the edges - * are simulated, in memory: the merchant's HTTP responses, Soroban RPC - * (latest ledger + simulation) and Horizon ledger times. Keys are random and - * unfunded; nothing is broadcast and no real network is touched. + * Fixtures: test/helpers/x402-edges.ts. */ import { test, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; -import { createRequire } from 'node:module'; +import { S, MERCHANT, payTo, X402PolicyError, counters, keySigner, agent, init, paid } from './helpers/x402-edges.ts'; -const require = createRequire(import.meta.url); -const S = require('@stellar/stellar-sdk'); -const http = require('@x402/core/http'); -const { USDC_TESTNET_ADDRESS: asset } = require('@x402/stellar'); -const { Agent, X402PolicyError } = require('../dist/index.js'); - -const MERCHANT = 'https://merchant.invalid'; -const payTo = S.Keypair.random().publicKey(); -let nonce = 1000; -let paidRetries = 0; -let 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'; - if (req.headers.has('PAYMENT-SIGNATURE')) { - 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' } }); - } - challenges++; - const requirements = { - x402Version: 2, - resource: { url: req.url, description: 'local fixture', mimeType: 'application/json' }, - accepts: [{ scheme: 'exact', network: 'stellar:testnet', asset, 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' }, - }); -}; - -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() }; - }, - }, - }; -} -const agent = () => new Agent({ apiKey: 'test-placeholder', baseUrl: 'https://backend.invalid' }); -const init = (a: any, cfg: any) => a.initX402({ network: 'stellar:testnet', rpcUrl: 'https://rpc.invalid', ...cfg }); -const paid = (a: any, amount = '499') => a.x402Fetch(`${MERCHANT}/resource?amount=${amount}`); - -beforeEach(() => { paidRetries = 0; challenges = 0; }); +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(paidRetries, 1); + assert.equal(counters.paidRetries, 1); }); test('ALLOW bound to the authorization pays; the engine saw URL, method and amount', async () => { @@ -122,7 +23,7 @@ test('ALLOW bound to the authorization pays; the engine saw URL, method and amou 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(paidRetries, 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'); @@ -136,7 +37,7 @@ test('ALLOW also gates a raw secretKey signer', async () => { 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(paidRetries, 1); + assert.equal(counters.paidRetries, 1); }); test('DENY: zero signer calls, no paid retry, X402PolicyError class survives @x402/fetch', async () => { @@ -147,8 +48,8 @@ test('DENY: zero signer calls, no paid retry, X402PolicyError class survives @x4 assert.ok(err instanceof X402PolicyError, String(err)); assert.equal(err.outcome, 'DENY'); assert.equal(calls.length, 0); - assert.equal(paidRetries, 0); - assert.equal(challenges, 1); + 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 () => { @@ -163,7 +64,7 @@ test('stale ALLOW (policy revised before signing): STALE, zero signer calls, no assert.ok(err instanceof X402PolicyError, String(err)); assert.equal(err.outcome, 'STALE'); assert.equal(calls.length, 0); - assert.equal(paidRetries, 0); + assert.equal(counters.paidRetries, 0); }); test('signer identity change during evaluation: SIGNER_CHANGED, zero signer calls', async () => { @@ -173,7 +74,7 @@ test('signer identity change during evaluation: SIGNER_CHANGED, zero signer call const err = await paid(a).catch((e: any) => e); assert.equal(err.outcome, 'SIGNER_CHANGED'); assert.equal(calls.length, 0); - assert.equal(paidRetries, 0); + assert.equal(counters.paidRetries, 0); }); // Garantía que da stellar-sdk >= 16 (authorizeEntry verifica la firma contra @@ -189,7 +90,7 @@ test('a signature over altered bytes never reaches the merchant (stellar-sdk che 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(paidRetries, 0); + assert.equal(counters.paidRetries, 0); }); test('initX402 rejects a malformed policy before any payment', () => { 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' }], + ); +}); From 735ffd4c3846d4d43fe7dfc1012e026ba1c34392 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 20:19:03 -0600 Subject: [PATCH 08/22] chore(sdk): declare engines.node >=22 The dependencies this branch moves to already require it: @stellar/stellar-sdk 16.3.0 and @x402/stellar 2.28.0 both declare engines.node >=22.0.0, and @stellar/mpp 0.7.1 did before that. The README says Node >= 22, but nirium's own package.json did not, so npm gave no warning attributed to nirium itself. Declare it before 0.16.0 is published; adding it afterwards would cost a 0.16.1. Checked by installing the packed tarball on Node 20.19.6: npm now warns EBADENGINE for nirium@0.16.0 itself. It stays a warning, not an error, unless the installer sets engine-strict. The lockfile changes only by the engines block on its root package entry. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/package-lock.json | 3 +++ packages/sdk/package.json | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index e365c49..54bb48f 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to the `nirium` package are documented here. ### Changed -- `@stellar/stellar-sdk` ^16.3.0 and `@x402/fetch`/`@x402/stellar`/`@x402/core` ^2.28.0. Node.js >= 22 is now required (both declare `engines.node >=22.0.0`). +- `@stellar/stellar-sdk` ^16.3.0 and `@x402/fetch`/`@x402/stellar`/`@x402/core` ^2.28.0. Node.js >= 22 is now required: both declare `engines.node >=22.0.0`, and `nirium` itself now declares `engines.node >=22` so npm warns up front. ## 0.15.0 - 2026-09-24 diff --git a/packages/sdk/package-lock.json b/packages/sdk/package-lock.json index 8a4c2dc..004698e 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -25,6 +25,9 @@ "jest": "^30.4.2", "ts-jest": "^29.4.12", "typescript": "^5.9.3" + }, + "engines": { + "node": ">=22" } }, "node_modules/@adraffy/ens-normalize": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index caa7ecf..f765188 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -22,6 +22,9 @@ "dist", "README.md" ], + "engines": { + "node": ">=22" + }, "scripts": { "build": "tsc", "prepublishOnly": "npm run build", From 5f1c68a5e8da10c3a3bcab98ecf70b2907be8eee Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:04:34 -0600 Subject: [PATCH 09/22] fix(sdk): policy gate refuses when the clock is not a finite number now() returning NaN (or undefined, null, a numeric string) made every comparison in the gate false: `t >= deadline` and `t >= expiresAt` never held, so the deadline could not be reached and even an already-expired ALLOW was signed. Measured against 735ffd4: NaN, undefined, null and '123' all produced one signer call, with and without expiresAt. Infinity already closed, through TIMEOUT. Every clock read now goes through one function that requires a finite number and otherwise refuses with a new CLOCK_INVALID outcome, before the policy is consulted when it is the first read. A clock that throws is refused the same way instead of escaping as a raw error. initX402 also rejects a `policy.now` that is not a function. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/src/x402-policy.ts | 28 +++++++-- packages/sdk/test/x402-policy.test.ts | 89 ++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 6 deletions(-) diff --git a/packages/sdk/src/x402-policy.ts b/packages/sdk/src/x402-policy.ts index d37d732..88b5342 100644 --- a/packages/sdk/src/x402-policy.ts +++ b/packages/sdk/src/x402-policy.ts @@ -69,7 +69,7 @@ export interface X402PolicyDecision { export type X402PolicyOutcome = | 'SIGNED' | 'DENY' | 'WAIT' | 'TIMEOUT' | 'ENGINE_ERROR' | 'EXPIRED' | 'UNBOUND' | 'MALFORMED' | 'CONTEXT_MISMATCH' - | 'STALE' | 'VERSION_UNAVAILABLE' | 'SIGNER_CHANGED'; + | 'STALE' | 'VERSION_UNAVAILABLE' | 'SIGNER_CHANGED' | 'CLOCK_INVALID'; export interface X402PolicyRecord { outcome: X402PolicyOutcome; @@ -110,7 +110,12 @@ export interface X402PolicyHook { requireExpiry?: boolean; /** Observer for receipts. Cannot change the outcome; its errors are swallowed. */ onDecision?: (record: X402PolicyRecord) => void; - /** Injectable clock (epoch ms), for tests. */ + /** + * 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; } @@ -159,6 +164,9 @@ export function assertValidPolicyHook(policy: X402PolicyHook): void { && (!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.'); + } } /** @@ -269,6 +277,18 @@ export function createPolicyGatedSigner( // 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 { policy.onDecision?.(r); } catch { /* un observador no decide nada */ } }; @@ -306,7 +326,7 @@ export function createPolicyGatedSigner( 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 = now() + timeoutMs; + const deadline = readClock(context) + timeoutMs; let timer: ReturnType | undefined; const TIMED_OUT = Symbol('timeout'); const timeout = new Promise((_, reject) => { @@ -343,7 +363,7 @@ export function createPolicyGatedSigner( return refuse('EXPIRED', 'ALLOW without expiresAt while requireExpiry is set', context, allow); } const checkClock = () => { - const t = now(); + 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); }; diff --git a/packages/sdk/test/x402-policy.test.ts b/packages/sdk/test/x402-policy.test.ts index acec569..f1d63fa 100644 --- a/packages/sdk/test/x402-policy.test.ts +++ b/packages/sdk/test/x402-policy.test.ts @@ -12,9 +12,11 @@ import { Keypair, Networks, xdr, Address, nativeToScVal, authorizeEntry, hash, } from '@stellar/stellar-sdk'; import { - createPolicyGatedSigner, effectiveTimeoutMs, X402PolicyError, + createPolicyGatedSigner, effectiveTimeoutMs, assertValidPolicyHook, X402PolicyError, +} from '../src/x402-policy.ts'; +import type { + X402PolicyHook, X402PaymentRequirementsView, X402PolicyContext, X402PolicyRecord, } from '../src/x402-policy.ts'; -import type { X402PolicyHook, X402PaymentRequirementsView, X402PolicyContext } from '../src/x402-policy.ts'; const payer = Keypair.random(); const merchant = Keypair.random().publicKey(); @@ -540,3 +542,86 @@ describe('timeout budget', () => { 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 })); + }); +}); From 8ebbdf3b007dff3023d1bf5b2f46c9998f698bb9 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:05:38 -0600 Subject: [PATCH 10/22] fix(sdk): policy receipts say SIGNED only after the signer returned a signature SIGNED was reported just before base.signAuthEntry was called, so a signer that rejected, threw synchronously or returned something unusable still left a SIGNED receipt behind. Anyone counting SIGNED as "a signature exists" over-counted. The gate now reports ALLOWED when it hands the authorization to the signer (a notice, not a result) and then exactly one of: - SIGNED, after the signer resolved with a non-empty signedAuthEntry; - SIGNER_ERROR, when the signer rejected or threw. Its own error is rethrown untouched, so a signer failure is still not mistaken for a policy refusal and nothing about retries changes; - SIGNER_ERROR as a refusal (X402PolicyError, onRefused called), when the signer resolved without a usable signedAuthEntry. The pre-signature timer is cleared before the signer runs, as it effectively was before. X402PolicyRefusal names the outcomes that mean no signature was released. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/src/x402-policy.ts | 45 +++++++++++--- packages/sdk/test/x402-policy.test.ts | 87 +++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/packages/sdk/src/x402-policy.ts b/packages/sdk/src/x402-policy.ts index 88b5342..7caa586 100644 --- a/packages/sdk/src/x402-policy.ts +++ b/packages/sdk/src/x402-policy.ts @@ -67,10 +67,18 @@ export interface X402PolicyDecision { } export type X402PolicyOutcome = - | 'SIGNED' | 'DENY' | 'WAIT' | 'TIMEOUT' | 'ENGINE_ERROR' + | '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 was accepted and the signer is about to be + * called; it is a notice, not a result. `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. + * A payment that reached the signer has an `ALLOWED` record followed by + * exactly one of `SIGNED` or `SIGNER_ERROR`. + */ export interface X402PolicyRecord { outcome: X402PolicyOutcome; context?: X402PolicyContext; @@ -108,7 +116,11 @@ export interface X402PolicyHook { timeoutMs?: number; /** If true, an ALLOW without expiresAt is treated as unsignable. Default false. */ requireExpiry?: boolean; - /** Observer for receipts. Cannot change the outcome; its errors are swallowed. */ + /** + * Observer for receipts (see X402PolicyRecord for the sequence). Cannot + * change the outcome; a synchronous throw is swallowed. A callback that + * returns a promise is not awaited and its rejection is NOT handled here. + */ onDecision?: (record: X402PolicyRecord) => void; /** * Injectable clock (epoch ms), for tests. It must return a finite number @@ -119,12 +131,15 @@ export interface X402PolicyHook { now?: () => number; } +/** Outcomes that mean no signature was released (the payment is refused). */ +export type X402PolicyRefusal = Exclude; + export class X402PolicyError extends Error { - readonly outcome: Exclude; + readonly outcome: X402PolicyRefusal; readonly decision?: X402PolicyDecision; readonly contextHash?: string; constructor( - outcome: Exclude, + outcome: X402PolicyRefusal, message: string, extra: { decision?: X402PolicyDecision; contextHash?: string } = {}, ) { @@ -293,7 +308,7 @@ export function createPolicyGatedSigner( try { policy.onDecision?.(r); } catch { /* un observador no decide nada */ } }; const refuse = ( - outcome: Exclude, msg: string, + outcome: X402PolicyRefusal, msg: string, context?: X402PolicyContext, decision?: X402PolicyDecision, ): never => { report({ outcome, context, decision, error: msg }); @@ -393,9 +408,25 @@ export function createPolicyGatedSigner( // Último chequeo antes de firmar, sin ningún await en medio. 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); + report({ outcome: 'ALLOWED', context, decision: allow }); + // 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 }); - // Los bytes del parámetro original, no una copia que alguien pudo tocar. - return base.signAuthEntry(preimageXdr, opts); + return signed; } finally { if (timer) clearTimeout(timer); } diff --git a/packages/sdk/test/x402-policy.test.ts b/packages/sdk/test/x402-policy.test.ts index f1d63fa..8fd8d4c 100644 --- a/packages/sdk/test/x402-policy.test.ts +++ b/packages/sdk/test/x402-policy.test.ts @@ -625,3 +625,90 @@ describe('clock (fails closed when it is not a finite number)', () => { 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); + } + }); +}); + From e84bcb354563c853f3d74b5a3ce0b8166239a657 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:06:36 -0600 Subject: [PATCH 11/22] fix(sdk): policy observer runs before the final freshness and identity checks The onDecision observer ran after the last clock and identity checks and immediately before the signer. A synchronous callback could advance the injected clock past expiresAt, change the policy version or change the base signer's address in that gap and nothing would notice, because nothing was checked afterwards. The ALLOWED notice, and so the observer, now runs before the version read. The clock is read again and the signer's address compared once more after it, always (not only when currentVersion is configured), with no await before the signer: the only code that can run between the last check and the signer is none. What an observer, evaluate or currentVersion changes in the clock, the version source or the signer's address is now refused. A slow synchronous observer that eats the deadline is refused with TIMEOUT. A malformed ALLOW (no policyVersion while currentVersion is set) is now refused before ALLOWED is reported, so ALLOWED always means "well formed, bound and not expired yet". Not covered, and documented in the code: replacing base.signAuthEntry itself, which is looked up at call time, and any other code in the same process holding the base signer. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/src/x402-policy.ts | 46 ++++++++---- packages/sdk/test/x402-policy.test.ts | 100 ++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 14 deletions(-) diff --git a/packages/sdk/src/x402-policy.ts b/packages/sdk/src/x402-policy.ts index 7caa586..46643c1 100644 --- a/packages/sdk/src/x402-policy.ts +++ b/packages/sdk/src/x402-policy.ts @@ -72,12 +72,17 @@ export type X402PolicyOutcome = | 'STALE' | 'VERSION_UNAVAILABLE' | 'SIGNER_CHANGED' | 'CLOCK_INVALID'; /** - * `ALLOWED`: the engine's ALLOW was accepted and the signer is about to be - * called; it is a notice, not a result. `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. - * A payment that reached the signer has an `ALLOWED` record followed by - * exactly one of `SIGNED` or `SIGNER_ERROR`. + * `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; @@ -107,6 +112,8 @@ export interface X402PolicyHook { * 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; /** @@ -280,7 +287,11 @@ export function effectiveTimeoutMs(policy: X402PolicyHook, maxTimeoutSeconds?: n * @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. + * 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, @@ -382,12 +393,19 @@ export function createPolicyGatedSigner( 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) { - if (!nonempty(allow.policyVersion)) { - return refuse('MALFORMED', 'ALLOW has no policyVersion to check against currentVersion', context, allow); - } let current: unknown; try { current = await Promise.race([ @@ -402,16 +420,16 @@ export function createPolicyGatedSigner( if (current !== allow.policyVersion) { return refuse('STALE', `ALLOW was issued under policy ${allow.policyVersion}, current is ${current}`, context, allow); } - // El reloj se vuelve a leer después del await. - checkClock(); } - // Último chequeo antes de firmar, sin ningún await en medio. + // 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); - report({ outcome: 'ALLOWED', context, decision: allow }); // 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>; diff --git a/packages/sdk/test/x402-policy.test.ts b/packages/sdk/test/x402-policy.test.ts index 8fd8d4c..f81e30f 100644 --- a/packages/sdk/test/x402-policy.test.ts +++ b/packages/sdk/test/x402-policy.test.ts @@ -712,3 +712,103 @@ describe('receipts: SIGNED only exists once the signer returned a signature', () }); }); +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); + }); +}); + From caaf23cc328b642b725dea01d1b762b79ef6f3a5 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:06:55 -0600 Subject: [PATCH 12/22] docs(sdk): describe the policy receipts, the clock rule and the observer order README and CHANGELOG now say what the three previous commits changed: CLOCK_INVALID, ALLOWED then SIGNED or SIGNER_ERROR (SIGNED means a signature exists), the observer running before the final checks, and the limits that remain: a callback returning a promise is not awaited, and replacing base.signAuthEntry is not detected. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 54bb48f..859094f 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the `nirium` package are documented here. ### Added -- `initX402({ policy })`: 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`. Without `policy`, behavior is unchanged. New exports: `X402PolicyError` and the `X402Policy*` types. +- `initX402({ policy })`: 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. 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. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index d5be781..21c36ce 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -140,15 +140,17 @@ try { } ``` -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`, or a signer whose address changed while the policy was being asked. Without `policy`, `x402Fetch()` behaves exactly as before. +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 that gets past the policy produces an `ALLOWED` record, which is a notice and not a result, and then exactly one of `SIGNED` (the signer returned a signature) or `SIGNER_ERROR` (it rejected, threw, or returned no `signedAuthEntry`; its own error is rethrown untouched). If the freshness or identity checks that follow `ALLOWED` refuse, the record after it is that refusal (`STALE`, `EXPIRED`, `TIMEOUT`, `SIGNER_CHANGED`, ...). `SIGNED` therefore means a signature exists. The observer cannot change the outcome and a synchronous throw from it is swallowed; a callback that returns a promise is not awaited and its rejection is not handled. 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 does not inspect the signature the signer returns. 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. +**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, and nothing on-chain enforces the policy. It does not reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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. +**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 (so can code that replaces its `signAuthEntry`, which is looked up at call time), and nothing on-chain enforces the policy. It does not reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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 — Session-Based Budget Delegation ```typescript From 1dc596932b0b124ad39bb54c4cb00b10c3b08e5e Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:11:15 -0600 Subject: [PATCH 13/22] fix(sdk): policy observer that returns a promise cannot cause an unhandled rejection onDecision is typed as returning void, but an async function satisfies that type. When such an observer rejected, the rejection escaped the try/catch around the call (which only sees synchronous throws) and became an unhandledRejection, which ends the process by default on Node 22. If the observer returns an object or function (a promise or any thenable), the gate now attaches a no-op rejection handler through Promise.resolve and does not wait for it, so a payment is never delayed or blocked by an observer, and a rejection, a throwing then() or a throwing then getter are all swallowed. A synchronous throw is swallowed exactly as before. README: ALLOWED does not mean every check has passed; it is a notice sent before the version read and the final checks, and a refusal can follow it. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 2 +- packages/sdk/src/x402-policy.ts | 15 ++++- packages/sdk/test/x402-policy.test.ts | 94 +++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 5 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 859094f..62be0fb 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the `nirium` package are documented here. ### Added -- `initX402({ policy })`: 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. Without `policy`, behavior is unchanged. New exports: `X402PolicyError` and the `X402Policy*` types. +- `initX402({ policy })`: 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. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 21c36ce..176f77f 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -144,7 +144,7 @@ Only an `ALLOW` that echoes this authorization's `contextHash`, and is still val **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 that gets past the policy produces an `ALLOWED` record, which is a notice and not a result, and then exactly one of `SIGNED` (the signer returned a signature) or `SIGNER_ERROR` (it rejected, threw, or returned no `signedAuthEntry`; its own error is rethrown untouched). If the freshness or identity checks that follow `ALLOWED` refuse, the record after it is that refusal (`STALE`, `EXPIRED`, `TIMEOUT`, `SIGNER_CHANGED`, ...). `SIGNED` therefore means a signature exists. The observer cannot change the outcome and a synchronous throw from it is swallowed; a callback that returns a promise is not awaited and its rejection is not handled. It runs before the final clock and identity checks, so what it changes there is detected. +**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. diff --git a/packages/sdk/src/x402-policy.ts b/packages/sdk/src/x402-policy.ts index 46643c1..7f575cd 100644 --- a/packages/sdk/src/x402-policy.ts +++ b/packages/sdk/src/x402-policy.ts @@ -125,8 +125,10 @@ export interface X402PolicyHook { requireExpiry?: boolean; /** * Observer for receipts (see X402PolicyRecord for the sequence). Cannot - * change the outcome; a synchronous throw is swallowed. A callback that - * returns a promise is not awaited and its rejection is NOT handled here. + * 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; /** @@ -316,7 +318,14 @@ export function createPolicyGatedSigner( return t; }; const report = (r: X402PolicyRecord) => { - try { policy.onDecision?.(r); } catch { /* un observador no decide nada */ } + 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, diff --git a/packages/sdk/test/x402-policy.test.ts b/packages/sdk/test/x402-policy.test.ts index f81e30f..f775f7d 100644 --- a/packages/sdk/test/x402-policy.test.ts +++ b/packages/sdk/test/x402-policy.test.ts @@ -812,3 +812,97 @@ describe('observer runs before the final checks (what it provokes is detected)', }); }); +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); + }); +}); + From 27db4803912bf1ebc8192f1e9e4e905ab05ef164 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 21:29:04 -0600 Subject: [PATCH 14/22] chore(sdk): require Node.js >= 22.12.0, not 22 stellar-sdk 16 depends on @noble/hashes 2.x, which is ESM-only. nirium is published as CommonJS, so loading it relies on require() of an ES module, which works without a flag only from Node 22.12.0. Measured with the packed tarball installed in a clean project: Node 22.11.0: require('nirium') throws ERR_REQUIRE_ESM, and npm printed no EBADENGINE warning because the declared range was >=22 Node 22.12.0: loads Node 22.18.0, 24.20.0, 20.19.6: load The published 0.15.0 fails the same way on 22.11.0, so this is not new, but the range declared one commit earlier was too permissive. Raises engines.node in package.json and in the lockfile's root entry (the only lockfile change), and corrects the README and CHANGELOG, which said the floor came from the dependencies' own >=22.0.0. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 2 +- packages/sdk/package-lock.json | 2 +- packages/sdk/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 62be0fb..1735078 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to the `nirium` package are documented here. ### Changed -- `@stellar/stellar-sdk` ^16.3.0 and `@x402/fetch`/`@x402/stellar`/`@x402/core` ^2.28.0. Node.js >= 22 is now required: both declare `engines.node >=22.0.0`, and `nirium` itself now declares `engines.node >=22` so npm warns up front. +- `@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 diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 176f77f..28bc1ea 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -331,7 +331,7 @@ Anchor a **hash** rather than the data itself: IPFS content cannot be deleted, s ## Requirements -- Node.js >= 22 (required by `@stellar/stellar-sdk` 16 and `@x402/stellar` 2.28, both declare `engines.node >=22.0.0`) +- 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 004698e..d053641 100644 --- a/packages/sdk/package-lock.json +++ b/packages/sdk/package-lock.json @@ -27,7 +27,7 @@ "typescript": "^5.9.3" }, "engines": { - "node": ">=22" + "node": ">=22.12.0" } }, "node_modules/@adraffy/ens-normalize": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f765188..f07b6a9 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -23,7 +23,7 @@ "README.md" ], "engines": { - "node": ">=22" + "node": ">=22.12.0" }, "scripts": { "build": "tsc", From 61ce4e0cd60a7c163365d683103785c52fefabb4 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 21:29:29 -0600 Subject: [PATCH 15/22] fix(sdk): initMpp() builds the MPP client the way mppx documents In 0.15.0, initMpp() threw "TypeError: Mppx.create is not a function" (reproduced from a clean install of the published package). It imported the root of mppx, where there is no Mppx, and passed a configuration shape the library does not take. The agent's own MPP buyer and the MCP server already use the right API. Now: Mppx from mppx/client, stellar.charge() from @stellar/mpp/charge/client, and polyfill: false. Without polyfill: false, mppx replaces globalThis.fetch and would also intercept the 402s that x402Fetch handles. MppConfig.network is still accepted but unused: the network comes from the server's challenge. Checked end to end with Agent.initMpp()/mppFetch() against the agent's real compiled MPP middleware on Express (testnet), in pull and in push mode: 200, receipt, content delivered. This does NOT show that Nirium's hosted endpoints accept MPP payments: they currently reject them. The README says so. Tests: 4 offline cases (2 fail on the old code). x402serve-smoke.test.ts mocks the two new imports; without that jest drops from 56 to 45 tests and npm test stops before the Node runner. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 4 +++ packages/sdk/src/index.ts | 22 +++++++-------- packages/sdk/src/x402serve-smoke.test.ts | 7 +++-- packages/sdk/test/x402-mpp-init.test.ts | 36 ++++++++++++++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) create mode 100644 packages/sdk/test/x402-mpp-init.test.ts diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 1735078..8d41fe3 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -10,6 +10,10 @@ All notable changes to the `nirium` package are documented here. - `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, which currently reject the payment; 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`). diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 46d6f33..796bcf2 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,7 +12,10 @@ 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, @@ -1265,17 +1268,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; } /** diff --git a/packages/sdk/src/x402serve-smoke.test.ts b/packages/sdk/src/x402serve-smoke.test.ts index 22adc63..bfcd966 100644 --- a/packages/sdk/src/x402serve-smoke.test.ts +++ b/packages/sdk/src/x402serve-smoke.test.ts @@ -22,8 +22,11 @@ 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. 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/); +}); From 3eeca501334b27456ea82e993e4c6b87d4a4b8b9 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 21:30:53 -0600 Subject: [PATCH 16/22] docs(sdk): say plainly that MPP Charge is experimental The README described MPP as a working payment option ("Session-Based Budget Delegation", an example pointed at Nirium's hosted endpoint, a row in the endpoint table). Three things were wrong with that: - The title describes Channel mode, which is switched off. Only Charge is live. - initMpp() threw in 0.15.0 (fixed in the previous commit). - Nirium's hosted /api/v1/mpp/* endpoints reject MPP payments with 402 Verification Failed. Reproduced on testnet in pull and in push mode; the same client, library versions and the agent's own compiled middleware verify and deliver locally, so the cause is in the hosted environment and is not found yet. Mainnet was not tested (it would spend real USDC). In push mode the payment settles on-chain before the server verifies it. The new section says what the client does, that it works against a compliant server, that the hosted endpoints currently reject payments (testnet reproduced, mainnet untested), that a push-mode payment is not refunded, and to use x402 meanwhile. It does not call MPP supported. The intro line, the method table, the endpoint table and the initMpp() JSDoc (which ships in the .d.ts) say the same. The package.json description still says "(x402 + MPP)" and is left for a separate decision. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/README.md | 27 ++++++++++++++++----------- packages/sdk/src/index.ts | 8 +++++++- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 28bc1ea..690153c 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 @@ -152,18 +152,23 @@ Only an `ALLOW` that echoes this authorization's `contextHash`, and is still val **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 (so can code that replaces its `signAuthEntry`, which is looked up at call time), and nothing on-chain enforces the policy. It does not reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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 — Session-Based Budget Delegation +### MPP Charge (experimental) + ```typescript -agent.initMpp({ - secretKey: 'S...', - network: 'stellar:testnet', - mode: 'pull', -}); +agent.initMpp({ secretKey: 'S...', mode: 'pull' }); // 'pull' (default) or 'push' -const response = await agent.mppFetch('https://nirium-agent.fly.dev/api/v1/mpp/signals'); -const data = await response.json(); +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. +- Nirium's hosted `/api/v1/mpp/*` endpoints currently reject MPP payments with `402 Verification Failed`, in `pull` and in `push` mode. We reproduced this on the testnet endpoint; we have not tested the mainnet one and you should expect the same. 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. @@ -194,7 +199,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` (hosted endpoints currently reject MPP payments, see [MPP Charge](#mpp-charge-experimental)) | ### x402 Metrics — Observability Wrapper diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 796bcf2..153a8ac 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -12,7 +12,7 @@ import { x402Client as X402ClientClass, wrapFetchWithPayment } from '@x402/fetch import { createEd25519Signer } from '@x402/stellar'; // @ts-ignore import { ExactStellarScheme } from '@x402/stellar/exact/client'; -// @ts-ignore — ESM subpath imports (mismo patrón que los de @x402 de arriba) +// @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'; @@ -1259,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 `/api/v1/mpp/*` endpoints currently reject MPP payments + * (`402 Verification Failed`), and in `push` mode the payment has already + * settled on-chain when that happens. Use `initX402()` for paid endpoints + * until this is resolved. See the README. * In pull mode, the server assembles and broadcasts the transaction. * * @example From 0ea697df25e8cd894bf32dd7bef6ee86417e256a Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 21:31:07 -0600 Subject: [PATCH 17/22] docs(sdk-python): document that mpp_fetch() cannot pay an MPP server Checked with the published wheel (nirium 0.11.0, installed into an isolated directory): init_mpp() succeeds and mpp_fetch() against a live MPP Charge endpoint raises "ValueError: This is not a valid account:" before anything is sent. The implementation reads the challenge from the 402 JSON body (MPP puts it in the WWW-Authenticate header), builds a classic payment instead of a Soroban SAC transfer, and sends X-PAYMENT instead of Authorization: Payment, so it could not have worked against any MPP server. Documentation only, as decided: the section titled "Session-Based Budget Delegation" (which also names Channel mode, switched off) becomes a plain statement that it does not work in this version and to use x402. The code is not touched and the version is not bumped; the README ships to PyPI only with a Python release. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk-python/README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/sdk-python/README.md b/packages/sdk-python/README.md index 51bcd70..4dd4a56 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 `/api/v1/mpp/*` endpoints also currently reject MPP payments, so do not rely on them 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` (hosted endpoints currently reject MPP payments, 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 From 0001f4a7d5da10fbbbde068832a5426473faf1ed Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 22:07:27 -0600 Subject: [PATCH 18/22] docs(sdk): state exactly what was tested for MPP, and that mainnet was not The MPP notes said the hosted endpoints "currently reject" payments. What was actually checked: the hosted testnet endpoint rejected MPP payments with 402 Verification Failed in pull and push mode when tested on 2026-10-01. The mainnet endpoint was not tested, because a push-mode attempt spends real USDC. "Currently" also goes stale the moment the server is changed. Rewords the TypeScript README (section and endpoint table), the initMpp() JSDoc that ships in the .d.ts, the Python README and the CHANGELOG entry so each says: tested on testnet on a date, mainnet not tested, cause not found. Earlier text also said to "expect the same" on mainnet; that was a guess and is removed. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk-python/README.md | 4 ++-- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 4 ++-- packages/sdk/src/index.ts | 8 ++++---- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/sdk-python/README.md b/packages/sdk-python/README.md index 4dd4a56..22c66ee 100644 --- a/packages/sdk-python/README.md +++ b/packages/sdk-python/README.md @@ -140,7 +140,7 @@ Runnable example: [`examples/langchain-x402-agent`](../../examples/langchain-x40 - 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 `/api/v1/mpp/*` endpoints also currently reject MPP payments, so do not rely on them from any language. +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 @@ This is not fixed in the version published today. Use x402 (`init_x402()` / `x40 | **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 Charge** | `/api/v1/mpp/signals`, `/api/v1/mpp/market` (hosted endpoints currently reject MPP payments, and `mpp_fetch()` does not work in this version) | +| **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 diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 8d41fe3..3cfa97b 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to the `nirium` package are documented here. ### 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, which currently reject the payment; see the README. +- `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 diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 690153c..309e207 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -165,7 +165,7 @@ const response = await agent.mppFetch('https://your-mpp-server.example/resource' **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. -- Nirium's hosted `/api/v1/mpp/*` endpoints currently reject MPP payments with `402 Verification Failed`, in `pull` and in `push` mode. We reproduced this on the testnet endpoint; we have not tested the mainnet one and you should expect the same. We have not found the cause yet. +- 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. @@ -199,7 +199,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 Charge** | `/api/v1/mpp/signals`, `/api/v1/mpp/market` (hosted endpoints currently reject MPP payments, see [MPP Charge](#mpp-charge-experimental)) | +| **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 diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 153a8ac..67b30c7 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -1261,10 +1261,10 @@ export class Agent { * Uses canonical @stellar/mpp charge mode with mppx. * * EXPERIMENTAL. The client works against a compliant MPP Charge server, but - * Nirium's hosted `/api/v1/mpp/*` endpoints currently reject MPP payments - * (`402 Verification Failed`), and in `push` mode the payment has already - * settled on-chain when that happens. Use `initX402()` for paid endpoints - * until this is resolved. See the README. + * 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 From 849ab9dd9be18456c754ffcf7df3d9914ed8eafb Mon Sep 17 00:00:00 2001 From: Eras256 Date: Wed, 30 Sep 2026 22:07:37 -0600 Subject: [PATCH 19/22] chore(sdk): date the 0.16.0 changelog and drop MPP from the package description The npm package page shows package.json's description. It said "(x402 + MPP)", which reads as two working payment rails; MPP Charge is an experimental client that Nirium's hosted endpoints did not accept when tested. The description now says x402. The "mpp" keyword stays: it names the protocol the client speaks and is how someone looking for that client finds it. CHANGELOG: "Unreleased" becomes "0.16.0 - 2026-10-01". The date is the day the release was prepared; change it if the publish happens on another day. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 3cfa97b..465c61d 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the `nirium` package are documented here. -## Unreleased +## 0.16.0 - 2026-10-01 ### Added diff --git a/packages/sdk/package.json b/packages/sdk/package.json index f07b6a9..7119a01 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "nirium", "version": "0.16.0", - "description": "Autonomous treasury and agentic-payments infrastructure for Stellar (x402 + MPP)", + "description": "Autonomous treasury and agentic-payments infrastructure for Stellar (x402)", "main": "dist/index.js", "types": "dist/index.d.ts", "homepage": "https://nirium.xyz", From 04e406435badbd6d7bf6047c5757d84eed1cfa47 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:49:15 -0600 Subject: [PATCH 20/22] docs(sdk): mark the pre-sign policy hook as experimental and state its scope The hook is new in 0.16.0 and was documented as if its shape were settled. The README section is now headed "(experimental)", says the shape of policy, the outcomes and the onDecision records can still change in a minor release before 1.0.0, and says the scope is the agent-side cases that the hook's tracking issue groups as G1, and nothing beyond. The same-process limit is labelled L02, as in that discussion. The CHANGELOG entry says experimental too. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- packages/sdk/README.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index 465c61d..b743379 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the `nirium` package are documented here. ### Added -- `initX402({ policy })`: 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. +- `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. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 309e207..770a50a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -111,10 +111,12 @@ try { 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 +#### 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 capacity across concurrent payments. + ```typescript import { X402PolicyError } from 'nirium'; @@ -150,7 +152,7 @@ Only an `ALLOW` that echoes this authorization's `contextHash`, and is still val **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 (so can code that replaces its `signAuthEntry`, which is looked up at call time), and nothing on-chain enforces the policy. It does not reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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. +**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 reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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) From 77cb87f5c4c723bc317e5f1b0632e62d852e3cf5 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:31:06 -0600 Subject: [PATCH 21/22] docs(sdk): label the capacity limit of the policy hook L01, like L02 The README named the same-process limit L02 but described the other known limit without a label. It is now L01, pending-capacity reservation: concurrent requests that observe the same remaining capacity. It is stated as not solved, in both places where the README lists what the hook does not cover. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 770a50a..54df07c 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -115,7 +115,7 @@ The error carries `url`, `amount` (atomic units, the cheapest offer when the ser 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 capacity across concurrent payments. +**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'; @@ -152,7 +152,7 @@ Only an `ALLOW` that echoes this authorization's `contextHash`, and is still val **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 reserve capacity across concurrent payments: two calls evaluated at the same time can each fit a limit that together they exceed; 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. +**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) From cf1e721b84d7f35194051e547c814fec0fb496b8 Mon Sep 17 00:00:00 2001 From: Eras256 Date: Fri, 2 Oct 2026 14:43:25 -0600 Subject: [PATCH 22/22] docs(sdk): date the 0.16.0 changelog 2026-10-02 The entry carried 2026-10-01, the day the release was prepared. The publication day is 2026-10-02. Only the date line changes; CHANGELOG.md is not part of the packed tarball, so the package contents are unaffected. Co-Authored-By: Claude Sonnet 5.5 --- packages/sdk/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index b743379..c41ed21 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to the `nirium` package are documented here. -## 0.16.0 - 2026-10-01 +## 0.16.0 - 2026-10-02 ### Added