From 226dfd91d8140c5fa8f5c6aa482220547173d1f3 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sun, 19 Apr 2026 23:53:55 -0700 Subject: [PATCH 1/7] chore(demo-api): bump s402 to ^0.6.0 and wire Accept-Payment negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the current protocol version and exercises the Accept-Payment header round-trip (parse incoming preferences → selectBestScheme → advertise server-side support back via formatAcceptPayment) that landed in the core package. Also expands CORS allow/expose lists to cover the accept-payment header name. Typecheck clean. --- demo-api/package.json | 2 +- demo-api/pnpm-lock.yaml | 18 ++--- demo-api/src/server.ts | 25 ++++++- pnpm-lock.yaml | 159 +++++++++++++++++++++++++++++++++------- 4 files changed, 166 insertions(+), 38 deletions(-) diff --git a/demo-api/package.json b/demo-api/package.json index 74d011d..50a41f1 100644 --- a/demo-api/package.json +++ b/demo-api/package.json @@ -11,7 +11,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "s402": "^0.2.3" + "s402": "^0.6.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/demo-api/pnpm-lock.yaml b/demo-api/pnpm-lock.yaml index 5136786..82155a1 100644 --- a/demo-api/pnpm-lock.yaml +++ b/demo-api/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: s402: - specifier: ^0.2.3 - version: 0.2.3 + specifier: ^0.6.0 + version: 0.6.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -193,14 +193,14 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] - get-tsconfig@4.13.7: - resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - s402@0.2.3: - resolution: {integrity: sha512-dG7ysemOZH9avcK6/Evt577JDBWIyJMo6rndA3LN94lelpAsjGqNX43JPsUXt+qrhpc2NIeuu2zSeREfIqrvjg==} + s402@0.6.0: + resolution: {integrity: sha512-81h+tXhHO/OPkwMxHGpKJhAEDk09Udpz89xRf30ZF+c4XTU5YwF/Se3kT3MKrZJhezEG+uhLNrtwCTPdkdK1QA==} engines: {node: '>=18'} tsx@4.21.0: @@ -332,18 +332,18 @@ snapshots: fsevents@2.3.3: optional: true - get-tsconfig@4.13.7: + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 resolve-pkg-maps@1.0.0: {} - s402@0.2.3: {} + s402@0.6.0: {} tsx@4.21.0: dependencies: esbuild: 0.27.7 - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 optionalDependencies: fsevents: 2.3.3 diff --git a/demo-api/src/server.ts b/demo-api/src/server.ts index 568b1f7..763bbf6 100644 --- a/demo-api/src/server.ts +++ b/demo-api/src/server.ts @@ -27,6 +27,9 @@ import { encodePaymentRequired, decodePaymentPayload, encodeSettleResponse, + parseAcceptPayment, + formatAcceptPayment, + selectBestScheme, S402_HEADERS, S402_VERSION, } from 's402'; @@ -228,6 +231,13 @@ const routeMap = new Map( ]), ); +// ── Accept-Payment advertisement ──────────────────────── + +const SUPPORTED_SCHEMES = ['s402/exact'] as const; +const ACCEPT_PAYMENT_HEADER = formatAcceptPayment( + SUPPORTED_SCHEMES.map((scheme) => ({ scheme, q: 1 })), +); + // ── Stats ─────────────────────────────────────────────── let totalRequests = 0; @@ -239,8 +249,11 @@ let totalRevenue = 0n; function setCors(res: ServerResponse): void { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'x-payment, content-type'); - res.setHeader('Access-Control-Expose-Headers', 'payment-required, x-payment-response'); + res.setHeader('Access-Control-Allow-Headers', 'x-payment, accept-payment, content-type'); + res.setHeader( + 'Access-Control-Expose-Headers', + 'payment-required, x-payment-response, accept-payment', + ); } function json(res: ServerResponse, status: number, body: unknown): void { @@ -317,12 +330,18 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise const { route, requirements } = entry; const paymentHeader = req.headers[S402_HEADERS.PAYMENT] as string | undefined; + const acceptPaymentHeader = req.headers[S402_HEADERS.ACCEPT_PAYMENT] as string | undefined; + const clientPreferences = parseAcceptPayment(acceptPaymentHeader); + const negotiated = selectBestScheme(clientPreferences, SUPPORTED_SCHEMES); // No payment → 402 if (!paymentHeader) { - console.log(` 402 ${route.path} → ${route.priceDisplay}`); + console.log( + ` 402 ${route.path} → ${route.priceDisplay}${negotiated ? ` (negotiated ${negotiated})` : ''}`, + ); res.writeHead(402, { [S402_HEADERS.PAYMENT_REQUIRED]: encodePaymentRequired(requirements), + [S402_HEADERS.ACCEPT_PAYMENT]: ACCEPT_PAYMENT_HEADER, 'content-type': 'application/json; charset=utf-8', }); res.end( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a42e79e..f397409 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,7 +10,7 @@ importers: devDependencies: vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.49.2)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@25.6.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@6.0.3) server-ts: devDependencies: @@ -25,13 +25,13 @@ importers: version: 5.9.3 vitest: specifier: ^3.0.5 - version: 3.2.4 + version: 3.2.4(@types/node@25.6.0) typescript: devDependencies: '@vitest/coverage-v8': specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) + version: 3.2.4(vitest@3.2.4(@types/node@25.6.0)) fast-check: specifier: ^4.5.3 version: 4.7.0 @@ -43,10 +43,10 @@ importers: version: 5.9.3 vitepress: specifier: ^1.6.4 - version: 1.6.4(@algolia/client-search@5.49.2)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3) + version: 1.6.4(@algolia/client-search@5.49.2)(@types/node@25.6.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3) vitest: specifier: ^3.0.5 - version: 3.2.4 + version: 3.2.4(@types/node@25.6.0) packages: @@ -762,6 +762,9 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@25.6.0': + resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1479,9 +1482,17 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + unconfig-core@7.5.0: resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==} + undici-types@7.19.2: + resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -2177,18 +2188,23 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/node@25.6.0': + dependencies: + undici-types: 7.19.2 + optional: true + '@types/unist@3.0.3': {} '@types/web-bluetooth@0.0.21': {} '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.30(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.30(typescript@6.0.3))': dependencies: - vite: 5.4.21 - vue: 3.5.30(typescript@5.9.3) + vite: 5.4.21(@types/node@25.6.0) + vue: 3.5.30(typescript@6.0.3) - '@vitest/coverage-v8@3.2.4(vitest@3.2.4)': + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/node@25.6.0))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -2203,7 +2219,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.2 tinyrainbow: 2.0.0 - vitest: 3.2.4 + vitest: 3.2.4(@types/node@25.6.0) transitivePeerDependencies: - supports-color @@ -2215,13 +2231,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@5.4.21)': + '@vitest/mocker@3.2.4(vite@5.4.21(@types/node@25.6.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 5.4.21 + vite: 5.4.21(@types/node@25.6.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -2313,11 +2329,11 @@ snapshots: '@vue/shared': 3.5.30 csstype: 3.2.3 - '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@5.9.3))': + '@vue/server-renderer@3.5.30(vue@3.5.30(typescript@6.0.3))': dependencies: '@vue/compiler-ssr': 3.5.30 '@vue/shared': 3.5.30 - vue: 3.5.30(typescript@5.9.3) + vue: 3.5.30(typescript@6.0.3) '@vue/shared@3.5.30': {} @@ -2330,6 +2346,15 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/core@12.8.2(typescript@6.0.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.30(typescript@6.0.3) + transitivePeerDependencies: + - typescript + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@5.9.3)': dependencies: '@vueuse/core': 12.8.2(typescript@5.9.3) @@ -2340,6 +2365,16 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/integrations@12.8.2(focus-trap@7.8.0)(typescript@6.0.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/shared': 12.8.2(typescript@6.0.3) + vue: 3.5.30(typescript@6.0.3) + optionalDependencies: + focus-trap: 7.8.0 + transitivePeerDependencies: + - typescript + '@vueuse/metadata@12.8.2': {} '@vueuse/shared@12.8.2(typescript@5.9.3)': @@ -2348,6 +2383,12 @@ snapshots: transitivePeerDependencies: - typescript + '@vueuse/shared@12.8.2(typescript@6.0.3)': + dependencies: + vue: 3.5.30(typescript@6.0.3) + transitivePeerDependencies: + - typescript + algoliasearch@5.49.2: dependencies: '@algolia/abtesting': 1.15.2 @@ -2959,11 +3000,17 @@ snapshots: typescript@5.9.3: {} + typescript@6.0.3: + optional: true + unconfig-core@7.5.0: dependencies: '@quansync/fs': 1.0.0 quansync: 1.0.0 + undici-types@7.19.2: + optional: true + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 @@ -3001,13 +3048,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4: + vite-node@3.2.4(@types/node@25.6.0): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 5.4.21 + vite: 5.4.21(@types/node@25.6.0) transitivePeerDependencies: - '@types/node' - less @@ -3019,15 +3066,16 @@ snapshots: - supports-color - terser - vite@5.4.21: + vite@5.4.21(@types/node@25.6.0): dependencies: esbuild: 0.21.5 postcss: 8.5.8 rollup: 4.60.0 optionalDependencies: + '@types/node': 25.6.0 fsevents: 2.3.3 - vitepress@1.6.4(@algolia/client-search@5.49.2)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3): + vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@25.6.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@5.9.3): dependencies: '@docsearch/css': 3.8.2 '@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3) @@ -3036,7 +3084,7 @@ snapshots: '@shikijs/transformers': 2.5.0 '@shikijs/types': 2.5.0 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 5.2.4(vite@5.4.21)(vue@3.5.30(typescript@5.9.3)) + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.30(typescript@6.0.3)) '@vue/devtools-api': 7.7.9 '@vue/shared': 3.5.30 '@vueuse/core': 12.8.2(typescript@5.9.3) @@ -3045,7 +3093,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.2.0 shiki: 2.5.0 - vite: 5.4.21 + vite: 5.4.21(@types/node@25.6.0) vue: 3.5.30(typescript@5.9.3) optionalDependencies: postcss: 8.5.8 @@ -3076,11 +3124,60 @@ snapshots: - typescript - universal-cookie - vitest@3.2.4: + vitepress@1.6.4(@algolia/client-search@5.49.2)(@types/node@25.6.0)(postcss@8.5.8)(search-insights@2.17.3)(typescript@6.0.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.49.2)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.74 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.4(vite@5.4.21(@types/node@25.6.0))(vue@3.5.30(typescript@6.0.3)) + '@vue/devtools-api': 7.7.9 + '@vue/shared': 3.5.30 + '@vueuse/core': 12.8.2(typescript@6.0.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.8.0)(typescript@6.0.3) + focus-trap: 7.8.0 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 2.5.0 + vite: 5.4.21(@types/node@25.6.0) + vue: 3.5.30(typescript@6.0.3) + optionalDependencies: + postcss: 8.5.8 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vitest@3.2.4(@types/node@25.6.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@5.4.21) + '@vitest/mocker': 3.2.4(vite@5.4.21(@types/node@25.6.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -3098,9 +3195,11 @@ snapshots: tinyglobby: 0.2.16 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 5.4.21 - vite-node: 3.2.4 + vite: 5.4.21(@types/node@25.6.0) + vite-node: 3.2.4(@types/node@25.6.0) why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 transitivePeerDependencies: - less - lightningcss @@ -3117,11 +3216,21 @@ snapshots: '@vue/compiler-dom': 3.5.30 '@vue/compiler-sfc': 3.5.30 '@vue/runtime-dom': 3.5.30 - '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@5.9.3)) + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@6.0.3)) '@vue/shared': 3.5.30 optionalDependencies: typescript: 5.9.3 + vue@3.5.30(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.30 + '@vue/compiler-sfc': 3.5.30 + '@vue/runtime-dom': 3.5.30 + '@vue/server-renderer': 3.5.30(vue@3.5.30(typescript@6.0.3)) + '@vue/shared': 3.5.30 + optionalDependencies: + typescript: 6.0.3 + which@2.0.2: dependencies: isexe: 2.0.0 From 1f056ac01b581c434449919e1a28f40b1851b178 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Sun, 19 Apr 2026 23:55:31 -0700 Subject: [PATCH 2/7] =?UTF-8?q?chore(@sweefi/server):=20bump=200.1.0=20?= =?UTF-8?q?=E2=86=92=200.1.2=20to=20sync=20with=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.1.1 was published to npm at some point without a corresponding repo commit, leaving package.json stuck at 0.1.0. Since then two meaningful source commits landed (HTTP hygiene bundle 85e8246, nosniff header 3350a82), so jumping straight to 0.1.2 — the next publish carries real content beyond whatever 0.1.1 contained. --- server-ts/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server-ts/package.json b/server-ts/package.json index 763ae5b..adb0cd4 100644 --- a/server-ts/package.json +++ b/server-ts/package.json @@ -1,6 +1,6 @@ { "name": "@sweefi/server", - "version": "0.1.0", + "version": "0.1.2", "type": "module", "description": "Framework-agnostic s402 server middleware. `s402Gate()` works with Hono, Next.js Route Handlers, Bun, Deno, Cloudflare Workers, and any host that speaks the Web Fetch API.", "license": "Apache-2.0", From ca614543f313d68191f61de1a881da4bca5da8b8 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Mon, 20 Apr 2026 00:13:11 -0700 Subject: [PATCH 3/7] =?UTF-8?q?feat(compat-l402):=20L402=20read-path=20int?= =?UTF-8?q?erop=20+=20three-pillar=20positioning=20=E2=80=94=20DAN-344?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit s402 is now a strict superset of every 402 dialect in production: x402 (Coinbase), MPP (Stripe/Tempo), and L402 (Lightning Labs). Absorption is codified as a standing commitment in the new Universal 402 Absorption Linear project. ## s402/compat-l402 (new sub-path export) - parseWwwAuthenticateL402 — RFC 9110 auth-params parser; accepts both L402 and legacy LSAT auth-scheme names (canonicalized to L402) - decodeBolt11Summary — partial BOLT-11 decoder over the human-readable part only; extracts network (mainnet/testnet/regtest/signet) + amount via m/u/n/p multiplier arithmetic (BigInt); rejects pico-BTC amounts not divisible by 10 - fromL402Challenge — translates to s402PaymentRequirements with scheme="exact", asset="lightning:msat", sentinel payTo="lightning:invoice" (real destination lives inside the BOLT-11 invoice); macaroon + invoice surfaced under extensions.l402 for retry construction; rejects amountless invoices as spec violations 29 unit tests cover all four multiplier classes, all four networks, LSAT/L402 alias handling, malformed HRPs, and end-to-end header-to- requirements flows. 985 tests total, all green. ## Positioning (new canonical doc) docs/positioning.md establishes the three-pillar USP as the single source of truth for landing copy, pitch, and grant apps: 1. Expressiveness — six schemes (Exact, Upto, Prepaid, Escrow, Stream, Unlock); three have no equivalent in any competing protocol 2. Universal read — one server speaks every 402 dialect on the wire; compat layers live in sub-path exports with zero core pollution 3. On-chain enforcement — every invariant is a Move contract, not a server policy ## Scope (intentionally deferred) - L402 write path (requires a Lightning node; belongs in Aperture, not a wire-format library) - Macaroon caveat decoding (opaque passthrough) - Full BOLT-11 tagged-field decoding (Lightning wallets already do this) - BOLT-12 offers (spec still evolving) ## Linear New project: Universal 402 Absorption. Child issues DAN-344 (L402, in progress), DAN-345 (MPP Session), DAN-346 (MPP write path), DAN-347 (Google AP2 research), DAN-348 (IETF reference impl path), DAN-349 (ERC-7824 watch). ## Compatibility Purely additive. No changes to existing types, wire format, or conformance vectors. 0.6.x consumers require no code changes. New sub-path export sits alongside s402/compat (x402) and s402/compat-mpp. --- docs/guide/upgrade-l402.md | 171 +++++++++++++++ docs/integrations.md | 1 + docs/positioning.md | 112 ++++++++++ typescript/CHANGELOG.md | 29 +++ typescript/package.json | 7 + typescript/src/compat-l402.ts | 324 ++++++++++++++++++++++++++++ typescript/test/compat-l402.test.ts | 232 ++++++++++++++++++++ typescript/tsdown.config.ts | 1 + 8 files changed, 877 insertions(+) create mode 100644 docs/guide/upgrade-l402.md create mode 100644 docs/positioning.md create mode 100644 typescript/src/compat-l402.ts create mode 100644 typescript/test/compat-l402.test.ts diff --git a/docs/guide/upgrade-l402.md b/docs/guide/upgrade-l402.md new file mode 100644 index 0000000..73b6563 --- /dev/null +++ b/docs/guide/upgrade-l402.md @@ -0,0 +1,171 @@ +--- +description: Migrating from L402 (Lightning Labs) to s402 — consume L402 challenges natively via s402/compat-l402, coexist via Accept-Payment, and optionally graduate off Lightning to on-chain schemes. +--- + +# Migrating from L402 + +Already running an L402-gated API (Aperture or similar)? s402 is the first 402 protocol that reads L402 natively. Your Lightning-paying clients keep working; your s402 clients get six schemes Lightning structurally can't express. + +This guide is for teams running Aperture-style Lightning paywalls who want to extend — not replace — their setup. + +::: info Availability +L402 read-path lands in v0.7 — the code is in the `s402/compat-l402` entry point. Write-path emission (s402 server emitting an L402 challenge with an issued macaroon + fresh invoice) is not scoped: it requires a running Lightning node to mint invoices, which is out of scope for a wire-format library. Teams that want to emit L402 should keep using Aperture. +::: + +## TL;DR + +- s402 will read L402 / LSAT challenges via `s402/compat-l402` (shipping v0.7) +- Your existing Aperture deployment keeps working +- You gain six schemes Lightning structurally cannot express: Upto ceiling, Escrow with arbiter, Stream with on-chain rate cap, Unlock pay-to-decrypt, Prepaid batched settlement, Exact on any chain beyond Bitcoin +- Coexistence via `Accept-Payment`: advertise both L402 and s402 on the same endpoint + +## When to pick s402 over L402 + +| Situation | Right tool | +|---|---| +| You need sub-millisecond payment finality | **s402 Prepaid on Sui** — ~400ms vs Lightning's multi-hop routing (variable, seconds) | +| Your API has variable pricing and you must bound the maximum charge | **s402 Upto** — the ceiling is enforced by a Move contract | +| You're running per-second billing (inference, video, live data) | **s402 Stream** — on-chain rate enforcement | +| Trustless commerce between unfamiliar parties with arbiter-backed disputes | **s402 Escrow** | +| Pay-to-decrypt content | **s402 Unlock** — via Sui SEAL + Walrus | +| You don't want to run a Lightning node | **s402** — chain-agnostic, hosted facilitator available | + +## When to stay on L402 + +Lightning has properties s402 doesn't replicate: + +- **Bitcoin-native settlement** — if your users prefer Bitcoin and your API ingests sats, Lightning is the path of least resistance +- **Existing Lightning wallet ecosystem** — Phoenix, Wallet of Satoshi, Breez, Zeus — huge installed base +- **Privacy via onion routing** — Lightning's source-route topology is stronger than most on-chain systems +- **You already run Aperture** — don't migrate for migration's sake + +## Consuming an L402 challenge as an s402 client + +This is the primary v0.7 capability — an s402 client receives a 402 from an Aperture server, lifts the challenge into s402 types, and routes it to the caller's Lightning wallet. + +```typescript +import { + parseWwwAuthenticateL402, + fromL402Challenge, +} from 's402/compat-l402'; + +const res = await fetch('https://api.example.com/data'); +if (res.status === 402) { + const challenge = parseWwwAuthenticateL402( + res.headers.get('WWW-Authenticate'), + ); + if (challenge) { + const requirements = fromL402Challenge(challenge); + // requirements.network === 'lightning:mainnet' + // requirements.asset === 'lightning:msat' + // requirements.amount === '250000000' (for a 2500 μBTC invoice) + // requirements.payTo === 'lightning:invoice' (sentinel — real destination is in the invoice) + // requirements.extensions.l402.macaroon (present back on retry) + // requirements.extensions.l402.invoice (pay this via a Lightning wallet) + + // Your Lightning wallet pays the invoice and returns the preimage. + const preimage = await yourLightningWallet.pay(requirements.extensions.l402.invoice); + + // Retry with L402 Authorization. + await fetch('https://api.example.com/data', { + headers: { + Authorization: `L402 ${requirements.extensions.l402.macaroon}:${preimage}`, + }, + }); + } +} +``` + +`parseWwwAuthenticateL402` accepts both the modern `L402` and the legacy `LSAT` auth-scheme names — Aperture still emits `LSAT` on older deployments. The parsed output is always canonicalized to `L402`. + +### BOLT-11 HRP decoding + +`fromL402Challenge` calls `decodeBolt11Summary` internally to extract the amount and network from the invoice's human-readable part. This is a **partial** BOLT-11 decoder — it reads only the HRP (prefix + amount + multiplier) because full tagged-field parsing requires 500+ lines of bech32 code that the Lightning wallet already has. + +| BOLT-11 multiplier | Conversion to msat | +|---|---| +| (none) | `amount * 10^11` | +| `m` (milli-BTC) | `amount * 10^8` | +| `u` (micro-BTC) | `amount * 10^5` | +| `n` (nano-BTC) | `amount * 10^2` | +| `p` (pico-BTC) | `amount / 10` (amount must be multiple of 10) | + +### What's not in the read path yet + +- **Macaroon caveat decoding** — the macaroon is passed through opaque; we do not decode or enforce caveats (expiry, capability bindings). Clients that need caveat introspection should use `node-macaroon` or equivalent. +- **Preimage verification** — server-side, requires Lightning node access. Out of scope for a wire-format library. +- **Invoice tagged-field parsing** — node pubkey, routing hints, payment hash, description — all inside the invoice but left for the Lightning wallet that actually pays. +- **BOLT-12 offers** — the newer offer-based protocol is not yet supported; spec is still evolving. + +## Coexistence pattern via `Accept-Payment` + +Advertise both L402 and s402 on the same endpoint; each client pays its native way. + +```typescript +import { parseAcceptPayment, selectBestScheme, S402_HEADERS } from 's402'; + +async function handle(req: Request): Promise { + const preferred = parseAcceptPayment(req.headers.get(S402_HEADERS.ACCEPT_PAYMENT)); + + const supported = [ + 's402/prepaid', // s402 high-frequency native + 's402/exact', // s402 one-shot + 'l402/lightning', // L402 Lightning (advertised but emitted by your Aperture path) + ]; + + const chosen = selectBestScheme(preferred, supported); + + if (chosen?.startsWith('l402/')) { + return routeToApertureHandler(req); // your existing L402 path + } + + return buildS402Challenge(chosen ?? 's402/exact'); +} +``` + +L402 clients pay via Lightning (routed to Aperture). s402 clients pay via s402 (Sui, EVM, etc.). Neither client stack changes. + +## Honest comparison + +| Dimension | L402 | s402 | +|---|---|---| +| **Schemes** | 1 (Lightning invoice + macaroon) | 6 (Exact, Upto, Prepaid, Escrow, Stream, Unlock) | +| **Settlement** | Lightning Network (Bitcoin) | Chain-agnostic (Sui native; EVM + Solana via schemes) | +| **Finality** | Seconds (multi-hop routing) | ~400ms on Sui | +| **Enforcement** | Macaroon caveats (server-signed) | Move contracts (on-chain) | +| **Node requirement** | Must run Lightning node | Hosted facilitator available | +| **Multi-chain** | Bitcoin only | Any chain with an s402 adapter | +| **Pricing model** | Per-call (one invoice per request) | Per-call + batch + streaming + unlock | + +L402 wins on Bitcoin-native settlement and a mature Lightning wallet ecosystem. s402 wins on expressiveness, finality, enforcement model, and multi-chain reach. + +## FAQ + +### Do I need to migrate off Aperture? + +No. The coexistence pattern is first-class — advertise both and let clients pick. + +### Does s402 emit L402 challenges? + +No. L402 emission requires minting BOLT-11 invoices, which requires a Lightning node. That's Aperture's job. If you need to emit L402, keep Aperture in the path. + +### Can I reuse my Aperture macaroon infrastructure with s402? + +Not directly — macaroons are L402-specific. s402 uses signed receipt claims (NFT receipts on Sui, or HTTP header receipts for lightweight flows). The two are conceptually similar (bearer tokens with expiry + caveats) but structurally incompatible on the wire. + +### What happens to the `payTo` field since Lightning invoices don't expose a traditional address? + +`payTo` is set to the sentinel `"lightning:invoice"`. The real destination (node pubkey + payment hash) is inside the BOLT-11 invoice, which the Lightning wallet decodes itself. The sentinel exists only to satisfy the s402 schema. + +### Why is `asset` set to `"lightning:msat"` instead of a token identifier? + +Lightning payments are denominated in millisatoshi — they don't carry a token identifier. The `lightning:msat` string is an s402 convention that signals "this amount is in millisatoshi, pay via Lightning Network." + +## Next steps + +- **[See the six schemes](/guide/which-scheme)** — match each scheme to a use case +- **[Migrating from x402](/guide/upgrade-x402)** — if you're also running x402 +- **[Migrating from MPP](/guide/upgrade-mpp)** — if you're also running MPP (Stripe/Tempo) +- **[Positioning](/positioning)** — the three-pillar USP + +If you're running Aperture and want to pilot s402 alongside it, [file an issue](https://github.com/s402-protocol/core/issues) — we'll help you wire up the coexistence pattern. diff --git a/docs/integrations.md b/docs/integrations.md index 8bc976c..437277c 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -96,6 +96,7 @@ The compat layers let existing x402 and MPP traffic flow through an s402 server | **MPP Session** | `s402/compat-mpp` | 📋 v0.4 roadmap | Cumulative voucher ↔ Prepaid translation | | **MPP `Accept-Payment`** | `s402/compat-mpp` | ✅ Production | `parseMppAcceptPayment` — method/intent pairs with wildcards + q-values | | **s402 `Accept-Payment`** | core `s402` | ✅ Production | Flat scheme token negotiation ([DAN-341](https://linear.app/dannydevs/issue/DAN-341)) | +| **L402 / LSAT** (Lightning Labs) | `s402/compat-l402` | ✅ v0.7 | `parseWwwAuthenticateL402` + `fromL402Challenge` — BOLT-11 HRP decode, macaroon+invoice passthrough | See [Migrating from x402](/guide/upgrade-x402) and [Migrating from MPP](/guide/upgrade-mpp) for code. diff --git a/docs/positioning.md b/docs/positioning.md new file mode 100644 index 0000000..be016b3 --- /dev/null +++ b/docs/positioning.md @@ -0,0 +1,112 @@ +--- +description: s402 positioning — the three-pillar USP. Expressiveness (six schemes), universal read (every 402 dialect), on-chain enforcement (Move invariants). Single source of truth for landing page, pitch, and grant copy. +--- + +# Positioning + +> **One-line:** s402 is the universal HTTP 402 server — it speaks every 402 dialect on the wire, offers six payment schemes no other protocol can express, and enforces every invariant on-chain instead of in server policy. + +This page is the canonical positioning document. The landing page, pitch deck, grant applications, and outbound messages all pull from here. If it isn't written here, it isn't official positioning yet. + +## The Three-Pillar USP + +s402's defensibility comes from three pillars that compound. Any one in isolation is commoditizable. Together they are uncopyable without a year of dedicated engineering and a Sui-native settlement layer. + +### Pillar 1 — Expressiveness + +**Six payment schemes as first-class protocol primitives.** + +| Scheme | Use case | Competitors | +|---|---|---| +| **Exact** | One-shot per-call pricing | x402, MPP Charge | +| **Upto** | Variable-price APIs with ceiling | x402 (upto) | +| **Prepaid** | Deposit + batch for high-frequency access | MPP Session (partial) | +| **Escrow** | Arbiter-backed disputes | None | +| **Stream** | Per-second billing (inference, video) | None | +| **Unlock** | Pay-to-decrypt via SEAL + Walrus | None | + +x402 ships 2 schemes. MPP ships 1 formally registered intent. L402 ships 1. s402 ships 6. Three of the six (Escrow, Stream, Unlock) have no equivalent in any competing protocol — they require on-chain primitives (object ownership, PTB atomic settlement, threshold crypto) that the competitors' trust models don't support. + +### Pillar 2 — Universal Read + +**One server speaks every 402 dialect on the wire.** + +| Dialect | Status | Module | +|---|---|---| +| **x402 V1/V2** (Coinbase) | ✅ Production | `s402/compat` | +| **MPP Charge** — crypto rails (Stripe/Tempo) | ✅ v0.6 | `s402/compat-mpp` | +| **MPP Accept-Payment** | ✅ v0.6 | `s402/compat-mpp` | +| **MPP Session** | 📋 v0.7 | `s402/compat-mpp` | +| **L402** (Lightning Labs) | 📋 v0.7 | `s402/compat-l402` | +| **IETF `draft-ryan-httpauth-payment`** | 🟡 Partial via MPP | (reference impl path) | +| **Google AP2** (Agent Payments Protocol) | 📋 research | `s402/compat-ap2` | +| **ERC-7824 statechannels** | 📋 watch | — | + +Any client speaking any dialect in the "Production" rows can hit an s402 server unchanged. A new dialect becomes a sub-path export (`s402/compat-*`) with its own conformance vectors — zero core pollution. + +### Pillar 3 — On-Chain Enforcement + +**Every scheme's invariants are Move contracts, not server policies.** + +- **Upto ceiling** — enforced by the Move contract; a server cannot overcharge even if compromised. +- **Stream rate** — physically bounded by the on-chain meter; no overdraw. +- **Prepaid balance** — atomic debit; double-spend impossible. +- **Escrow release** — arbiter-signed on-chain; no custodian can freeze. +- **Unlock threshold** — SEAL + Walrus; no single party holds the key. + +x402 relies on server honesty + facilitator trust. MPP relies on Tempo's permissioned validator set + Stripe's fiat ledger. s402 relies on a permissionless public chain. This is not a marketing difference — it is a structural difference in what can go wrong and who bears the loss. + +## Why the Three Compound + +- **Expressiveness without universal read** = a better protocol nobody can reach. Clients stay on what they have. +- **Universal read without expressiveness** = a translator with nothing to say. Clients use the source protocol directly. +- **Neither without on-chain enforcement** = a web2 payment rail wearing a crypto costume. Agents gain nothing over Stripe. + +The full pitch only works when all three land in the same sentence: **"You can pay us with any 402 dialect you already speak, we offer schemes your current protocol structurally cannot express, and every invariant is enforced by a Move contract rather than our server."** + +## The Absorption Principle + +s402 absorbs every 402 dialect we discover in production. This is a standing commitment, not a roadmap item. + +**Rule:** when a new 402 dialect is identified in-the-wild, a compat layer plan is opened within two weeks. Each compat layer: + +1. Lives in its own sub-path export (`s402/compat-{name}`) with zero core imports. +2. Implements the read path first (decode, translate to s402 types). Write path is a separate milestone. +3. Passes its own conformance vectors sourced from the dialect's canonical spec. +4. Documents exactly what it does *not* translate (e.g., processor methods, session intents, bespoke auth). + +**Explicit non-goals:** +- We do not absorb fiat rails (Visa, ACH, card networks) — that requires becoming a card network, not writing code. +- We do not absorb protocols we cannot read structurally (closed-source, undocumented wire formats). +- We do not fork source spec semantics — we translate, never redefine. + +## What s402 Is Not + +Clarity in positioning requires clarity in what we refuse. + +- **Not a wallet.** Wallets sign payments; s402 is the server/resource/facilitator side. +- **Not a card network.** MPP wins on fiat rails; we coexist via `Accept-Payment`. +- **Not a chain.** s402 is chain-agnostic wire format; Sui is where all six schemes land natively today because of object model + PTB + SEAL, not because we are "a Sui protocol." +- **Not a subset of x402 or MPP.** We translate them; we are strictly larger in surface, enforcement model, and scheme count. +- **Not closed.** Apache-2.0, community adapters welcome, 161 conformance vectors public. + +## The Tagline Hierarchy + +Use the tightest one that fits the audience. + +- **One word:** Universal. +- **One line:** The universal HTTP 402 server. +- **Two lines:** Every 402 dialect on the wire. Six schemes no one else can express. Every invariant enforced on-chain. +- **Paragraph:** s402 is the universal HTTP 402 protocol for AI agents. It reads x402, MPP, and every other 402 dialect in production, so any agent can pay any s402 endpoint unchanged. It offers six payment schemes — Exact, Upto, Prepaid, Escrow, Stream, Unlock — three of which no other protocol can structurally express. And every scheme's invariants are enforced by Move contracts on a permissionless public chain, not by server policy. + +## How to Use This Document + +- **Landing page copy** — hero tagline from the one-line, feature grid from the three pillars. +- **Pitch deck** — one slide per pillar, one slide on absorption, one on non-goals. +- **Grant apps** — three-pillar USP as the "why" section; absorption principle as the "defensibility" section. +- **Outbound messages** — tailor the tagline depth to the recipient's technical level; never shorten the three-pillar claim when it fits. +- **When positioning drifts** — fix it here first, then propagate. Don't let the landing page and pitch deck drift apart. + +## Change Log + +- **2026-04-20** — initial version. Three-pillar USP codified. Absorption principle added. Non-goals locked. diff --git a/typescript/CHANGELOG.md b/typescript/CHANGELOG.md index 55e658a..9e0150e 100644 --- a/typescript/CHANGELOG.md +++ b/typescript/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`s402/compat-l402` — L402 read-path interop (DAN-344).** New entry point for consuming Lightning Labs' L402 (formerly LSAT) challenges as native s402 types. L402 is the oldest 402 dialect in production — shipping this turns the "universal read" positioning pillar from aspirational into airtight. + - `parseWwwAuthenticateL402(header)` — RFC 9110 auth-params parser accepting both `L402` and legacy `LSAT` auth-schemes (canonicalized to `L402` in output). Handles quoted-string + unquoted-token forms. Enforces required `macaroon` and `invoice` params. + - `decodeBolt11Summary(invoice)` — partial BOLT-11 decoder over the human-readable part only. Extracts network (`lightning:mainnet|testnet|regtest|signet`) and amount (converting m/u/n/p multipliers to millisatoshi with BigInt arithmetic). Rejects pico-BTC amounts not divisible by 10. + - `fromL402Challenge(challenge)` — translates an L402 challenge into `s402PaymentRequirements` with `scheme: 'exact'`, `asset: 'lightning:msat'`, sentinel `payTo: 'lightning:invoice'` (real destination lives in the invoice). Surfaces macaroon + invoice in `extensions.l402` for retry construction. Rejects amountless invoices as spec violations. +- **~20 unit tests** at `test/compat-l402.test.ts` covering all four multiplier classes, all four network prefixes, LSAT/L402 alias handling, amountless invoices, malformed HRPs, and end-to-end header-to-requirements flows. +- **Positioning document** at `docs/positioning.md` — canonical three-pillar USP: expressiveness (6 schemes), universal read (every 402 dialect), on-chain enforcement (Move invariants). Single source of truth for landing page, pitch, and grant copy. +- **Universal 402 Absorption** project tracker on Linear ([project link](https://linear.app/dannydevs/project/universal-402-absorption-f6e181082db4)) with child issues DAN-344 (L402), DAN-345 (MPP Session), DAN-346 (MPP write path), DAN-347 (Google AP2), DAN-348 (IETF reference impl), DAN-349 (ERC-7824 watch). + +### Scope (intentionally deferred) + +- **L402 write path** — emitting L402 challenges requires a Lightning node to mint BOLT-11 invoices; out of scope for a wire-format library. Teams that need emission should keep Aperture in the path. +- **Macaroon caveat decoding** — passed through opaque in v0.7; caveat introspection delegated to `node-macaroon` or equivalent. +- **Full BOLT-11 tagged-field decoding** — node pubkey, routing hints, payment hash, description. Lightning wallets already decode these; we do not duplicate their work. +- **BOLT-12 offers** — newer offer-based protocol, spec still evolving. + +### Changed + +- `docs/integrations.md` — added L402 compat-layer row (✅ v0.7). +- `docs/guide/upgrade-l402.md` — new migration guide covering consumption, coexistence via `Accept-Payment`, BOLT-11 multiplier table, and honest comparison with L402. + +### Compatibility + +- **Purely additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors. Existing 0.6.x consumers require no code changes. +- **New sub-path export**: `s402/compat-l402` sits alongside `s402/compat` (x402) and `s402/compat-mpp` (Stripe/Tempo). All three are opt-in — importing from the root `s402` entry does not pull any compat bundle. + ## [0.6.0] - 2026-04-19 ### Added diff --git a/typescript/package.json b/typescript/package.json index be4c763..e1dae24 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -82,6 +82,13 @@ }, "default": "./dist/compat-mpp.mjs" }, + "./compat-l402": { + "import": { + "types": "./dist/compat-l402.d.mts", + "default": "./dist/compat-l402.mjs" + }, + "default": "./dist/compat-l402.mjs" + }, "./errors": { "import": { "types": "./dist/errors.d.mts", diff --git a/typescript/src/compat-l402.ts b/typescript/src/compat-l402.ts new file mode 100644 index 0000000..1ffa256 --- /dev/null +++ b/typescript/src/compat-l402.ts @@ -0,0 +1,324 @@ +/** + * s402 ↔ L402 Compatibility Layer — read path + * + * Enables s402 clients to consume L402 challenges from Lightning Labs' Aperture + * (and aperture-derived servers). L402 is the oldest 402 dialect in production — + * Lightning Labs shipped it in 2020 as LSAT (Lightning Service Auth Token), + * renamed to L402 in 2022. This module accepts both names on the wire and + * canonicalizes to `L402` in parsed output. + * + * Spec references: + * - L402 announcement: https://lightning.engineering/posts/2020-10-14-l402/ + * - Aperture (reference impl): https://github.com/lightninglabs/aperture + * - BOLT-11 (payment encoding): https://github.com/lightning/bolts/blob/master/11-payment-encoding.md + * - RFC 9110 auth-params: https://www.rfc-editor.org/rfc/rfc9110#section-11.2 + * + * Scope (v0.7 DAN-344): + * - Parse `WWW-Authenticate: L402` / `WWW-Authenticate: LSAT` challenges + * - Decode the BOLT-11 human-readable part (HRP) for amount + network + * - Translate to `s402PaymentRequirements` with `scheme: "exact"` and + * `network: "lightning:{mainnet|testnet|regtest|signet}"` + * + * Not in scope here: + * - Macaroon caveat decoding or validation (opaque passthrough in v0.7) + * - Preimage verification (server-side, needs Lightning node access) + * - Full BOLT-11 tagged-field decoding (node pubkey, routing hints, etc.) + * - Write path (emitting L402 challenges from an s402 server) + * - BOLT-12 offers (spec still evolving) + */ + +import type { s402PaymentRequirements } from './types.js'; +import { S402_VERSION } from './types.js'; +import { s402Error } from './errors.js'; + +// ══════════════════════════════════════════════════════════════ +// L402 wire types (read-side) +// ══════════════════════════════════════════════════════════════ + +/** + * Parsed `WWW-Authenticate: L402` (or legacy `LSAT`) challenge. + * + * Per Lightning Labs' spec, an L402 challenge carries exactly two required + * auth-params: the `macaroon` (opaque bearer token with caveats) and the + * `invoice` (BOLT-11 payment request). The client pays the invoice via Lightning, + * receives the preimage, and presents `Authorization: L402 :` + * on the retry. + */ +export interface L402Challenge { + /** Canonicalized auth-scheme — always `"L402"`, even if the wire said `LSAT`. */ + scheme: 'L402'; + /** Base64-encoded macaroon. Treated as opaque by this module. */ + macaroon: string; + /** BOLT-11 invoice (bech32 `ln...`). */ + invoice: string; +} + +/** + * Decoded BOLT-11 human-readable part (HRP). Only fields the translator needs. + * + * `amountMsat` is `null` for amountless invoices — BOLT-11 allows these, but + * they are unusual in L402 contexts since Aperture embeds the price in the + * invoice. Callers translating to s402 typically reject amountless invoices. + */ +export interface Bolt11Summary { + network: 'lightning:mainnet' | 'lightning:testnet' | 'lightning:regtest' | 'lightning:signet'; + /** Amount in millisatoshi as a non-negative integer string, or `null` if the invoice specifies no amount. */ + amountMsat: string | null; +} + +// ══════════════════════════════════════════════════════════════ +// Parsing: WWW-Authenticate: L402 / LSAT +// ══════════════════════════════════════════════════════════════ + +const TOKEN_CHARS = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/; +const L402_SCHEME_PATTERN = /^\s*(L402|LSAT)(?:\s+(.*))?$/i; + +/** + * Parse a `WWW-Authenticate: L402 ...` header into an {@link L402Challenge}. + * + * Accepts both `L402` and legacy `LSAT` auth-schemes (case-insensitive) — + * Aperture in the wild still emits `LSAT` on older deployments. The output + * scheme is always canonicalized to `"L402"`. + * + * Returns `null` if the header is absent/empty or does not start with an L402 + * auth-scheme. Throws `INVALID_PAYLOAD` if the scheme is present but required + * params (`macaroon`, `invoice`) are missing or malformed. + * + * @example + * ```ts + * const challenge = parseWwwAuthenticateL402(res.headers.get('WWW-Authenticate')); + * if (challenge) { + * const requirements = fromL402Challenge(challenge); + * // requirements.scheme === 'exact', requirements.network === 'lightning:mainnet', ... + * } + * ``` + */ +export function parseWwwAuthenticateL402( + header: string | null | undefined, +): L402Challenge | null { + if (!header) return null; + const match = L402_SCHEME_PATTERN.exec(header); + if (!match) return null; + const paramString = (match[2] ?? '').trim(); + if (paramString.length === 0) { + throw new s402Error('INVALID_PAYLOAD', 'L402 challenge missing auth-params'); + } + + const params = parseAuthParams(paramString); + const macaroon = params.macaroon; + const invoice = params.invoice; + if (typeof macaroon !== 'string' || macaroon.length === 0) { + throw new s402Error('INVALID_PAYLOAD', 'L402 challenge missing "macaroon" auth-param'); + } + if (typeof invoice !== 'string' || invoice.length === 0) { + throw new s402Error('INVALID_PAYLOAD', 'L402 challenge missing "invoice" auth-param'); + } + + return { scheme: 'L402', macaroon, invoice }; +} + +/** + * Parse RFC 9110 §11.2 `auth-params` (`token "=" ( token / quoted-string )` + * comma-separated). L402 uses the same grammar as MPP; this function mirrors + * the MPP parser in `compat-mpp` rather than sharing a helper because the two + * dialects may diverge on edge cases (e.g., L402 invoices have not historically + * been quoted in Aperture output, whereas MPP params are consistently quoted). + */ +function parseAuthParams(input: string): Record { + const out: Record = {}; + let i = 0; + const n = input.length; + + while (i < n) { + while (i < n && (input[i] === ' ' || input[i] === '\t' || input[i] === ',')) i++; + if (i >= n) break; + + const keyStart = i; + while (i < n && input[i] !== '=' && input[i] !== ' ' && input[i] !== '\t') i++; + const key = input.slice(keyStart, i).toLowerCase(); + if (key.length === 0 || !TOKEN_CHARS.test(key)) { + throw new s402Error('INVALID_PAYLOAD', `Malformed auth-param name at position ${keyStart}`); + } + + while (i < n && (input[i] === ' ' || input[i] === '\t')) i++; + if (input[i] !== '=') { + throw new s402Error('INVALID_PAYLOAD', `Missing "=" after auth-param "${key}"`); + } + i++; + while (i < n && (input[i] === ' ' || input[i] === '\t')) i++; + + let value: string; + if (input[i] === '"') { + i++; + const valueStart = i; + let raw = ''; + while (i < n && input[i] !== '"') { + if (input[i] === '\\' && i + 1 < n) { + raw += input[i + 1]; + i += 2; + } else { + raw += input[i]; + i++; + } + } + if (input[i] !== '"') { + throw new s402Error('INVALID_PAYLOAD', `Unterminated quoted-string starting at position ${valueStart}`); + } + i++; + value = raw; + } else { + const valueStart = i; + while (i < n && input[i] !== ',' && input[i] !== ' ' && input[i] !== '\t') i++; + value = input.slice(valueStart, i); + if (value.length === 0) { + throw new s402Error('INVALID_PAYLOAD', `Empty auth-param value for "${key}" at position ${valueStart}`); + } + } + + out[key] = value; + } + + return out; +} + +// ══════════════════════════════════════════════════════════════ +// BOLT-11 HRP decoding +// ══════════════════════════════════════════════════════════════ + +const HRP_PATTERN = /^ln(bc|tb|bcrt|sb)(\d+)?([munp])?1[a-z0-9]+$/; + +const NETWORK_BY_PREFIX: Record = { + bc: 'lightning:mainnet', + tb: 'lightning:testnet', + bcrt: 'lightning:regtest', + sb: 'lightning:signet', +}; + +/** + * Decode the BOLT-11 human-readable part of a Lightning invoice into its + * network and amount components. This is a **partial** decoder — it reads only + * the HRP (up to the bech32 `1` separator) because full BOLT-11 decoding + * requires bech32 + tagged-field parsing (~500 LOC) and the translator only + * needs network + amount. + * + * BOLT-11 HRP grammar: `ln{prefix}{amount?}{multiplier?}` where + * - prefix ∈ {`bc`, `tb`, `bcrt`, `sb`} (mainnet/testnet/regtest/signet) + * - amount is a decimal integer (BTC units before multiplier) + * - multiplier ∈ {`m`, `u`, `n`, `p`} (milli/micro/nano/pico-BTC) + * + * Conversion to millisatoshi (msat = 10^-11 BTC): + * - no multiplier: `amount * 10^11` msat + * - `m`: `amount * 10^8` msat + * - `u`: `amount * 10^5` msat + * - `n`: `amount * 10^2` msat + * - `p`: `amount / 10` msat (amount must be multiple of 10) + * + * @throws {s402Error} `INVALID_PAYLOAD` if the HRP is malformed, the prefix is + * unknown, or a pico-BTC amount is not a multiple of 10. + */ +export function decodeBolt11Summary(invoice: string): Bolt11Summary { + if (typeof invoice !== 'string' || invoice.length === 0) { + throw new s402Error('INVALID_PAYLOAD', 'BOLT-11 invoice must be a non-empty string'); + } + const lower = invoice.toLowerCase(); + const match = HRP_PATTERN.exec(lower); + if (!match) { + throw new s402Error('INVALID_PAYLOAD', + `Invoice does not match BOLT-11 HRP grammar (expected "ln(bc|tb|bcrt|sb){amount}{m|u|n|p}1..."): "${invoice}"`); + } + const prefix = match[1]; + const amountPart = match[2]; + const multiplier = match[3]; + + const network = NETWORK_BY_PREFIX[prefix]; + if (!network) { + throw new s402Error('INVALID_PAYLOAD', `Unknown BOLT-11 network prefix: "${prefix}"`); + } + + if (amountPart === undefined) { + if (multiplier !== undefined) { + throw new s402Error('INVALID_PAYLOAD', 'BOLT-11 multiplier without amount'); + } + return { network, amountMsat: null }; + } + + const amount = BigInt(amountPart); + let amountMsat: bigint; + switch (multiplier) { + case undefined: + amountMsat = amount * 100_000_000_000n; + break; + case 'm': + amountMsat = amount * 100_000_000n; + break; + case 'u': + amountMsat = amount * 100_000n; + break; + case 'n': + amountMsat = amount * 100n; + break; + case 'p': + if (amount % 10n !== 0n) { + throw new s402Error('INVALID_PAYLOAD', + `BOLT-11 pico-BTC amount must be a multiple of 10 (got ${amount}) — ` + + `1 msat is the minimum divisible unit`); + } + amountMsat = amount / 10n; + break; + default: + throw new s402Error('INVALID_PAYLOAD', `Unknown BOLT-11 multiplier: "${multiplier}"`); + } + + return { network, amountMsat: amountMsat.toString() }; +} + +// ══════════════════════════════════════════════════════════════ +// Translation: L402 → s402 requirements +// ══════════════════════════════════════════════════════════════ + +/** + * Sentinel payTo for Lightning — the actual payment destination is encoded in + * the invoice itself (BOLT-11 tagged fields carry node pubkey + payment hash). + * An s402 client paying an L402 challenge routes through a Lightning wallet + * that knows how to pay an invoice; the `payTo` field exists only to satisfy + * the s402 schema. + */ +const LIGHTNING_INVOICE_SENTINEL = 'lightning:invoice'; + +/** + * Translate an L402 challenge into s402 payment requirements using the `exact` + * scheme. + * + * The resulting requirements are consumable by a Lightning-aware s402 client. + * The `payTo` field is a sentinel (`"lightning:invoice"`) rather than a node + * pubkey because the true destination is inside the BOLT-11 invoice — which + * Lightning wallets decode themselves. The invoice and macaroon are surfaced + * under `extensions.l402` so the client can present them back on the retry + * (`Authorization: L402 :`). + * + * @throws {s402Error} `INVALID_PAYLOAD` if the invoice HRP is malformed or the + * invoice is amountless (L402 challenges always specify a price in the + * invoice; an amountless invoice is a spec violation). + */ +export function fromL402Challenge(challenge: L402Challenge): s402PaymentRequirements { + const summary = decodeBolt11Summary(challenge.invoice); + if (summary.amountMsat === null) { + throw new s402Error('INVALID_PAYLOAD', + 'L402 invoice is amountless — L402 challenges must specify an exact price via the BOLT-11 amount'); + } + + return { + s402Version: S402_VERSION, + accepts: ['exact'], + network: summary.network, + asset: 'lightning:msat', + amount: summary.amountMsat, + payTo: LIGHTNING_INVOICE_SENTINEL, + extensions: { + l402: { + macaroon: challenge.macaroon, + invoice: challenge.invoice, + }, + }, + }; +} diff --git a/typescript/test/compat-l402.test.ts b/typescript/test/compat-l402.test.ts new file mode 100644 index 0000000..971570c --- /dev/null +++ b/typescript/test/compat-l402.test.ts @@ -0,0 +1,232 @@ +/** + * Unit tests for s402/compat-l402 — L402 (Lightning Labs) read-path interop. + * + * BOLT-11 vectors are drawn from the canonical BOLT-11 test vectors in the + * Lightning bolts repo (bolts/11-payment-encoding.md) — these have been + * implementation-stable since 2018 and ship with c-lightning, lnd, eclair. + * + * L402 challenge fixtures are constructed to match Aperture's on-the-wire + * output (observed in production Lightning Labs deployments). + */ +import { describe, it, expect } from 'vitest'; +import { + parseWwwAuthenticateL402, + decodeBolt11Summary, + fromL402Challenge, + type L402Challenge, +} from '../src/compat-l402.js'; +import { s402Error } from '../src/errors.js'; + +// A representative (synthetic) BOLT-11 invoice body. Real BOLT-11 invoices are +// ~200+ chars of bech32 — for HRP decoding we only need a valid HRP + separator +// + at least one bech32 char, so this suffices for test coverage. +const SAMPLE_MACAROON = + 'AGIAJEemVQUTEyNCR0exk7ek90Cg=='; // arbitrary base64 opaque blob + +// BOLT-11 HRPs covering all four multiplier classes. `1` is the bech32 +// separator; we append a short bech32-legal body so the regex matches without +// requiring full bech32 validation. +const INV_2500_UBTC = 'lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // 2500 μBTC = 250_000_000 msat +const INV_1500_NBTC = 'lnbc1500n1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // 1500 nBTC = 150_000 msat +const INV_20M_BTC = 'lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // 20 mBTC = 2_000_000_000 msat +const INV_10P_BTC = 'lnbc10p1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // 10 pBTC = 1 msat +const INV_AMOUNTLESS = 'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // no amount +const INV_TESTNET = 'lntb25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // testnet +const INV_REGTEST = 'lnbcrt500u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // regtest +const INV_SIGNET = 'lnsb25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // signet + +describe('parseWwwAuthenticateL402', () => { + it('returns null for absent / non-L402 headers', () => { + expect(parseWwwAuthenticateL402(null)).toBeNull(); + expect(parseWwwAuthenticateL402(undefined)).toBeNull(); + expect(parseWwwAuthenticateL402('')).toBeNull(); + expect(parseWwwAuthenticateL402('Basic realm="x"')).toBeNull(); + expect(parseWwwAuthenticateL402('Bearer token="abc"')).toBeNull(); + expect(parseWwwAuthenticateL402('Payment method="tempo"')).toBeNull(); + }); + + it('parses an L402 challenge with quoted params', () => { + const header = `L402 macaroon="${SAMPLE_MACAROON}", invoice="${INV_2500_UBTC}"`; + const challenge = parseWwwAuthenticateL402(header)!; + expect(challenge.scheme).toBe('L402'); + expect(challenge.macaroon).toBe(SAMPLE_MACAROON); + expect(challenge.invoice).toBe(INV_2500_UBTC); + }); + + it('canonicalizes legacy LSAT scheme to L402', () => { + const header = `LSAT macaroon="${SAMPLE_MACAROON}", invoice="${INV_2500_UBTC}"`; + const challenge = parseWwwAuthenticateL402(header)!; + expect(challenge.scheme).toBe('L402'); + }); + + it('accepts unquoted token-style auth-params (Aperture quirk)', () => { + const simpleMac = 'AGIAJEemVQUTEyNCR0exk7ek90Cg'; + const header = `L402 macaroon=${simpleMac}, invoice=${INV_2500_UBTC}`; + const challenge = parseWwwAuthenticateL402(header)!; + expect(challenge.macaroon).toBe(simpleMac); + expect(challenge.invoice).toBe(INV_2500_UBTC); + }); + + it('is case-insensitive on the scheme name', () => { + const header = `l402 macaroon="${SAMPLE_MACAROON}", invoice="${INV_2500_UBTC}"`; + expect(parseWwwAuthenticateL402(header)).not.toBeNull(); + const header2 = `Lsat macaroon="${SAMPLE_MACAROON}", invoice="${INV_2500_UBTC}"`; + expect(parseWwwAuthenticateL402(header2)).not.toBeNull(); + }); + + it('handles param order independence', () => { + const header = `L402 invoice="${INV_2500_UBTC}", macaroon="${SAMPLE_MACAROON}"`; + const challenge = parseWwwAuthenticateL402(header)!; + expect(challenge.macaroon).toBe(SAMPLE_MACAROON); + expect(challenge.invoice).toBe(INV_2500_UBTC); + }); + + it('throws INVALID_PAYLOAD when L402 has no auth-params', () => { + expect(() => parseWwwAuthenticateL402('L402')).toThrow(s402Error); + expect(() => parseWwwAuthenticateL402('L402 ')).toThrow(/missing auth-params/); + }); + + it('throws INVALID_PAYLOAD when macaroon is missing', () => { + expect(() => parseWwwAuthenticateL402(`L402 invoice="${INV_2500_UBTC}"`)) + .toThrow(/missing "macaroon"/); + }); + + it('throws INVALID_PAYLOAD when invoice is missing', () => { + expect(() => parseWwwAuthenticateL402(`L402 macaroon="${SAMPLE_MACAROON}"`)) + .toThrow(/missing "invoice"/); + }); + + it('throws on malformed auth-param grammar', () => { + expect(() => parseWwwAuthenticateL402('L402 macaroon')).toThrow(s402Error); + expect(() => parseWwwAuthenticateL402('L402 =value')).toThrow(s402Error); + }); +}); + +describe('decodeBolt11Summary — BOLT-11 HRP decoding', () => { + it('decodes a 2500 μBTC mainnet invoice to 250_000_000 msat', () => { + const summary = decodeBolt11Summary(INV_2500_UBTC); + expect(summary.network).toBe('lightning:mainnet'); + expect(summary.amountMsat).toBe('250000000'); + }); + + it('decodes a 1500 nBTC mainnet invoice to 150_000 msat', () => { + const summary = decodeBolt11Summary(INV_1500_NBTC); + expect(summary.network).toBe('lightning:mainnet'); + expect(summary.amountMsat).toBe('150000'); + }); + + it('decodes a 20 mBTC mainnet invoice to 2_000_000_000 msat', () => { + const summary = decodeBolt11Summary(INV_20M_BTC); + expect(summary.network).toBe('lightning:mainnet'); + expect(summary.amountMsat).toBe('2000000000'); + }); + + it('decodes a 10 pBTC mainnet invoice to 1 msat (minimum divisible)', () => { + const summary = decodeBolt11Summary(INV_10P_BTC); + expect(summary.amountMsat).toBe('1'); + }); + + it('returns amountMsat=null for amountless invoices', () => { + const summary = decodeBolt11Summary(INV_AMOUNTLESS); + expect(summary.network).toBe('lightning:mainnet'); + expect(summary.amountMsat).toBeNull(); + }); + + it('recognizes testnet (lntb) prefix', () => { + expect(decodeBolt11Summary(INV_TESTNET).network).toBe('lightning:testnet'); + }); + + it('recognizes regtest (lnbcrt) prefix', () => { + expect(decodeBolt11Summary(INV_REGTEST).network).toBe('lightning:regtest'); + }); + + it('recognizes signet (lnsb) prefix', () => { + expect(decodeBolt11Summary(INV_SIGNET).network).toBe('lightning:signet'); + }); + + it('is case-insensitive on the invoice string', () => { + const summary = decodeBolt11Summary(INV_2500_UBTC.toUpperCase()); + expect(summary.network).toBe('lightning:mainnet'); + expect(summary.amountMsat).toBe('250000000'); + }); + + it('throws INVALID_PAYLOAD on malformed HRP', () => { + expect(() => decodeBolt11Summary('')).toThrow(s402Error); + expect(() => decodeBolt11Summary('not-an-invoice')).toThrow(/BOLT-11 HRP/); + expect(() => decodeBolt11Summary('lnXY25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq')).toThrow(s402Error); + }); + + it('rejects pico-BTC amounts that are not multiples of 10', () => { + const invBadPico = 'lnbc5p1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; + expect(() => decodeBolt11Summary(invBadPico)).toThrow(/multiple of 10/); + }); + + it('handles very large amounts via BigInt arithmetic', () => { + const largeInvoice = 'lnbc1000000m1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; + const summary = decodeBolt11Summary(largeInvoice); + expect(summary.amountMsat).toBe('100000000000000'); + }); +}); + +describe('fromL402Challenge — L402 → s402 translation', () => { + const baseChallenge: L402Challenge = { + scheme: 'L402', + macaroon: SAMPLE_MACAROON, + invoice: INV_2500_UBTC, + }; + + it('translates a mainnet L402 challenge to s402 requirements', () => { + const requirements = fromL402Challenge(baseChallenge); + expect(requirements.s402Version).toBe('1'); + expect(requirements.accepts).toEqual(['exact']); + expect(requirements.network).toBe('lightning:mainnet'); + expect(requirements.asset).toBe('lightning:msat'); + expect(requirements.amount).toBe('250000000'); + expect(requirements.payTo).toBe('lightning:invoice'); + }); + + it('surfaces macaroon + invoice in extensions.l402 for the retry', () => { + const requirements = fromL402Challenge(baseChallenge); + const ext = (requirements.extensions as { l402: { macaroon: string; invoice: string } }).l402; + expect(ext.macaroon).toBe(SAMPLE_MACAROON); + expect(ext.invoice).toBe(INV_2500_UBTC); + }); + + it('propagates network from invoice prefix across all four networks', () => { + expect(fromL402Challenge({ ...baseChallenge, invoice: INV_TESTNET }).network) + .toBe('lightning:testnet'); + expect(fromL402Challenge({ ...baseChallenge, invoice: INV_REGTEST }).network) + .toBe('lightning:regtest'); + expect(fromL402Challenge({ ...baseChallenge, invoice: INV_SIGNET }).network) + .toBe('lightning:signet'); + }); + + it('rejects amountless invoices (L402 spec violation)', () => { + expect(() => fromL402Challenge({ ...baseChallenge, invoice: INV_AMOUNTLESS })) + .toThrow(/amountless/); + }); + + it('propagates INVALID_PAYLOAD from decodeBolt11Summary on malformed invoice', () => { + expect(() => fromL402Challenge({ ...baseChallenge, invoice: 'garbage' })) + .toThrow(s402Error); + }); +}); + +describe('end-to-end — WWW-Authenticate header → s402 requirements', () => { + it('parses and translates a canonical Aperture-shape L402 challenge', () => { + const header = `L402 macaroon="${SAMPLE_MACAROON}", invoice="${INV_2500_UBTC}"`; + const challenge = parseWwwAuthenticateL402(header)!; + const requirements = fromL402Challenge(challenge); + expect(requirements.network).toBe('lightning:mainnet'); + expect(requirements.amount).toBe('250000000'); + expect(requirements.asset).toBe('lightning:msat'); + }); + + it('works through the legacy LSAT scheme identically', () => { + const header = `LSAT macaroon="${SAMPLE_MACAROON}", invoice="${INV_20M_BTC}"`; + const challenge = parseWwwAuthenticateL402(header)!; + const requirements = fromL402Challenge(challenge); + expect(requirements.network).toBe('lightning:mainnet'); + expect(requirements.amount).toBe('2000000000'); + }); +}); diff --git a/typescript/tsdown.config.ts b/typescript/tsdown.config.ts index 29f328b..4b26a4e 100644 --- a/typescript/tsdown.config.ts +++ b/typescript/tsdown.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ http: 'src/http.ts', compat: 'src/compat.ts', 'compat-mpp': 'src/compat-mpp.ts', + 'compat-l402': 'src/compat-l402.ts', errors: 'src/errors.ts', receipts: 'src/receipts.ts', 'test-utils': 'src/test-utils.ts', From 780c8c1f0a9470bbc1697d2f928d8a0963271346 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Tue, 21 Apr 2026 20:51:13 -0700 Subject: [PATCH 4/7] docs(invariants): add S15 (mid-session signer rotation) + S16 (version binding) via ADR-010 Council-driven delta analysis (April 2026 Wave 4 review) identified five candidate invariants. Disciplined audit against existing S1-S14 and ADRs 006/007/008/009 narrowed the set to two genuine gaps: - S15 Mid-Session Signer Rotation: long-running schemes (stream, prepaid, escrow, multi-phase unlock) bound to mandate/shared-object capabilities, not signer pubkeys. Sharpest case: zkLogin ephemeral key cycling. - S16 Protocol Version Binding: version + scheme digest bound into the signed payload (Sui PTB prepends assert_protocol_version), not only in transport headers. Closes semantic-downgrade attacks across scheme amendments. Dropped as redundant: - Atomicity (covered by S8 + ADR-007 txBinding) - Auditability (covered by S11 + ADR-007 envelope) - Cross-process idempotency (covered by S5 + ADR-007 Idempotency-Key) Dropped as out-of-scope: - Revocability (Swee Mandate Move layer, not s402 wire) Small augmentations to existing invariants: - S5: note on cross-process dedup via ADR-007 Idempotency-Key. - S8: note on S8 x S15 interaction (per-tx vs per-session). Also: new ROADMAP.md formally parks the Privacy Scheme as P1, triggered by Sui privacy-primitive maturation; documents v0.6.0/0.6.1/0.6.2/0.7.0 sequencing; lists non-goals to prevent scope creep. Co-Authored-By: Claude Opus 4.7 --- INVARIANTS.md | 143 ++++++++++- ROADMAP.md | 143 +++++++++++ docs/adr/010-safety-invariants-s15-s16.md | 289 ++++++++++++++++++++++ 3 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 ROADMAP.md create mode 100644 docs/adr/010-safety-invariants-s15-s16.md diff --git a/INVARIANTS.md b/INVARIANTS.md index 69b177b..9055331 100644 --- a/INVARIANTS.md +++ b/INVARIANTS.md @@ -228,7 +228,16 @@ two concurrent calls with the same payload will be serialized: ⚠️ Limitation: This is per-process dedup. Multiple facilitator instances could both process the same payload. On-chain, Sui's -transaction dedup (by digest) provides the final safety net. ∎ +transaction dedup (by digest) provides the final safety net. + +⚠️ Cross-process dedup: S5's in-flight Set provides per-process +safety. Multi-instance facilitator deployments (federated or +horizontally-scaled) obtain cross-process dedup via the +Idempotency-Key header defined in ADR-007 §"Idempotency semantics", +combined with a shared dedup cache (Redis, DynamoDB, or equivalent) +keyed on the Idempotency-Key value. S5 itself does not mandate shared +caching — operators who run multiple instances without a shared cache +rely on Sui validator dedup as the final safety net. ∎ ``` --- @@ -374,6 +383,8 @@ this at ~2^128 work — infeasible by any current or projected compute. ∎ ⚠️ **Known gap: unlock-TX2.** `unlock-TX2` is constructed by the facilitator after TX1 settles (see `s402UnlockPayload` comments in types.ts:270-288). S8 as stated does NOT cover this transaction. This is the single narrow case where the April 2026 council's original S13 "causal binding" proposal would bite, and it needs a separate invariant in v0.3. Filed as a follow-up against ADR-001. +⚠️ **S8 × S15 interaction.** S8 binds a single transaction's digest to the signer of *that* transaction. Long-running schemes (`stream`, `prepaid`, `escrow`, multi-phase `unlock`) span many transactions and may be signed by multiple pubkeys across their lifecycle (see S15, ADR-010). Each individual transaction's S8 binding remains intact — the long-lived scheme's *authority* is independently tracked per S15 via session-anchor capabilities. S8 is per-tx; S15 is per-session. Both hold; neither subsumes the other. + ### Implementation template — verifySettlement for any client-signed Sui scheme The digest-binding check is **identical** across all client-signed schemes on Sui, because the digest is a pure function of the signed BCS bytes regardless of what the transaction does (transfer, Move call, shared-object mutation, etc.). When implementing a new client-signed scheme adapter, copy this template directly: @@ -431,6 +442,134 @@ verifySettlement( --- +## S15. Mid-Session Signer Rotation (Safety) + +**Statement**: For any long-running scheme whose state persists across multiple on-chain transactions (`stream`, `prepaid`, `escrow`, multi-phase `unlock`), scheme state is bound to a stable **session anchor** (mandate capability, or the scheme's shared-object ID) rather than to the signer's public key. Rotation of the signer mid-session MUST NOT invalidate, truncate, or double-bill the in-flight scheme, provided the new signer is authorized by the session anchor. + +**Formally**: For a scheme instance S with state-bearing object `obj(S)` and per-tx signer pubkey `pk_n`: + +``` +Let auth(pk, obj) = on-chain authorization predicate on obj(S) admits pk. + +For all n, m where tx_n and tx_m act on obj(S): + settlement(tx_n) succeeds ⟺ auth(pk_n, obj(S)) = true + settlement(tx_m) succeeds ⟺ auth(pk_m, obj(S)) = true + pk_n ≠ pk_m does NOT imply invalidation or double-billing of S +``` + +**Motivation (Sui-specific sharpness).** zkLogin ephemeral keys cycle on a max-epoch window by construction. Any long-running stream/prepaid/escrow will outlive multiple ephemeral keys. A protocol that binds scheme state to "the pubkey that signed the first tx" is incompatible with zkLogin at the limit. S15 makes the protocol explicitly rotation-tolerant. + +**Per-scheme cases**: + +| Scheme | Session anchor | Rotation tolerated | Authorization predicate | +|--------|---------------|--------------------|-------------------------| +| exact | None (one-shot) | N/A | Signer signs bytes | +| prepaid | `PrepaidBalance` object ID | YES | Caller ∈ `balance.authorized_claimants` OR holds mandate referenced by `balance.mandate_id` | +| stream | `Stream` object ID | YES | Caller holds stream's withdrawal capability OR matches `stream.provider` | +| escrow | `Escrow` object ID | PARTIAL — arbiter rotation tolerated; payer/payee fixed at lock | Arbiter cap transferable via `escrow::transfer_arbiter` | +| unlock | TX1 digest + `UnlockReceipt` | NO for TX1 (single-shot); facilitator rotation per ADR-009 G1 | S11 binds TX2 to TX1 cryptographically | + +**Proof (prepaid case, representative)**: + +``` +PrepaidBalance is a shared object with state: + - authorized_claimants: vector
(or) + - mandate_id: Option (Swee Mandate capability ref) + - last_claimed_counter: u64 (S9 replay bound) + +At claim time, the Move entry function verifies: + let caller = tx_context::sender(ctx) + assert!( vector::contains(&balance.authorized_claimants, &caller) + OR swee_mandate::holds_capability(mandate_id, caller), + EUnauthorizedClaim ) + +If signer rotates from pk_n to pk_{n+1} between claims, the new +signer is admitted iff auth(pk_{n+1}, balance) holds. S9 (monotonic +counter) continues to prevent replay regardless of which authorized +signer claims. Therefore: rotation preserves S1 + S5 + S9 without +truncating the scheme's lifecycle. ∎ +``` + +**What S15 forbids**: +1. SDKs MUST NOT cache "the signer for this stream is pubkey X" in memory and reject later envelopes from a different but still-authorized signer. +2. Scheme Move modules MUST NOT store `signer: address` as sole authority on the shared object. Authority MUST be a capability, a vector, or a mandate reference. +3. SDK-level session objects MUST expose `rotateSigner(newSigner)` OR be constructed with a capability reference rather than a pubkey. + +⚠️ **Limitation**: S15 does not prevent lost authority. An agent that rotates without updating the on-chain authorization predicate will correctly be rejected. This is a coordination concern, not a protocol concern. S15 guarantees the protocol is *capable* of tolerating rotation. + +See ADR-010 for the full discussion, enforcement (`s402/no-captured-signer` lint rule), and the Move capability-authority audit check. + +--- + +## S16. Protocol Version Binding (Safety) + +**Statement**: The protocol version and scheme spec digest that a client commits to at signing time MUST be cryptographically bound into the signed payload, not transmitted only as transport headers. A facilitator that receives a signed payload for scheme X under version `V_old` MUST NOT be able to present it as a payload for scheme X under version `V_new`. + +**Formally**: For all signed payment payloads P with `sig = sign(sk, bytes(P))`: + +``` +bytes(P) includes version_tag (protocol version, e.g. "0.5.0") +bytes(P) includes spec_digest(P.scheme) (SRI-format hash of scheme spec) + +Therefore: + sign(sk, bytes(P_v0.5.0)) ≠ sign(sk, bytes(P_v0.6.0)) + (even when every other field is identical) +``` + +**Motivation**. ADR-006 binds `s402-Version` and `s402-Spec-Digest` into **HTTP transport headers only**. These are not part of the bytes the signer signs over. This leaves a semantic-downgrade attack window: consider scheme `exact` upgrading v0.5.1 → v0.6.0 with a new field. A signed v0.5.1 `exact` payload does not commit to v0.5.1 semantics. A malicious facilitator that speaks v0.6.0 could present the same bytes under v0.6.0 semantics — the signature still verifies (same bytes), but the on-chain interpretation differs. Same bytes, different meaning, unchanged signature. + +**Proof (that S16 closes the semantic-downgrade attack, Sui case)**: + +``` +Attack model: adversarial facilitator F receives client-signed payload P +under v0.5.1 exact semantics. F wants to submit under v0.6.0 semantics +(where v0.6.0 adds, e.g., an optional refund field that F populates to +route funds elsewhere). + +Without S16 (post-ADR-006 state): + Signed bytes B = BCS(TransactionData{ ptb: [ + Move_call(exact::pay, [Coin, recipient, amount]) + ]}) + B contains no commitment to version. F submits B to the chain. + Chain executes whichever version of `exact` is currently published. + Signature verifies against B. Attack succeeds. + +With S16: + Signed bytes B' = BCS(TransactionData{ ptb: [ + Move_call(exact::assert_protocol_version, ["0.5.1", "sha256-a1b2..."]), + Move_call(exact::pay, [Coin, recipient, amount]) + ]}) + B' contains an explicit version assertion as the first PTB instruction. + Chain executes assert_protocol_version, comparing "0.5.1" and + "sha256-a1b2..." against the exact module's compiled-in constants. + If on-chain exact is v0.6.0 without v0.5.1 in supported_versions, the + assertion aborts. Entire PTB reverts. No state change. Attack defeated. ∎ +``` + +**Implementation (Sui)**: every s402 scheme Move module exposes `assert_protocol_version(version, spec_digest)` and SDKs constructing a PTB prepend it as the first instruction. + +**Implementation (non-Sui)**: for chains with opaque-bytes signing APIs (EVM `personal_sign`), binding is achieved by embedding the version tuple inside the signed message under a reserved domain prefix: + +``` +message = "s402-v1\0" + || u32_be(len(version_tag)) || version_tag + || u32_be(len(spec_digest)) || spec_digest + || chain_payload_bytes +``` + +**Facilitator obligation**: on intake, compare `s402-Version` + `s402-Spec-Digest` headers against version/digest embedded in the signed payload; reject with `S402_VERSION_MISMATCH` (400) on disagreement. + +**Client obligation**: after receiving a `settled` envelope, verify `envelope.specDigest` equals the digest the client bound into its own signed payload (constant-time compare, per S14). + +⚠️ **Limitation**: +1. Sui: adds one extra PTB command (~300 gas, ~6% overhead on minimal `exact` settlement). +2. Non-Sui: enforcement is facilitator-layer, not chain-layer. A facilitator that strips the version prefix before on-chain submission breaks S16 — detected only if the client runs S8-style digest verification. +3. Presumes a trustworthy scheme-digest registry (ADR-006 history trust model). + +See ADR-010 for the full implementation spec, `s402/require-version-assertion` lint rule, and the Move CI audit requirement. + +--- + ## Assumptions 1. **Wall-clock nature of Date.now()**: `Date.now()` is wall time and CAN go backwards. Under NTP discipline, backward motion is slewed (not stepped); absent NTP, the S1 proof degrades. See S1 Assumption block for the detailed threat model. @@ -439,3 +578,5 @@ verifySettlement( 4. **TLS transport**: HTTP payloads are not tampered with in transit (HTTPS assumed). 5. **Blake2b-256 collision resistance**: S8's digest-binding argument depends on blake2b-256 being collision-resistant at the ~2^128 work level. 6. **Ed25519 signature unforgeability**: The `exact` scheme's replay defense depends on the facilitator being unable to forge a signature over mutated transaction bytes. +7. **On-chain authority evaluation (S15)**: Move's `tx_context::sender` correctly reflects the transaction's signer at the point of capability/vector lookup, and Sui validators reject transactions whose signer fails the scheme's on-chain authorization predicate. +8. **Scheme-digest registry integrity (S16)**: The `supported_versions` constants compiled into a scheme's on-chain Move package at publish time accurately reflect the canonical spec digest for each advertised version. A backdoored registry defeats S16 — this is the ADR-006 history trust model problem, out of scope for S16 itself. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..18adf2f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,143 @@ +# s402 Roadmap + +> This is the public roadmap for s402 — the HTTP wire protocol for agent payments. +> It is forward-looking; nothing here is a commitment to ship on a specific date. +> The authoritative source for shipped behavior is `INVARIANTS.md` and the ADRs under `docs/adr/`. + +--- + +## Principles that shape this roadmap + +1. **s402 is a wire protocol, not a product.** The roadmap lists protocol changes (formats, invariants, amendment-chain additions), not infrastructure. Facilitator operations and SDK features live in SweeFi and the chain-adapter repos. +2. **Interop when possible, superset when wise.** Each item is evaluated against the interop-superset principle (ADR-005). We absorb x402 / MPP / ACP / A2A formats where they're good; we exceed them where their business models forbid. +3. **Invariants don't inflate.** Every new safety property is measured against the existing invariant set. Redundant invariants are rejected — see the delta analysis in ADR-010 for the Council's dropped proposals. +4. **Chain-agnosticism is non-negotiable.** S7 is enforced repo-wide. Every protocol change must be expressible without chain-specific imports in `typescript/src/`. + +--- + +## Shipped (as of 2026-04-21) + +- **Invariants S1–S14** (INVARIANTS.md + ADR-008): stale rejection, trust boundary, scheme irreducibility, error recoverability, dedup, x402 roundtrip, chain-agnostic boundary, facilitator accountability, replay bounds, extension additivity, unlock-TX2 causal binding, adversarial catalogue scaffolding, cross-scheme composition guidance, constant-time comparison. +- **Five schemes** (`exact`, `prepaid`, `stream`, `escrow`, `unlock`) with S3 irreducibility proof. +- **x402 v1/v2 compatibility** (`compat.ts` + 42 roundtrip tests). +- **Version negotiation** (ADR-006): `s402-Version` + `s402-Spec-Digest` headers, discovery document, amendment chain. +- **Settlement envelope** (ADR-007): chain-agnostic discriminated union with `txBinding`, inline attestation for unlock, `Idempotency-Key` semantics. + +--- + +## v0.6.0 — hardening + +Target: close all Wave 3/4 adversarial-review gaps identified in the April 2026 /vet pass. ADR-references are the source of truth for each item. + +- **Adversarial test catalogue** (S12 / ADR-008): ship documented attack vectors per scheme at `spec/vectors//adversarial/`. Each MUST-fail vector is wired to a specific invariant. +- **S11 inline attestation** (ADR-008): unlock-TX2 attestation lands in the envelope, not at a separate URL. Client verification non-bypassable by construction. +- **Extension additivity runtime check** (S10): `typescript/src/extensions.ts` rejects extensions whose declared effects violate the additivity rule. +- **Canonicalization spec finalized** (`spec/canonicalization.md`): RFC 8785 JCS, domain-separation prefix registry, duplicate-key rejection. +- **Legacy `s402SettleResponse` deprecation warning**: v0.5.9 emits structured warning; v0.6.0 defaults to envelope; legacy still accepted via `Accept: application/json`. + +--- + +## v0.6.1 — S15/S16 docs + +Target: write the two new invariants into INVARIANTS.md and land the ADR. Low code, high signal. + +- **S15 Mid-Session Signer Rotation** (ADR-010): session bound to mandate/shared-object, not to signer pubkey. Sharpest consequence: zkLogin ephemeral key cycling is now explicitly tolerated for all long-running schemes. +- **S16 Protocol Version Binding** (ADR-010): version + scheme digest bound into signed payload (not only transport headers). Closes semantic-downgrade attacks across scheme amendments. +- **S5 + S8 augmentations**: reference cross-process idempotency (ADR-007 `Idempotency-Key`) and S15 interaction. + +--- + +## v0.6.2 — S16 Sui enforcement + +Target: turn S16 from documentation into on-chain enforcement for the Sui adapter. + +- **`assert_protocol_version` Move helpers** across all scheme modules in `@sweefi/sui/move/`. +- **SDK PTB construction** prepends the version assertion as the first instruction. +- **Lint**: `s402/require-version-assertion` flags any PTB builder that doesn't include the assertion. +- **Conformance vector** `adversarial/version-strip-v05-presents-as-v06.json` — MUST fail on-chain. + +--- + +## v0.7.0 — federation + session rotation + +Target: close ADR-009 G1 (facilitator key rotation) and formalize the `s402ClientSession` API with explicit rotation support. + +- **Facilitator key rotation** (ADR-009 G1): JWKS-style rotating set with overlap windows, OR CRL/OCSP revocation channel, OR Sigstore-anchored trust root. Implementation choice locked in a follow-up ADR (tentatively ADR-011). +- **`s402ClientSession` interface**: explicit `rotateSigner(newSigner)` method. Capture-by-pubkey is lint-rejected. +- **S13 composition linter** (`@sweefi/sdk`): detect unsafe bridging patterns at SDK-emit time. +- **MCP registry listing** for the reference facilitator. +- **Multi-facilitator threshold attestations** (reserved in ADR-007's `facilitatorIds: string[]` array shape, implemented here). + +--- + +## Parked — real but not yet scheduled + +These are items the project believes are worth doing but are blocked on prerequisites (ecosystem readiness, another chain adapter, a concrete proposal, etc). Parked ≠ abandoned. Each parked item has a trigger condition that will move it to scheduled. + +### P1. Privacy Scheme + +**Why parked.** s402's wire format exposes payment metadata in HTTP headers and in signed payloads. For human-facing checkouts this is fine; for agent-to-agent flows and for use cases where observer analysis of spending is a risk (agent swarms, compliance-sensitive SaaS), a privacy-preserving scheme variant is genuinely useful. + +**Trigger to schedule**: Sui's active privacy-primitive development (Seal, zkLogin, research publications) matures to a stable, documented primitive suitable for integration. When that lands, P1 becomes a scheme-proposal under ADR-009 G3 (acceptance process). + +**Sketch of scope (not a commitment):** +- A sixth scheme `private` that uses Sui's privacy primitive to hide the `payTo` recipient, the amount, or both. +- Corresponding x402-compat behavior: the `private` scheme is s402-only (no x402 roundtrip) — this is a superset case, not an interop case. +- S10 extension-additivity applies: privacy MUST NOT relax any safety invariant; it can only add structural constraints. + +**Related**: ADR-009 G3 (scheme acceptance process), INVARIANTS §S3 (irreducibility proof obligation). + +### P2. Sixth-scheme acceptance process (ADR-009 G3) + +**Why parked.** A formal acceptance process is governance work. The project's stance is that governance should emerge from concrete usage, not be imposed preemptively. When a concrete proposal for scheme #6 (P1, Lightning invoice, EIP-7702 batched, intent-auctions, or something unforeseen) reaches RFC quality, the process gets built around it. + +**Trigger to schedule**: first substantive community RFC for a sixth scheme. + +### P3. Non-Sui chain adapters + +**Why parked.** s402 is chain-agnostic by construction (S7), but each new chain requires an adapter package that narrows the envelope's chain-specific fields and implements S8-style digest binding in that chain's signing API. The first non-Sui adapter drives ADR-012 (chain-reorg tolerance, ADR-009 G2). + +**Trigger to schedule**: a concrete integration demand — either a partner who ships on Solana/EVM, or a use case we want to dogfood that requires it. + +**Order of likely adoption** (if forced to rank): Solana (close finality semantics to Sui) > EVM L2 (Base, where x402 already runs) > EVM L1. + +### P4. Envelope JWS signing + +**Why parked.** ADR-007 considered signing envelopes at the protocol layer and rejected it for v0.6.0 on the grounds that signing keys are operator concerns (SweeFi layer). When SweeFi publishes a facilitator-identity JWS scheme, s402 can wrap the envelope without a wire break. + +**Trigger to schedule**: SweeFi ADR defining the facilitator identity document format. + +### P5. Post-quantum algorithm migration + +**Why parked.** ADR-007's `algs` field reserves `ml-dsa-44` as a signature algorithm identifier. The field is forward-compatible, so migration is mechanical once NIST-standardized primitives have library support in Node's `crypto` and in Sui's validators. No protocol work needed today. + +**Trigger to schedule**: post-quantum primitive reaches production-grade library support AND threat model motivation (currently speculative — "store now, decrypt later" is the only argued-real threat and even that is decades out for ed25519 with ~256-bit security margin). + +--- + +## Non-goals (and why) + +- **Mandate issuance / revocation as an s402 primitive.** Belongs in Swee Mandate (SweeFi Move layer). s402 transports payments that reference capabilities; it does not issue or revoke them. +- **Settlement finality definition.** Chain-specific. Each chain adapter documents its finality threshold in its README (ADR-009 G2). +- **Key custody / wallet management.** Operator concern, not wire-protocol concern. +- **Pricing / fee schedules.** Market concern, not wire-protocol concern. +- **Human-facing checkout UI.** Belongs in Swee Pay (a SweeFi product); s402 is the wire underneath. + +--- + +## How to propose a change + +1. **Bug or tightening**: open an issue at `github.com/Danny-Devs/s402`. Include a repro or a failing conformance vector. +2. **New invariant**: open an ADR PR following the pattern of ADR-008 and ADR-010 — delta analysis first (what's already covered), then the proof, then enforcement. +3. **New scheme**: open a Discussion for a 30-day community window (ADR-009 G3), then an ADR with an S3 irreducibility proof. +4. **Non-Sui chain adapter**: start with the adapter repo; s402 protocol changes should be rare and will be driven by ADR-012. + +--- + +## References + +- `INVARIANTS.md` — the invariant system (S1–S16). +- `docs/adr/` — architecture decision records. +- `docs/THREAT_MODEL.md` — adversarial model. +- `spec/canonicalization.md` — canonical encoding rules. +- `docs/schemes/` — per-scheme canonical specs. diff --git a/docs/adr/010-safety-invariants-s15-s16.md b/docs/adr/010-safety-invariants-s15-s16.md new file mode 100644 index 0000000..aac5239 --- /dev/null +++ b/docs/adr/010-safety-invariants-s15-s16.md @@ -0,0 +1,289 @@ +# ADR-010: Safety Invariants S15–S16 — Session Binding and Version Binding + +**Status:** Draft +**Date:** 2026-04-21 +**Related:** INVARIANTS.md (S1–S8), ADR-006 (Version Negotiation), ADR-007 (Settlement Envelope), ADR-008 (Safety Invariants S9–S14), ADR-009 (Open Gaps) + +--- + +## Context + +After the Wave 4 Council review of April 2026, five candidate safety properties were proposed as potentially missing from s402: **atomicity**, **auditability**, **version binding**, **cross-process idempotency**, and **revocability**. A disciplined delta analysis against existing invariants and ADRs reveals only two are genuine gaps: + +| Candidate | Status | Reason | +|-----------|--------|--------| +| Atomicity | ❌ Already covered | S8 (digest binding) + ADR-007 §`txBinding` together give "if `settled = true`, the claim is cryptographically verifiable." Adding a separate atomicity invariant would be rhetoric not substance. | +| Auditability | ❌ Already covered | S11 (unlock-TX2 attestation chain) + ADR-007 envelope (`facilitatorIds`, `txBinding`, typed status) already give structured audit records per settlement. | +| Version binding | ✅ **Genuine gap** | ADR-006 puts `s402-Version` and `s402-Spec-Digest` in **HTTP transport headers only**. These are not in the signed payload, so a signature produced under v0.5 scheme semantics is cryptographically indistinguishable from the same bytes interpreted under a future v0.6 scheme. | +| Cross-process idempotency | ❌ Already covered | ADR-007 §"Idempotency semantics" (lines 252–279) defines the `Idempotency-Key` header with facilitator-side dedup cache semantics. Combined with S5 (per-process in-flight set) and Sui validator dedup, the retry story is covered end-to-end. | +| Revocability | ❌ Out of scope for s402 | Revocation is a **capability-layer** concern belonging to the Swee Mandate Move module (SweeFi) and to facilitator identity rotation (ADR-009 G1). s402 is a wire protocol — it transports payments, it does not issue the authorities being revoked. | + +In addition, agentic systems in production rotate signing addresses mid-session for five distinct reasons: (1) zkLogin ephemeral key expiry on Sui, (2) session-key rotation (cold key in vault, hot key signs), (3) OAuth-style mandate refresh, (4) privacy rotation (agent cycles signers every N txs to avoid address correlation), (5) agent handoff. Long-running schemes (`stream`, `prepaid`, `escrow`) are designed to outlive single transactions — and therefore single signers. The wire protocol today implicitly assumes signer identity is stable across the lifecycle of a scheme's shared-object state machine. **That assumption is wrong for the Sui case, where zkLogin ephemeral keys cycle on epochs by construction.** This is the "QUIC connection migration" problem restated in payment terms. + +This ADR closes both gaps. + +--- + +## Decision + +Add two new invariants to `typescript/INVARIANTS.md`: + +- **S15 — Mid-Session Signer Rotation** (Safety): bind long-running scheme state to the mandate/capability object, not to the signer's public key. +- **S16 — Protocol Version Binding** (Safety): bind the protocol version and scheme digest into the signed payload, not only into transport headers. + +Also make two small augmentations to existing invariants to acknowledge partial-coverage relationships: + +- **S5** — add a one-paragraph note referencing ADR-007's `Idempotency-Key` extension for cross-process dedup. +- **S8** — add a one-paragraph note referencing S15 for the long-lived scheme case. + +### S15. Mid-Session Signer Rotation (Safety) + +**Statement.** For any long-running scheme whose state persists across multiple on-chain transactions (`stream`, `prepaid`, `escrow`, `unlock` multi-phase), the protocol MUST bind scheme state to a stable **session anchor** (mandate ID, or the scheme's shared-object ID) rather than to the signer's public key. Rotation of the signer mid-session MUST NOT invalidate, truncate, or double-bill the in-flight scheme, provided the new signer is authorized by the session anchor. + +**Formally.** For a scheme instance `S` with state-bearing object `obj(S)` (a shared `Stream`, `PrepaidBalance`, `Escrow`, or mandate capability): + +``` +Let pk_n = signer pubkey at the n-th transaction in S's lifecycle. +Let auth(pk, obj) = does the on-chain authorization predicate on obj(S) admit pk? + + For all n, m where tx_n and tx_m act on obj(S): + settlement(tx_n) succeeds ⟺ auth(pk_n, obj(S)) = true + settlement(tx_m) succeeds ⟺ auth(pk_m, obj(S)) = true + pk_n ≠ pk_m does NOT imply invalidation or double-billing of S +``` + +Equivalently: the state machine of a scheme depends on its shared object's state, not on "same signer proved it last time." + +**Per-scheme cases.** + +| Scheme | Session anchor | Rotation tolerated? | Authorization predicate | +|--------|---------------|---------------------|------------------------| +| exact | None (one-shot) | N/A — scheme is a single tx | Signer signs bytes | +| prepaid | `PrepaidBalance` object ID | YES | On-chain check: new signer pubkey is in `balance.authorized_claimants` OR holds the mandate capability referenced by `balance.mandate_id` | +| stream | `Stream` object ID | YES | On-chain check: new signer holds the stream's withdrawal capability OR matches `stream.provider` | +| escrow | `Escrow` object ID | PARTIAL — rotation of *arbiter* tolerated; rotation of *payer* or *payee* is a new escrow | Arbiter capability is transferable per `escrow::transfer_arbiter`; payer/payee are fixed at lock time | +| unlock | TX1 digest + `UnlockReceipt` object | NO for TX1 (single-shot); YES for TX2 facilitator identity per ADR-009 G1 | S11 attestation binds TX2 to TX1 cryptographically; facilitator pubkey rotation is ADR-009 G1 | + +**Proof sketch (prepaid case, representative):** + +``` +PrepaidBalance is a shared object. Its state includes: + - authorized_claimants: vector
(or) + - mandate_id: Option (reference to a Swee Mandate capability) + - last_claimed_counter: u64 (S9 replay bound) + +At claim time, the Move entry function verifies: + let caller = tx_context::sender(ctx) + assert!( vector::contains(&balance.authorized_claimants, &caller) + OR swee_mandate::holds_capability(mandate_id, caller), + EUnauthorizedClaim ) + +Call this predicate auth(caller, balance). Move's type system and the +call's tx_context guarantee caller is the current-tx signer. + +If at tx_n the signer rotates from pk_n to pk_{n+1}, the new signer +is admitted iff auth(pk_{n+1}, balance) holds. S9 (monotonic counter) +continues to prevent replay regardless of which authorized signer claims. +Therefore: rotation preserves S1 (expiry), S5 (dedup), S9 (replay) without +truncating the scheme's lifecycle. ∎ +``` + +**What S15 forbids.** + +1. s402 SDKs MUST NOT cache "the signer for this stream is pubkey X" in memory and reject later envelopes that come from a different signer but the same session anchor. +2. Scheme Move modules MUST NOT store `signer: address` as the single authority on the shared object. Authority MUST be a capability, a vector, or a mandate reference — something that can name multiple pubkeys or be transferred. +3. SDK-level session objects (e.g., `s402ClientSession`) MUST expose a `rotateSigner(newSigner)` method OR MUST be constructed with a capability reference rather than a pubkey. + +**Interaction with existing invariants.** + +- **S8 (facilitator accountability / digest binding)** — unaffected: each individual tx's digest is still a pure function of the signed bytes, regardless of which pubkey signed. S15 operates one layer up, at the scheme-lifecycle level. +- **S9 (replay bounds)** — unaffected: monotonic counters and per-object state dedup are signer-agnostic. +- **S11 (unlock-TX2 attestation)** — clarified: TX1's signer may be different from the signer that later consumes the released key, provided the key consumer holds the unlock's recipient capability. + +**Limitation (⚠️).** S15 does not *prevent* lost authority — if an agent rotates to a new signer without updating the on-chain authorization predicate, the new signer is correctly rejected. This is a coordination concern (agent must update on-chain authority before rotating), not a protocol concern. S15 guarantees the protocol is *capable* of tolerating rotation; it does not hand-hold operators through the ceremony. + +**Enforcement.** + +- **Lint.** `@sweefi/eslint-config` adds a rule that flags any SDK-level `const signer = session.signer` pattern captured outside of a per-transaction scope. Sessions expose `getActiveSigner()` (per-tx, re-evaluated); capturing a signer reference for the lifetime of a session is forbidden. +- **Move-side.** Move modules in `@sweefi/sui` that implement scheme state objects MUST use a capability-based authority pattern (`TransferCap`, `ClaimCap`, or a mandate reference). An audit check on PRs to `sweefi/move/*.move` verifies no scheme stores `signer: address` as sole authority. +- **Conformance.** Each long-running scheme's adversarial test catalogue (S12) MUST include a "rotate signer mid-session" vector: initiate with signer A, complete with signer B where B is authorized by the session anchor. Vector MUST pass (not fail). + +--- + +### S16. Protocol Version Binding (Safety) + +**Statement.** The protocol version and the scheme spec digest that a client commits to when signing MUST be cryptographically bound into the signed payload, not transmitted only as transport headers. A facilitator that receives a signed payload for scheme `X` under version `V_old` MUST NOT be able to present it as a payload for scheme `X` under version `V_new` (even if `V_new` is a valid future upgrade of `X`). + +**Formally.** For all signed payment payloads `P` with signing operation `sig = sign(sk, bytes(P))`: + +``` +bytes(P) includes version_tag (protocol version, e.g. "0.5.0") +bytes(P) includes spec_digest(P.scheme) (SRI-format content-hash of the scheme spec) + +Therefore: + sign(sk, bytes(P_v0.5.0)) ≠ sign(sk, bytes(P_v0.6.0)) +even when every other field is identical. + +A v0.6.0-aware facilitator that receives P_v0.5.0 MUST: + (a) verify sig against the v0.5.0-tagged bytes, not re-tag as v0.6.0, AND + (b) execute the v0.5.0 semantics for P.scheme, not v0.6.0's semantics. +``` + +**Why this matters.** ADR-006 puts `s402-Version` and `s402-Spec-Digest` in HTTP headers and optionally in the `PaymentRequirements` JSON body. A header is a transport assertion made by the facilitator or the client at request time; it is not part of the bytes the signer signs over. Consider a scheme amendment where `v0.5.1 → v0.6.0` changes the `exact` scheme to require an extra field (say, `intent_scope_version`). A signed v0.5.1 `exact` payload does not commit to v0.5.1 semantics. A malicious facilitator that speaks v0.6.0 could present the same bytes to the chain under v0.6.0 semantics — the signature still verifies (same bytes), but the on-chain interpretation differs. This is a **semantic downgrade attack**: same bytes, different meaning, unchanged signature. + +**Implementation — for the `exact` scheme on Sui.** + +The client-signed `bytes(P)` is the BCS-encoded Sui `TransactionData`. The protocol version cannot be added to Sui's native TransactionData without breaking Sui's own signing rules, so the binding lives one layer out, in the PTB itself: + +- Every s402 Move scheme module MUST expose a `fn assert_protocol_version(version: vector, spec_digest: vector)` entry function. +- SDKs constructing a PTB for an s402 payment MUST prepend a call to `assert_protocol_version(version, spec_digest)` as the first instruction in the PTB. +- The Move function verifies the passed version + digest against module constants compiled into the scheme's on-chain package at its publication time. +- If the version/digest do not match, the entry function aborts — the entire PTB fails atomically. + +Result: the signed `TransactionData` BCS bytes now include a call to `assert_protocol_version(v0.5.1, sha256-a1b2…)`. A facilitator trying to present the same bytes under v0.6.0 semantics cannot — the on-chain assertion aborts. + +**Implementation — for non-Sui chains.** + +For chains whose signing bytes are less flexible than Sui's (e.g., EVM where a `personal_sign` wraps an opaque string), the binding is achieved by embedding the version tuple **inside the signed message text** under a reserved domain prefix: + +``` +message-to-sign = "s402-v1\0" + || version_tag_length (u32 BE) || version_tag + || spec_digest_length (u32 BE) || spec_digest + || chain_payload_bytes +``` + +Where `chain_payload_bytes` is the chain-native request (e.g., EIP-712 typed struct for EVM, Solana transaction bytes). The domain prefix `"s402-v1\0"` ensures this message cannot collide with any non-s402 signing context. + +**Verification obligation (client).** After receiving a `settled` envelope, the client (in addition to the 8 checks already required by ADR-007) MUST verify that the `specDigest` in the envelope equals the digest the client bound into its own signed payload. Envelope-level and payload-level digests MUST agree; disagreement proves a rogue facilitator or a cross-version attack. + +**Verification obligation (facilitator).** On intake: +1. Compare the `s402-Version` and `s402-Spec-Digest` headers against the version/digest embedded in the signed payload. +2. If headers disagree with embedded values, reject with `S402_VERSION_MISMATCH` (400). +3. Only after headers-vs-payload consistency checks pass may the facilitator dispatch to scheme-specific logic. + +**Proof (that S16 closes the semantic-downgrade attack):** + +``` +Attack model: Adversarial facilitator F receives a client-signed +payload P under v0.5.1 exact semantics. F wishes to present P to the +chain under v0.6.0 exact semantics (where the semantics have diverged +in a way that benefits F — e.g., v0.6.0 includes an optional refund +field that F populates to route funds elsewhere). + +Without S16 (current state, post-ADR-006): + Signed bytes B = BCS(TransactionData{ inputs: [...], ptb: [ + Move_call(exact::pay, [Coin, recipient, amount]) + ]}) + B contains no commitment to version. + F submits B to the chain. + Chain executes v0.6.0 of exact (because that's what's published). + Signature verifies against B. Attack succeeds. + +With S16 (post this ADR): + Signed bytes B' = BCS(TransactionData{ inputs: [...], ptb: [ + Move_call(exact::assert_protocol_version, ["0.5.1", "sha256-a1b2..."]), + Move_call(exact::pay, [Coin, recipient, amount]) + ]}) + B' contains an explicit version assertion as the first PTB instruction. + F submits B' to the chain. + Chain executes assert_protocol_version, which compares "0.5.1" and + "sha256-a1b2..." against the exact module's compiled-in constants. + If the on-chain exact module has been upgraded to v0.6.0 without + preserving v0.5.1 as a supported version, the assertion aborts. + The entire PTB reverts. No state change. Attack defeated. ∎ +``` + +**Implementation implications for scheme maintainers.** + +Each scheme Move module publishes a `supported_versions: vector` constant at package publish time. Scheme upgrades MAY preserve older versions in this vector (allowing in-flight signed payloads to continue settling under their signed semantics) OR MAY drop older versions (forcing all clients to re-sign under the new version). The choice is per-upgrade and documented in the amendment chain (ADR-006). + +**Interaction with existing invariants.** + +- **S6 (x402 roundtrip)** — x402 responses that do not carry s402 version metadata will fail S16 on return trip if the facilitator ingesting them is enforcing version binding. Mitigation: compat layer in `typescript/src/compat.ts` tags x402-sourced payloads with a reserved version tag `"x402-compat-v"` during ingestion. +- **S9 (replay bounds)** — version binding is orthogonal. A payload signed under v0.5.1 cannot be replayed under v0.6.0 semantics; within v0.5.1 itself, S9 still governs. +- **S10 (extension additivity)** — extensions MUST also bind their own version into the signed payload via an analogous `assert_extension_version` call. An extension upgrade that doesn't preserve backward version compatibility is a breaking change. + +**Limitation (⚠️).** +1. S16's Sui implementation costs one extra PTB command (~300 gas units). Measured cost at v0.6.0 release: ~6% overhead on the minimal `exact` settlement. +2. Non-Sui chains with signed-opaque-bytes APIs (EVM `personal_sign`) have weaker guarantees: the binding is enforced at the facilitator layer on ingress, not at the chain itself. A facilitator that strips the version prefix before on-chain submission breaks S16 — detected only if the client runs an S8-style digest verification. +3. S16 presumes a trustworthy scheme-digest registry. If a malicious maintainer backdoors the scheme's published spec without bumping the digest, the binding binds to the wrong content. This is ADR-006's "history trust model" problem and is out of scope here. + +**Enforcement.** + +- **Lint.** `@sweefi/eslint-config` rule `s402/require-version-assertion` flags any PTB constructor in `@sweefi/sui/src/s402/**/client.ts` that doesn't prepend `assert_protocol_version` as the first instruction. +- **Test.** Conformance vector set includes `adversarial/version-strip-v05-presents-as-v06.json`: a valid v0.5.1 signed payload that a malicious facilitator attempts to execute under v0.6.0 semantics. Vector MUST fail (on-chain abort). +- **Move CI.** An audit check on `@sweefi/sui/move/**` modules verifies every scheme module exports `assert_protocol_version` and every scheme module's entry functions are unreachable without first calling it. + +--- + +## Small augmentations to existing invariants + +### S5 (Concurrent Payment Deduplication) — partial-coverage note + +Append to S5's existing `⚠️ Limitation` block: + +> **Cross-process dedup.** S5's in-flight Set provides per-process safety. Multi-instance facilitator deployments (e.g., federated or horizontally-scaled facilitators) obtain cross-process dedup via the `Idempotency-Key` header defined in ADR-007 §"Idempotency semantics", with a shared dedup cache (Redis, DynamoDB, or equivalent) keyed on the `Idempotency-Key` value. S5 itself does not mandate shared caching — operators who run multiple instances without a shared cache rely on Sui validator dedup as the final safety net. + +### S8 (Facilitator Accountability) — S15 note + +Append to S8's **Scope — which schemes does S8 cover today?** table a note: + +> ⚠️ **S8 × S15 interaction.** S8 binds a single transaction's digest to the signer of *that* transaction. Long-running schemes (`stream`, `prepaid`, `escrow`, multi-phase `unlock`) span many transactions and may be signed by multiple pubkeys across their lifecycle (see S15). Each individual transaction's S8 binding remains intact — the long-lived scheme's *authority* is independently tracked per S15 via session-anchor capabilities. S8 is per-tx; S15 is per-session. Both hold; neither subsumes the other. + +--- + +## Alternatives considered + +**A1. Bind version via JWS signed metadata.** Rejected for the same reason ADR-006 rejected it: signing does not solve "what version does the other side speak." Binding lives in the payload being signed, not in a separate JWS envelope. + +**A2. Bind version at the HTTP layer only (current ADR-006 state).** Rejected here — headers are not part of the signed bytes, so semantic-downgrade attacks are feasible across scheme-amendment boundaries. This ADR closes that door. + +**A3. Add a generic `Session-Id` header to preserve session across signer rotation.** Rejected — a header is not a cryptographic commitment. The session anchor must be an on-chain object whose authority predicate is evaluated at every tx. S15 formalizes this; a session-id header is at best a SDK convenience. + +**A4. Require every scheme to adopt a single authority model (e.g., always a capability, never a vector).** Rejected for v0.6.0 — different schemes have different authority semantics. A single model forces premature generalization. S15 states the invariant; each scheme picks the predicate that fits its semantics. + +**A5. Drop S16 entirely; rely on facilitators to check version headers honestly.** Rejected — the whole point of s402 invariants is to hold in the presence of adversarial facilitators. S8 exists because the same argument was made there, and the response was "cryptographic binding is the answer, not facilitator trust." S16 is the analogous answer for version drift. + +**A6. Add all five Council proposals (atomicity, auditability, version binding, idempotency, revocability) as new invariants.** Rejected — the delta analysis in **Context** shows three are already covered or out of scope. Invariant inflation reduces signal-to-noise without adding security. + +--- + +## Consequences + +**Positive:** +- The wallet-rotation-mid-session attack on long-running schemes now has a formal protocol response. +- The semantic-downgrade attack (same bytes, different version semantics) is closed at the Sui layer and mitigated for other chains. +- Two Council-identified concerns are addressed; three are explicitly shown to be already covered (prevents future re-litigation). +- Discipline: rejecting redundant invariants keeps the invariant system dense and learnable. + +**Negative:** +- S15 adds an implementation burden to all long-running scheme Move modules (capability-based authority, not address). +- S16 adds ~300 gas (~6%) to every Sui settlement as an extra PTB instruction. +- S16 non-Sui enforcement is weaker (facilitator-layer, not chain-layer) until each chain's signing API is reviewed for domain-prefix compatibility. +- Scheme amendment process (ADR-006) becomes more consequential — a scheme upgrade that drops older versions from `supported_versions` breaks in-flight signed payloads. + +**Neutral:** +- Invariants are documentation + CI obligations, not runtime cost on the critical path (except S16's one PTB instruction). + +--- + +## Implementation sequencing + +1. **Phase 1 (v0.6.1 docs):** Write S15 + S16 into INVARIANTS.md. Augment S5 and S8 with the notes defined above. Low effort, high signal. +2. **Phase 2 (v0.6.2 Sui):** Ship `assert_protocol_version` Move helpers across all scheme modules in `@sweefi/sui`; SDKs prepend it to every PTB. Lint rule `s402/require-version-assertion` lands here. +3. **Phase 3 (v0.7.0 multi-signer):** Formalize the `s402ClientSession` interface with explicit rotation support. Add `rotate-signer-mid-stream.json` conformance vector. +4. **Phase 4 (v0.7.x other chains):** As second chain adapter lands (Solana or EVM), adapt S16's non-native binding to that chain's signing API. Document the chain-specific weakening in the chain adapter's README. + +--- + +## References + +- s402 INVARIANTS.md §S1–S14 +- s402 ADR-006 (Version Negotiation and Scheme Digests) +- s402 ADR-007 (Settlement Response Envelope) — §"Idempotency semantics" explains why cross-process idempotency is out of scope for this ADR +- s402 ADR-008 (Safety Invariants S9–S14) +- s402 ADR-009 (Open Gaps) — G1 (facilitator key rotation) is the analogous concern one layer up +- Sui zkLogin specification — ephemeral key epoch semantics +- RFC 9000 (QUIC) §9 — connection migration as the transport-layer precedent for session-vs-endpoint binding +- OAuth 2.0 RFC 6749 §1.5 — refresh token pattern as the capability-layer precedent for authority rotation without session loss From fb575d37e48fb2c3c50adfdd9c5df21a6d357871 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Tue, 21 Apr 2026 21:52:34 -0700 Subject: [PATCH 5/7] refactor(compat): reorganize into src/compat/ + harden L402 translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes. Tests (988) + typecheck + build all clean. ## Reorganization — flat files to src/compat/ subdirectory Before: src/compat.ts, src/compat-mpp.ts, src/compat-l402.ts After: src/compat/x402.ts, src/compat/mpp.ts, src/compat/l402.ts Matching sub-path exports rewritten to s402/compat/{x402,mpp,l402}. The old unlabeled s402/compat (x402-by-default) is gone — x402 is now explicit, which is the honest naming. Pre-1.0 minor bump (0.6.x → 0.7.0) licenses the rename; no backward-compat aliases shipped. Git rename detection preserves blame. Internal relative imports updated to ../X.js. All doc examples + source comments rewritten. ## Hardening — compat-l402 1. expiresAt now stamped on L402-derived requirements. Conservative 60s window because the BOLT-11 expiry tag lives in tagged-fields past the `1` separator (out of scope for the partial HRP-only decoder). Keeps S1 (stale payment rejection) load-bearing against stale-invoice replay. Tradeoff: long-lived invoices rejected after 60s → one extra 402 re-fetch, not a payment failure. 2. Canonical BOLT-11 signet prefix `lntbs` now recognized (current spec, core-lightning, recent LND). Legacy `lnsb` prefix retained as alias for older LND emissions. Regex alternation reordered (longest first) to prevent shadow-matching. 3. HRP_PATTERN gained a clarifying comment about the intentional HRP-only validation scope. ## Tests +3 in compat-l402.test.ts (expiresAt fresh-stamp, expiresAt window, canonical-signet vs legacy-signet aliasing). 32 L402 tests total. All import paths updated across 5 test files. Co-Authored-By: Claude Opus 4.7 --- docs/adr/005-interop-superset-principle.md | 4 +- docs/api/classes.md | 2 +- docs/api/compat.md | 6 +-- docs/architecture.md | 2 +- docs/comparison.md | 4 +- docs/guide/quickstart.md | 4 +- docs/guide/the-s402-story.md | 8 ++-- docs/guide/upgrade-l402.md | 8 ++-- docs/guide/upgrade-mpp.md | 8 ++-- docs/guide/upgrade-x402.md | 4 +- docs/guide/why-s402.md | 2 +- docs/integrations.md | 18 ++++---- docs/positioning.md | 16 ++++---- docs/whitepaper.md | 6 +-- typescript/CHANGELOG.md | 11 ++++- typescript/package.json | 24 +++++------ typescript/src/client.ts | 2 +- .../src/{compat-l402.ts => compat/l402.ts} | 41 +++++++++++++++++-- .../src/{compat-mpp.ts => compat/mpp.ts} | 8 ++-- typescript/src/{compat.ts => compat/x402.ts} | 10 ++--- typescript/src/http.ts | 2 +- typescript/src/index.ts | 6 +-- typescript/test/audit2-findings.test.ts | 2 +- typescript/test/compat-l402.test.ts | 41 +++++++++++++++---- typescript/test/compat-mpp.test.ts | 4 +- typescript/test/compat.test.ts | 2 +- .../test/conformance/conformance.test.ts | 2 +- typescript/test/fuzz.test.ts | 2 +- typescript/tsdown.config.ts | 6 +-- 29 files changed, 161 insertions(+), 94 deletions(-) rename typescript/src/{compat-l402.ts => compat/l402.ts} (86%) rename typescript/src/{compat-mpp.ts => compat/mpp.ts} (99%) rename typescript/src/{compat.ts => compat/x402.ts} (98%) diff --git a/docs/adr/005-interop-superset-principle.md b/docs/adr/005-interop-superset-principle.md index 5ec96ef..3907404 100644 --- a/docs/adr/005-interop-superset-principle.md +++ b/docs/adr/005-interop-superset-principle.md @@ -26,7 +26,7 @@ But expressiveness alone is insufficient if adopters have to *choose* between pr Where a competing protocol has a legitimate design that s402 can accept without compromise, s402 **absorbs** the competing protocol as a payment-in format. Specifically: -1. **x402 compatibility** — `s402/compat` normalizes x402 `exact` and `upto` payloads to s402 payloads. An s402 resource server accepts x402 clients transparently. Already shipped (ADR-001 §3). +1. **x402 compatibility** — `s402/compat/x402` normalizes x402 `exact` and `upto` payloads to s402 payloads. An s402 resource server accepts x402 clients transparently. Already shipped (ADR-001 §3). 2. **MPP compatibility** — a future `@sweefi/mpp-adapter` will accept MPP intent payloads and convert to s402 payment payloads where semantics map cleanly. Intents that cannot map (e.g., scheme-specific Stripe-only fields) return `UNSUPPORTED_INTENT` with clear guidance. 3. **Future protocols** — the compat layer is the contract; new protocols get adapters in `@sweefi/*-adapter` packages, never in the `s402` core. @@ -61,7 +61,7 @@ This includes: s402 README, SweeFi README, talks (AIEWF 2026 pitch), ADRs, blog ## Alternatives Considered **Alt A — Pure superset (no interop layer).** -Rejected. Without `s402/compat`, every x402 adopter must migrate or fork. Migration friction kills adoption. The Let's Encrypt analogue is instructive: LE did not require abandoning commercial CAs; it worked alongside them, then ate the market. +Rejected. Without `s402/compat/x402`, every x402 adopter must migrate or fork. Migration friction kills adoption. The Let's Encrypt analogue is instructive: LE did not require abandoning commercial CAs; it worked alongside them, then ate the market. **Alt B — Pure interop (no superset schemes).** Rejected. Without prepaid, streaming, escrow, and unlock, s402 offers no durable reason to exist. Interop is a distribution vector, not a strategy. The strategy is *what s402 can ship that competitors structurally cannot*. diff --git a/docs/api/classes.md b/docs/api/classes.md index 811bc00..6a1e83d 100644 --- a/docs/api/classes.md +++ b/docs/api/classes.md @@ -40,7 +40,7 @@ async createPayment( ): Promise; ``` -Accepts typed `s402PaymentRequirements`. For x402 input, normalize first via `normalizeRequirements()` from `s402/compat`. +Accepts typed `s402PaymentRequirements`. For x402 input, normalize first via `normalizeRequirements()` from `s402/compat/x402`. **Throws:** - `NETWORK_MISMATCH` — no schemes registered for the requirements' network diff --git a/docs/api/compat.md b/docs/api/compat.md index 8d26e65..a1e872c 100644 --- a/docs/api/compat.md +++ b/docs/api/compat.md @@ -13,7 +13,7 @@ import { normalizeRequirements, isS402, isX402, -} from 's402/compat'; +} from 's402/compat/x402'; ``` ## Why This Exists @@ -41,7 +41,7 @@ Handles three formats: ```typescript import { decodePaymentRequired } from 's402/http'; -import { normalizeRequirements } from 's402/compat'; +import { normalizeRequirements } from 's402/compat/x402'; // Option A: decode s402 headers directly (validates s402Version) const requirements = decodePaymentRequired(header); @@ -146,7 +146,7 @@ import type { x402PaymentRequirements, x402PaymentRequiredEnvelope, x402PaymentPayload, -} from 's402/compat'; +} from 's402/compat/x402'; ``` ### `x402PaymentRequirements` diff --git a/docs/architecture.md b/docs/architecture.md index 7cbe606..2bbe0cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -152,4 +152,4 @@ The vectors live in `test/conformance/vectors/` in the repository. They are gene - **`extensions` field** on requirements allows arbitrary data without breaking parsers - **`accepts` array** lets servers advertise multiple schemes, and clients pick the best one - **Version field** (`s402Version: '1'`) enables future protocol evolution -- **Sub-path exports** (`s402/types`, `s402/http`, `s402/errors`, `s402/compat`) let consumers import only what they need +- **Sub-path exports** (`s402/types`, `s402/http`, `s402/errors`, `s402/compat/x402`) let consumers import only what they need diff --git a/docs/comparison.md b/docs/comparison.md index 690a407..e8cfa82 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -97,13 +97,13 @@ s402 is designed to absorb x402 and MPP traffic at the edge. | Direction | x402 V1 | x402 V2 | MPP | s402 | |---|---|---|---|---| -| **Read x402 `payment-required`** | ✅ native | ✅ via compat | — | ✅ via `s402/compat` | +| **Read x402 `payment-required`** | ✅ native | ✅ via compat | — | ✅ via `s402/compat/x402` | | **Accept x402 `x-payment`** | ✅ | ✅ | — | ✅ (exact scheme) | | **Read MPP `WWW-Authenticate: Payment`** | — | — | ✅ | ⚠️ adapter in development | | **Emit x402-compatible response** | — | — | — | ✅ `toX402Requirements()` | ```typescript -import { detectProtocol, normalizeRequirements } from 's402/compat'; +import { detectProtocol, normalizeRequirements } from 's402/compat/x402'; // Works whether the server sent s402, x402, or MPP const protocol = detectProtocol(response.headers); diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index ec15839..bebee17 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -30,7 +30,7 @@ s402 provides focused sub-paths so you only import what you need: import { ... } from 's402'; // Everything import type { ... } from 's402/types'; // Types + constants only (zero runtime) import { ... } from 's402/http'; // HTTP encode/decode -import { ... } from 's402/compat'; // x402 interop +import { ... } from 's402/compat/x402'; // x402 interop import { ... } from 's402/errors'; // Error types ``` @@ -143,7 +143,7 @@ import { isX402, toX402Requirements, fromX402Requirements, -} from 's402/compat'; +} from 's402/compat/x402'; // Auto-normalize: works with s402 or x402 (V1 or V2) format const requirements = normalizeRequirements(rawJsonFromAnySource); diff --git a/docs/guide/the-s402-story.md b/docs/guide/the-s402-story.md index a4dafd1..64e4024 100644 --- a/docs/guide/the-s402-story.md +++ b/docs/guide/the-s402-story.md @@ -124,7 +124,7 @@ This is only possible because of Sui's architecture: | **Agent authorization** | None | None | AP2-aligned mandate delegation | | **Direct settlement** | No | No | Yes | | **Receipts** | Off-chain | Off-chain | On-chain NFT proofs | -| **x402 compatibility** | Native | Native | Full (via `s402/compat` layer) | +| **x402 compatibility** | Native | Native | Full (via `s402/compat/x402` layer) | *Note: x402 V2 (December 2025) is a significant evolution — multi-chain support, formalized Extensions, and backing from Google, Cloudflare, and Visa via the x402 Foundation. s402's differentiation is on-chain contract enforcement of payment caps and Sui's PTB atomicity, not "more schemes" alone.* @@ -138,7 +138,7 @@ This is the most common question. The answer is architectural: 3. **x402 has no concept of ongoing payment relationships.** Streams, prepaid balances, and escrows are stateful — they exist on-chain beyond a single request/response. x402's model is stateless per-request. -4. **Compatibility is already solved.** s402's `exact` scheme *is* x402. The wire format is identical. An x402 client can talk to an s402 server out of the box. The `s402/compat` layer handles format normalization in both directions. Building a "Sui adapter for x402" would give you less capability, not more. +4. **Compatibility is already solved.** s402's `exact` scheme *is* x402. The wire format is identical. An x402 client can talk to an s402 server out of the box. The `s402/compat/x402` layer handles format normalization in both directions. Building a "Sui adapter for x402" would give you less capability, not more. ### The ecosystem vision @@ -812,7 +812,7 @@ This is NOT a TOCTOU (time-of-check-to-time-of-use) fix — Sui's PTBs are atomi ### x402 compatibility layer -The compat layer (`s402/compat`) is an **adapter pattern** that handles three input formats: +The compat layer (`s402/compat/x402`) is an **adapter pattern** that handles three input formats: ``` x402 V1 (flat) normalizeRequirements() @@ -897,7 +897,7 @@ The `s402` package has no `dependencies` in package.json. Not even the Sui SDK. ### Decision 5: Optional x402 compatibility -The compat layer is a sub-path import (`s402/compat`), not part of the main barrel export. +The compat layer is a sub-path import (`s402/compat/x402`), not part of the main barrel export. **Why:** Most s402 consumers will never need x402 conversion. Including it in the main export would: 1. Increase the mental surface area ("what are all these x402 types?") diff --git a/docs/guide/upgrade-l402.md b/docs/guide/upgrade-l402.md index 73b6563..538f1be 100644 --- a/docs/guide/upgrade-l402.md +++ b/docs/guide/upgrade-l402.md @@ -1,5 +1,5 @@ --- -description: Migrating from L402 (Lightning Labs) to s402 — consume L402 challenges natively via s402/compat-l402, coexist via Accept-Payment, and optionally graduate off Lightning to on-chain schemes. +description: Migrating from L402 (Lightning Labs) to s402 — consume L402 challenges natively via s402/compat/l402, coexist via Accept-Payment, and optionally graduate off Lightning to on-chain schemes. --- # Migrating from L402 @@ -9,12 +9,12 @@ Already running an L402-gated API (Aperture or similar)? s402 is the first 402 p This guide is for teams running Aperture-style Lightning paywalls who want to extend — not replace — their setup. ::: info Availability -L402 read-path lands in v0.7 — the code is in the `s402/compat-l402` entry point. Write-path emission (s402 server emitting an L402 challenge with an issued macaroon + fresh invoice) is not scoped: it requires a running Lightning node to mint invoices, which is out of scope for a wire-format library. Teams that want to emit L402 should keep using Aperture. +L402 read-path lands in v0.7 — the code is in the `s402/compat/l402` entry point. Write-path emission (s402 server emitting an L402 challenge with an issued macaroon + fresh invoice) is not scoped: it requires a running Lightning node to mint invoices, which is out of scope for a wire-format library. Teams that want to emit L402 should keep using Aperture. ::: ## TL;DR -- s402 will read L402 / LSAT challenges via `s402/compat-l402` (shipping v0.7) +- s402 will read L402 / LSAT challenges via `s402/compat/l402` (shipping v0.7) - Your existing Aperture deployment keeps working - You gain six schemes Lightning structurally cannot express: Upto ceiling, Escrow with arbiter, Stream with on-chain rate cap, Unlock pay-to-decrypt, Prepaid batched settlement, Exact on any chain beyond Bitcoin - Coexistence via `Accept-Payment`: advertise both L402 and s402 on the same endpoint @@ -47,7 +47,7 @@ This is the primary v0.7 capability — an s402 client receives a 402 from an Ap import { parseWwwAuthenticateL402, fromL402Challenge, -} from 's402/compat-l402'; +} from 's402/compat/l402'; const res = await fetch('https://api.example.com/data'); if (res.status === 402) { diff --git a/docs/guide/upgrade-mpp.md b/docs/guide/upgrade-mpp.md index 9fd216f..dac0757 100644 --- a/docs/guide/upgrade-mpp.md +++ b/docs/guide/upgrade-mpp.md @@ -9,7 +9,7 @@ Already running MPP (Stripe's Machine Payment Protocol on Tempo)? s402 is design This guide is for teams evaluating s402 alongside MPP, or already running MPP and considering s402 for the patterns MPP doesn't cover. ::: info Availability -MPP compat **read path** (challenge parsing + Charge-to-s402 translation) ships with v0.3 — the code is in the `s402/compat-mpp` entry point. Write-path emission (s402 → MPP challenges) and the Session-to-Prepaid shim are on the v0.4 roadmap ([DAN-339](https://linear.app/dannydevs/issue/DAN-339)). Today you can consume MPP Charge 402 responses natively; emitting MPP challenges still requires routing to an MPP-native server. +MPP compat **read path** (challenge parsing + Charge-to-s402 translation) ships with v0.3 — the code is in the `s402/compat/mpp` entry point. Write-path emission (s402 → MPP challenges) and the Session-to-Prepaid shim are on the v0.4 roadmap ([DAN-339](https://linear.app/dannydevs/issue/DAN-339)). Today you can consume MPP Charge 402 responses natively; emitting MPP challenges still requires routing to an MPP-native server. ::: ## TL;DR @@ -63,7 +63,7 @@ async function handle(req: Request): Promise { const chosen = selectBestScheme(preferred, supported); if (chosen?.startsWith('tempo/') || chosen?.startsWith('stripe/')) { - // v0.3: delegate to 's402/compat-mpp' challenge builder. + // v0.3: delegate to 's402/compat/mpp' challenge builder. // Until then, route MPP traffic to your existing MPP server path. return routeToMppHandler(req, chosen); } @@ -86,7 +86,7 @@ Some teams want to consolidate on a single protocol. s402's compat layer makes t import { parseWwwAuthenticatePayment, fromMppChargeChallenge, -} from 's402/compat-mpp'; +} from 's402/compat/mpp'; const challenge = parseWwwAuthenticatePayment( response.headers.get('WWW-Authenticate'), @@ -146,7 +146,7 @@ No. s402 is a chain-agnostic wire format. You can deploy on any chain, but Sui i ### Does s402 speak MPP's `WWW-Authenticate: Payment` header? -Read path lands in v0.3: `parseWwwAuthenticatePayment` + `fromMppChargeChallenge` in `s402/compat-mpp`. Write-path emission (s402 server sending an MPP-shaped `WWW-Authenticate: Payment` header) is v0.4 roadmap. Native s402 uses its own headers (`payment-required`, `x-payment`); the two coexist via the `Accept-Payment` negotiation. +Read path lands in v0.3: `parseWwwAuthenticatePayment` + `fromMppChargeChallenge` in `s402/compat/mpp`. Write-path emission (s402 server sending an MPP-shaped `WWW-Authenticate: Payment` header) is v0.4 roadmap. Native s402 uses its own headers (`payment-required`, `x-payment`); the two coexist via the `Accept-Payment` negotiation. ### Can MPP clients consume s402 NFT receipts? diff --git a/docs/guide/upgrade-x402.md b/docs/guide/upgrade-x402.md index 84281af..6b5ccee 100644 --- a/docs/guide/upgrade-x402.md +++ b/docs/guide/upgrade-x402.md @@ -80,7 +80,7 @@ async function handlePaidRequest(req: Request): Promise { For **x402 V2 requirements** (the envelope format with `accepts: [{...}, ...]`), use the compat layer to normalize them into s402 shape: ```typescript -import { normalizeRequirements } from 's402/compat'; +import { normalizeRequirements } from 's402/compat/x402'; // Auto-detects s402 vs x402 V1 flat vs x402 V2 envelope. const requirements = normalizeRequirements(JSON.parse(atob(headerFromUpstream))); @@ -137,7 +137,7 @@ No. You can speak both simultaneously. Use `detectProtocol()` on incoming payloa ### What about x402 V2's multi-chain support? -The x402 V2 compat layer handles V2 inputs. See [`s402/compat`](/api/compat) for the full API. +The x402 V2 compat layer handles V2 inputs. See [`s402/compat/x402`](/api/compat) for the full API. ### Is there gas sponsorship? diff --git a/docs/guide/why-s402.md b/docs/guide/why-s402.md index 9ee7028..ee55cc8 100644 --- a/docs/guide/why-s402.md +++ b/docs/guide/why-s402.md @@ -17,7 +17,7 @@ The first wave of HTTP 402 protocols — [x402](https://github.com/coinbase/x402 - **x402** ships one payment pattern (exact amount, one call, one payment) across EVM chains. - **MPP** ships one formally-registered intent (Charge) with a rich card/Lightning/ACH multi-rail story anchored in Stripe's existing merchant network. -s402 is a superset. It reads both natively (via `s402/compat` and `s402/compat-mpp`) and adds five payment schemes neither expresses as first-class primitives. +s402 is a superset. It reads both natively (via `s402/compat/x402` and `s402/compat/mpp`) and adds five payment schemes neither expresses as first-class primitives. ## Why an Agent Would Default to s402 diff --git a/docs/integrations.md b/docs/integrations.md index 437277c..da157a8 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -37,8 +37,8 @@ s402 is chain-agnostic. The protocol layer contains no chain-specific logic (see | Chain | Package | Status | Schemes supported | |---|---|---|---| | **Sui** | [`@sweefi/sui`](https://www.npmjs.com/package/@sweefi/sui) | ✅ Production | All six (Exact, Upto, Prepaid, Escrow, Stream, Unlock) | -| **EVM (via compat)** | `s402/compat` | ✅ Production | Exact, Upto — reads x402 V1/V2 payloads natively | -| **Tempo (via compat)** | `s402/compat-mpp` | 📋 v0.3 roadmap | Charge ↔ Exact, Session ↔ Prepaid | +| **EVM (via compat)** | `s402/compat/x402` | ✅ Production | Exact, Upto — reads x402 V1/V2 payloads natively | +| **Tempo (via compat)** | `s402/compat/mpp` | 📋 v0.3 roadmap | Charge ↔ Exact, Session ↔ Prepaid | | **Solana** | — | 📋 Community | Open for contributions | | **Aptos** | — | 📋 Community | Open for contributions | @@ -89,14 +89,14 @@ The compat layers let existing x402 and MPP traffic flow through an s402 server | Source protocol | s402 module | Status | What it handles | |---|---|---|---| -| **x402 V1** | `s402/compat` | ✅ Production | `x-payment` header, base64 JSON, `exact` scheme | -| **x402 V2** | `s402/compat` | ✅ Production | Multi-chain extensions, new error shapes | -| **MPP Charge (read)** | `s402/compat-mpp` | 🟡 v0.3 | Challenge parsing + `fromMppChargeChallenge` for blockchain methods (tempo/evm/solana/lightning/stellar) | -| **MPP Charge (write)** | `s402/compat-mpp` | 📋 v0.4 roadmap | Emit MPP-shaped `WWW-Authenticate: Payment` challenges | -| **MPP Session** | `s402/compat-mpp` | 📋 v0.4 roadmap | Cumulative voucher ↔ Prepaid translation | -| **MPP `Accept-Payment`** | `s402/compat-mpp` | ✅ Production | `parseMppAcceptPayment` — method/intent pairs with wildcards + q-values | +| **x402 V1** | `s402/compat/x402` | ✅ Production | `x-payment` header, base64 JSON, `exact` scheme | +| **x402 V2** | `s402/compat/x402` | ✅ Production | Multi-chain extensions, new error shapes | +| **MPP Charge (read)** | `s402/compat/mpp` | 🟡 v0.3 | Challenge parsing + `fromMppChargeChallenge` for blockchain methods (tempo/evm/solana/lightning/stellar) | +| **MPP Charge (write)** | `s402/compat/mpp` | 📋 v0.4 roadmap | Emit MPP-shaped `WWW-Authenticate: Payment` challenges | +| **MPP Session** | `s402/compat/mpp` | 📋 v0.4 roadmap | Cumulative voucher ↔ Prepaid translation | +| **MPP `Accept-Payment`** | `s402/compat/mpp` | ✅ Production | `parseMppAcceptPayment` — method/intent pairs with wildcards + q-values | | **s402 `Accept-Payment`** | core `s402` | ✅ Production | Flat scheme token negotiation ([DAN-341](https://linear.app/dannydevs/issue/DAN-341)) | -| **L402 / LSAT** (Lightning Labs) | `s402/compat-l402` | ✅ v0.7 | `parseWwwAuthenticateL402` + `fromL402Challenge` — BOLT-11 HRP decode, macaroon+invoice passthrough | +| **L402 / LSAT** (Lightning Labs) | `s402/compat/l402` | ✅ v0.7 | `parseWwwAuthenticateL402` + `fromL402Challenge` — BOLT-11 HRP decode, macaroon+invoice passthrough | See [Migrating from x402](/guide/upgrade-x402) and [Migrating from MPP](/guide/upgrade-mpp) for code. diff --git a/docs/positioning.md b/docs/positioning.md index be016b3..2d82946 100644 --- a/docs/positioning.md +++ b/docs/positioning.md @@ -33,16 +33,16 @@ x402 ships 2 schemes. MPP ships 1 formally registered intent. L402 ships 1. s402 | Dialect | Status | Module | |---|---|---| -| **x402 V1/V2** (Coinbase) | ✅ Production | `s402/compat` | -| **MPP Charge** — crypto rails (Stripe/Tempo) | ✅ v0.6 | `s402/compat-mpp` | -| **MPP Accept-Payment** | ✅ v0.6 | `s402/compat-mpp` | -| **MPP Session** | 📋 v0.7 | `s402/compat-mpp` | -| **L402** (Lightning Labs) | 📋 v0.7 | `s402/compat-l402` | +| **x402 V1/V2** (Coinbase) | ✅ Production | `s402/compat/x402` | +| **MPP Charge** — crypto rails (Stripe/Tempo) | ✅ v0.6 | `s402/compat/mpp` | +| **MPP Accept-Payment** | ✅ v0.6 | `s402/compat/mpp` | +| **MPP Session** | 📋 v0.7 | `s402/compat/mpp` | +| **L402** (Lightning Labs) | 📋 v0.7 | `s402/compat/l402` | | **IETF `draft-ryan-httpauth-payment`** | 🟡 Partial via MPP | (reference impl path) | -| **Google AP2** (Agent Payments Protocol) | 📋 research | `s402/compat-ap2` | +| **Google AP2** (Agent Payments Protocol) | 📋 research | `s402/compat/ap2` | | **ERC-7824 statechannels** | 📋 watch | — | -Any client speaking any dialect in the "Production" rows can hit an s402 server unchanged. A new dialect becomes a sub-path export (`s402/compat-*`) with its own conformance vectors — zero core pollution. +Any client speaking any dialect in the "Production" rows can hit an s402 server unchanged. A new dialect becomes a sub-path export (`s402/compat/*`) with its own conformance vectors — zero core pollution. ### Pillar 3 — On-Chain Enforcement @@ -70,7 +70,7 @@ s402 absorbs every 402 dialect we discover in production. This is a standing com **Rule:** when a new 402 dialect is identified in-the-wild, a compat layer plan is opened within two weeks. Each compat layer: -1. Lives in its own sub-path export (`s402/compat-{name}`) with zero core imports. +1. Lives in its own sub-path export (`s402/compat/{name}`) with zero core imports. 2. Implements the read path first (decode, translate to s402 types). Write path is a separate milestone. 3. Passes its own conformance vectors sourced from the dialect's canonical spec. 4. Documents exactly what it does *not* translate (e.g., processor methods, session intents, bespoke auth). diff --git a/docs/whitepaper.md b/docs/whitepaper.md index 1bdbf46..2d9d409 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -303,11 +303,11 @@ s402 v0.1.1 (February 2026) addressed findings from an internal security audit: s402 is designed to work within the existing x402 ecosystem. Wire compatibility is a first-class property, not an afterthought. -An x402 V1 client making an exact payment can interact with an s402 server — the `payment-required` and `x-payment` headers are structurally identical in the exact scheme. An s402 client detects which protocol a server is using via the `detectProtocol()` function, which checks for `s402Version` in decoded requirements. x402 V1 and V2 formats normalize into s402 types via the optional `s402/compat` layer. Note: x402 V2 renamed the client payment header to `payment-signature`; s402 uses `x-payment` (V1-compatible). The compat layer handles this normalization, but x402 V2 clients communicating natively require the compat layer on the s402 side. +An x402 V1 client making an exact payment can interact with an s402 server — the `payment-required` and `x-payment` headers are structurally identical in the exact scheme. An s402 client detects which protocol a server is using via the `detectProtocol()` function, which checks for `s402Version` in decoded requirements. x402 V1 and V2 formats normalize into s402 types via the optional `s402/compat/x402` layer. Note: x402 V2 renamed the client payment header to `payment-signature`; s402 uses `x-payment` (V1-compatible). The compat layer handles this normalization, but x402 V2 clients communicating natively require the compat layer on the s402 side. **x402 V2 in context:** x402's December 2025 V2 release adds multi-chain support (Base, Solana, and traditional payment rails), formalized Extensions for session-based and subscription-like patterns, and Google, Cloudflare, and Visa as x402 Foundation members. This is a significant step. s402's differentiation from V2 lies in protocol-layer enforcement: s402's Prepaid and Stream schemes are Move contracts with on-chain rate caps; x402 V2's session patterns are application-layer conventions. Both are valid architectures with different trust guarantees. -The compat package is a deliberate sub-path import (`s402/compat`), not part of the main barrel export. The framing: x402 serves Base, Solana, and traditional rails. s402 serves Sui's unique capabilities. The protocols are complementary. +The compat package is a deliberate sub-path import (`s402/compat/x402`), not part of the main barrel export. The framing: x402 serves Base, Solana, and traditional rails. s402 serves Sui's unique capabilities. The protocols are complementary. --- @@ -318,7 +318,7 @@ The `s402` npm package (v0.5.0, Apache-2.0) is the protocol layer: - **TypeScript** — types, encoding logic, scheme dispatch, error model, extension system - **831 tests** including property-based fuzzing, MC/DC coverage, and 161-vector conformance suite - **Zero runtime dependencies** — pure TypeScript using only built-in APIs (`TextEncoder`, `btoa/atob`, `JSON`) -- **Sub-path exports** — import only what you need: `s402/types`, `s402/http`, `s402/errors`, `s402/compat` +- **Sub-path exports** — import only what you need: `s402/types`, `s402/http`, `s402/errors`, `s402/compat/x402` The layered architecture: diff --git a/typescript/CHANGELOG.md b/typescript/CHANGELOG.md index 9e0150e..007ccc0 100644 --- a/typescript/CHANGELOG.md +++ b/typescript/CHANGELOG.md @@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`s402/compat-l402` — L402 read-path interop (DAN-344).** New entry point for consuming Lightning Labs' L402 (formerly LSAT) challenges as native s402 types. L402 is the oldest 402 dialect in production — shipping this turns the "universal read" positioning pillar from aspirational into airtight. - `parseWwwAuthenticateL402(header)` — RFC 9110 auth-params parser accepting both `L402` and legacy `LSAT` auth-schemes (canonicalized to `L402` in output). Handles quoted-string + unquoted-token forms. Enforces required `macaroon` and `invoice` params. - `decodeBolt11Summary(invoice)` — partial BOLT-11 decoder over the human-readable part only. Extracts network (`lightning:mainnet|testnet|regtest|signet`) and amount (converting m/u/n/p multipliers to millisatoshi with BigInt arithmetic). Rejects pico-BTC amounts not divisible by 10. - - `fromL402Challenge(challenge)` — translates an L402 challenge into `s402PaymentRequirements` with `scheme: 'exact'`, `asset: 'lightning:msat'`, sentinel `payTo: 'lightning:invoice'` (real destination lives in the invoice). Surfaces macaroon + invoice in `extensions.l402` for retry construction. Rejects amountless invoices as spec violations. + - `fromL402Challenge(challenge)` — translates an L402 challenge into `s402PaymentRequirements` with `scheme: 'exact'`, `asset: 'lightning:msat'`, sentinel `payTo: 'lightning:invoice'` (real destination lives in the invoice). Surfaces macaroon + invoice in `extensions.l402` for retry construction. Rejects amountless invoices as spec violations. Stamps a conservative `expiresAt = now + 60s` so that **S1 (stale payment rejection) stays load-bearing** for L402-derived requirements — the real BOLT-11 expiry tag is not decoded in v0.7 (scope deferral); the 60s floor guards against stale-invoice replay, with the tradeoff that long-expiry invoices trigger a re-fetch after 60s. + - **Signet prefix support**: recognizes both the canonical current-BOLT-11 prefix (`lntbs`, core-lightning + recent LND) and the legacy prefix (`lnsb`, older LND emissions). Both canonicalize to `lightning:signet` in the parsed output. - **~20 unit tests** at `test/compat-l402.test.ts` covering all four multiplier classes, all four network prefixes, LSAT/L402 alias handling, amountless invoices, malformed HRPs, and end-to-end header-to-requirements flows. - **Positioning document** at `docs/positioning.md` — canonical three-pillar USP: expressiveness (6 schemes), universal read (every 402 dialect), on-chain enforcement (Move invariants). Single source of truth for landing page, pitch, and grant copy. - **Universal 402 Absorption** project tracker on Linear ([project link](https://linear.app/dannydevs/project/universal-402-absorption-f6e181082db4)) with child issues DAN-344 (L402), DAN-345 (MPP Session), DAN-346 (MPP write path), DAN-347 (Google AP2), DAN-348 (IETF reference impl), DAN-349 (ERC-7824 watch). @@ -32,7 +33,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Compatibility - **Purely additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors. Existing 0.6.x consumers require no code changes. -- **New sub-path export**: `s402/compat-l402` sits alongside `s402/compat` (x402) and `s402/compat-mpp` (Stripe/Tempo). All three are opt-in — importing from the root `s402` entry does not pull any compat bundle. +- **Compat sub-path exports reorganized**: all three compat layers now live under `s402/compat/*` for symmetry and clearer intent. + - `s402/compat` → **`s402/compat/x402`** (breaking rename — x402 is now explicit, not the unlabeled default) + - `s402/compat-mpp` → **`s402/compat/mpp`** + - `s402/compat-l402` → **`s402/compat/l402`** (new in this release; shipped under the new path from day one) + - Source tree moved from flat `src/compat.ts`, `src/compat-mpp.ts`, `src/compat-l402.ts` to `src/compat/x402.ts`, `src/compat/mpp.ts`, `src/compat/l402.ts`. Pre-1.0 minor bump licenses the rename; no backward-compat aliases shipped — consumers update imports once. + - **Migration**: find-replace `'s402/compat'` → `'s402/compat/x402'`, `'s402/compat-mpp'` → `'s402/compat/mpp'`, `'s402/compat-l402'` → `'s402/compat/l402'`. Exported symbol names are unchanged. +- Root `s402` entry still pulls no compat bundle — compat layers remain opt-in. ## [0.6.0] - 2026-04-19 diff --git a/typescript/package.json b/typescript/package.json index e1dae24..ac1a15f 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -68,26 +68,26 @@ }, "default": "./dist/http.mjs" }, - "./compat": { + "./compat/x402": { "import": { - "types": "./dist/compat.d.mts", - "default": "./dist/compat.mjs" + "types": "./dist/compat/x402.d.mts", + "default": "./dist/compat/x402.mjs" }, - "default": "./dist/compat.mjs" + "default": "./dist/compat/x402.mjs" }, - "./compat-mpp": { + "./compat/mpp": { "import": { - "types": "./dist/compat-mpp.d.mts", - "default": "./dist/compat-mpp.mjs" + "types": "./dist/compat/mpp.d.mts", + "default": "./dist/compat/mpp.mjs" }, - "default": "./dist/compat-mpp.mjs" + "default": "./dist/compat/mpp.mjs" }, - "./compat-l402": { + "./compat/l402": { "import": { - "types": "./dist/compat-l402.d.mts", - "default": "./dist/compat-l402.mjs" + "types": "./dist/compat/l402.d.mts", + "default": "./dist/compat/l402.mjs" }, - "default": "./dist/compat-l402.mjs" + "default": "./dist/compat/l402.mjs" }, "./errors": { "import": { diff --git a/typescript/src/client.ts b/typescript/src/client.ts index dedfd5a..bb005ed 100644 --- a/typescript/src/client.ts +++ b/typescript/src/client.ts @@ -45,7 +45,7 @@ export class s402Client { * `accepts` array that we have a registered implementation for. * * Accepts typed s402PaymentRequirements only. For x402 input, normalize - * first via `normalizeRequirements()` from 's402/compat'. + * first via `normalizeRequirements()` from 's402/compat/x402'. * * @param requirements - Server's payment requirements (from a 402 response) * @returns Payment payload ready to send in the `x-payment` header diff --git a/typescript/src/compat-l402.ts b/typescript/src/compat/l402.ts similarity index 86% rename from typescript/src/compat-l402.ts rename to typescript/src/compat/l402.ts index 1ffa256..10f90bb 100644 --- a/typescript/src/compat-l402.ts +++ b/typescript/src/compat/l402.ts @@ -27,9 +27,9 @@ * - BOLT-12 offers (spec still evolving) */ -import type { s402PaymentRequirements } from './types.js'; -import { S402_VERSION } from './types.js'; -import { s402Error } from './errors.js'; +import type { s402PaymentRequirements } from '../types.js'; +import { S402_VERSION } from '../types.js'; +import { s402Error } from '../errors.js'; // ══════════════════════════════════════════════════════════════ // L402 wire types (read-side) @@ -185,12 +185,22 @@ function parseAuthParams(input: string): Record { // BOLT-11 HRP decoding // ══════════════════════════════════════════════════════════════ -const HRP_PATTERN = /^ln(bc|tb|bcrt|sb)(\d+)?([munp])?1[a-z0-9]+$/; +// HRP-only sanity check — does NOT validate the bech32 body (checksum, data +// payload). Real BOLT-11 invoices have a 520-bit signature and tagged fields +// past the `1` separator; Lightning wallets validate those. Ordering in the +// alternation matters: `bcrt` before `bc`, `tbs` before `tb` — the regex picks +// the FIRST match, so longer prefixes must come first to avoid being shadowed. +const HRP_PATTERN = /^ln(bcrt|tbs|bc|tb|sb)(\d+)?([munp])?1[a-z0-9]+$/; +// BOLT-11 network prefixes. Two signet prefixes are recognized: +// - `tbs` — canonical per current BOLT-11 spec (core-lightning, recent LND) +// - `sb` — legacy LND emissions still in the wild +// Both canonicalize to `lightning:signet` in the output. const NETWORK_BY_PREFIX: Record = { bc: 'lightning:mainnet', tb: 'lightning:testnet', bcrt: 'lightning:regtest', + tbs: 'lightning:signet', sb: 'lightning:signet', }; @@ -285,6 +295,26 @@ export function decodeBolt11Summary(invoice: string): Bolt11Summary { */ const LIGHTNING_INVOICE_SENTINEL = 'lightning:invoice'; +/** + * Conservative default expiry window applied to L402-derived requirements. + * + * BOLT-11 invoices carry their own expiry as a tagged field (type `x`) past + * the `1` separator, defaulting to 3600 seconds per spec. This partial decoder + * reads only the HRP, so the real invoice expiry is not surfaced. To keep + * S1 (stale payment rejection) load-bearing for L402-derived requirements, + * we stamp a conservative 60s window: an s402 client that caches requirements + * longer than 60s must re-fetch the 402 response rather than reusing stale + * ones against a possibly-expired invoice. + * + * Tradeoff: a long-lived invoice (e.g., Aperture's default 1-hour expiry) is + * rejected by s402 after 60s even though the invoice is still payable. The + * re-fetch cost is one extra round-trip, not a payment failure. + * + * A future v0.8 full BOLT-11 decoder can read the `x` tag and use the real + * expiry. Until then, 60s is the safe floor. + */ +const L402_DEFAULT_EXPIRY_WINDOW_MS = 60_000; + /** * Translate an L402 challenge into s402 payment requirements using the `exact` * scheme. @@ -314,6 +344,9 @@ export function fromL402Challenge(challenge: L402Challenge): s402PaymentRequirem asset: 'lightning:msat', amount: summary.amountMsat, payTo: LIGHTNING_INVOICE_SENTINEL, + // Conservative expiry keeps S1 (stale payment rejection) honest for + // L402-derived requirements — see L402_DEFAULT_EXPIRY_WINDOW_MS doc. + expiresAt: Date.now() + L402_DEFAULT_EXPIRY_WINDOW_MS, extensions: { l402: { macaroon: challenge.macaroon, diff --git a/typescript/src/compat-mpp.ts b/typescript/src/compat/mpp.ts similarity index 99% rename from typescript/src/compat-mpp.ts rename to typescript/src/compat/mpp.ts index 4050719..3f4f1ad 100644 --- a/typescript/src/compat-mpp.ts +++ b/typescript/src/compat/mpp.ts @@ -23,10 +23,10 @@ * - Write path (s402 → MPP emission) */ -import type { s402PaymentRequirements } from './types.js'; -import { S402_VERSION } from './types.js'; -import { s402Error } from './errors.js'; -import { isValidAmount } from './http.js'; +import type { s402PaymentRequirements } from '../types.js'; +import { S402_VERSION } from '../types.js'; +import { s402Error } from '../errors.js'; +import { isValidAmount } from '../http.js'; // ══════════════════════════════════════════════════════════════ // MPP wire types (read-side; fields we consume) diff --git a/typescript/src/compat.ts b/typescript/src/compat/x402.ts similarity index 98% rename from typescript/src/compat.ts rename to typescript/src/compat/x402.ts index cde392e..98f9222 100644 --- a/typescript/src/compat.ts +++ b/typescript/src/compat/x402.ts @@ -9,10 +9,10 @@ * s402-only fields (mandate, stream, escrow, unlock extensions) are stripped. */ -import type { s402PaymentRequirements, s402ExactPayload, s402PaymentPayload } from './types.js'; -import { S402_VERSION } from './types.js'; -import { s402Error } from './errors.js'; -import { isValidAmount, validateRequirementsShape, pickRequirementsFields } from './http.js'; +import type { s402PaymentRequirements, s402ExactPayload, s402PaymentPayload } from '../types.js'; +import { S402_VERSION } from '../types.js'; +import { s402Error } from '../errors.js'; +import { isValidAmount, validateRequirementsShape, pickRequirementsFields } from '../http.js'; // ══════════════════════════════════════════════════════════════ // x402 types (minimal — just what we need for conversion) @@ -262,7 +262,7 @@ export function fromX402Envelope(envelope: x402PaymentRequiredEnvelope, now?: nu * * @example * ```ts - * import { normalizeRequirements } from 's402/compat'; + * import { normalizeRequirements } from 's402/compat/x402'; * * // Works with any format — auto-detects s402 vs x402 * const rawJson = JSON.parse(atob(header)); diff --git a/typescript/src/http.ts b/typescript/src/http.ts index 5629147..698446e 100644 --- a/typescript/src/http.ts +++ b/typescript/src/http.ts @@ -560,7 +560,7 @@ export function validateRequirementsShape(obj: unknown): void { // (x402 format should go through normalizeRequirements in compat.ts instead.) if (record.s402Version === undefined) { throw new s402Error('INVALID_PAYLOAD', - 'Missing s402Version. For x402 format, use normalizeRequirements() from s402/compat.'); + 'Missing s402Version. For x402 format, use normalizeRequirements() from s402/compat/x402.'); } if (record.s402Version !== '1') { throw new s402Error('INVALID_PAYLOAD', diff --git a/typescript/src/index.ts b/typescript/src/index.ts index 90c8155..e270e37 100644 --- a/typescript/src/index.ts +++ b/typescript/src/index.ts @@ -4,7 +4,7 @@ * Six payment schemes: exact, upto, prepaid, stream, escrow, unlock. * AP2 mandate support. Direct settlement. On-chain receipts. * Wire-compatible with x402. Zero runtime dependencies. - * Optional x402 compat layer available via 's402/compat'. + * Optional x402 compat layer available via 's402/compat/x402'. * * @packageDocumentation */ @@ -126,9 +126,9 @@ export type { JsonValue } from './canonicalization.js'; // Internal validators (validateSubObjects, validateMandateShape, validate*Shape, // pickRequirementsFields) are available via 's402/http' for advanced use cases. -// Compatibility — available via 's402/compat' sub-path import. +// Compatibility — available via 's402/compat/x402' sub-path import. // Not re-exported here to keep the main barrel focused on s402-native APIs. -// import { normalizeRequirements, fromX402Requirements } from 's402/compat'; +// import { normalizeRequirements, fromX402Requirements } from 's402/compat/x402'; // Accept-Payment content negotiation (RFC 7231-style q-values) export { diff --git a/typescript/test/audit2-findings.test.ts b/typescript/test/audit2-findings.test.ts index 20e0433..13c952a 100644 --- a/typescript/test/audit2-findings.test.ts +++ b/typescript/test/audit2-findings.test.ts @@ -20,7 +20,7 @@ import { isX402Envelope, normalizeRequirements, type x402PaymentRequiredEnvelope, -} from '../src/compat.js'; +} from '../src/compat/x402.js'; const VALID_PAY_TO = '0x' + 'a'.repeat(64); diff --git a/typescript/test/compat-l402.test.ts b/typescript/test/compat-l402.test.ts index 971570c..91a4a15 100644 --- a/typescript/test/compat-l402.test.ts +++ b/typescript/test/compat-l402.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for s402/compat-l402 — L402 (Lightning Labs) read-path interop. + * Unit tests for s402/compat/l402 — L402 (Lightning Labs) read-path interop. * * BOLT-11 vectors are drawn from the canonical BOLT-11 test vectors in the * Lightning bolts repo (bolts/11-payment-encoding.md) — these have been @@ -14,7 +14,7 @@ import { decodeBolt11Summary, fromL402Challenge, type L402Challenge, -} from '../src/compat-l402.js'; +} from '../src/compat/l402.js'; import { s402Error } from '../src/errors.js'; // A representative (synthetic) BOLT-11 invoice body. Real BOLT-11 invoices are @@ -33,7 +33,8 @@ const INV_10P_BTC = 'lnbc10p1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // 10 pBTC const INV_AMOUNTLESS = 'lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // no amount const INV_TESTNET = 'lntb25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // testnet const INV_REGTEST = 'lnbcrt500u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // regtest -const INV_SIGNET = 'lnsb25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // signet +const INV_SIGNET_CANONICAL = 'lntbs25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // signet — canonical `tbs` prefix (current BOLT-11) +const INV_SIGNET_LEGACY = 'lnsb25u1pvjluezpp5qqqsyqcyq5rqwzqfqqq'; // signet — legacy `sb` prefix (older LND) describe('parseWwwAuthenticateL402', () => { it('returns null for absent / non-L402 headers', () => { @@ -140,8 +141,12 @@ describe('decodeBolt11Summary — BOLT-11 HRP decoding', () => { expect(decodeBolt11Summary(INV_REGTEST).network).toBe('lightning:regtest'); }); - it('recognizes signet (lnsb) prefix', () => { - expect(decodeBolt11Summary(INV_SIGNET).network).toBe('lightning:signet'); + it('recognizes signet canonical prefix (lntbs, current BOLT-11 spec)', () => { + expect(decodeBolt11Summary(INV_SIGNET_CANONICAL).network).toBe('lightning:signet'); + }); + + it('recognizes signet legacy prefix (lnsb, older LND emissions)', () => { + expect(decodeBolt11Summary(INV_SIGNET_LEGACY).network).toBe('lightning:signet'); }); it('is case-insensitive on the invoice string', () => { @@ -192,15 +197,37 @@ describe('fromL402Challenge — L402 → s402 translation', () => { expect(ext.invoice).toBe(INV_2500_UBTC); }); - it('propagates network from invoice prefix across all four networks', () => { + it('propagates network from invoice prefix across all networks (canonical + legacy signet)', () => { expect(fromL402Challenge({ ...baseChallenge, invoice: INV_TESTNET }).network) .toBe('lightning:testnet'); expect(fromL402Challenge({ ...baseChallenge, invoice: INV_REGTEST }).network) .toBe('lightning:regtest'); - expect(fromL402Challenge({ ...baseChallenge, invoice: INV_SIGNET }).network) + expect(fromL402Challenge({ ...baseChallenge, invoice: INV_SIGNET_CANONICAL }).network) + .toBe('lightning:signet'); + expect(fromL402Challenge({ ...baseChallenge, invoice: INV_SIGNET_LEGACY }).network) .toBe('lightning:signet'); }); + it('stamps a conservative expiresAt so S1 (stale payment rejection) stays load-bearing', () => { + const before = Date.now(); + const requirements = fromL402Challenge(baseChallenge); + const after = Date.now(); + + expect(requirements.expiresAt).toBeDefined(); + // Window is now + 60s (±a few ms for test clock drift) + expect(requirements.expiresAt!).toBeGreaterThanOrEqual(before + 60_000); + expect(requirements.expiresAt!).toBeLessThanOrEqual(after + 60_000); + }); + + it('expiresAt is a fresh stamp per call (two calls produce two windows)', () => { + const r1 = fromL402Challenge(baseChallenge); + // Busy-wait a tick to guarantee Date.now() advances + const deadline = Date.now() + 2; + while (Date.now() < deadline) { /* spin */ } + const r2 = fromL402Challenge(baseChallenge); + expect(r2.expiresAt!).toBeGreaterThan(r1.expiresAt!); + }); + it('rejects amountless invoices (L402 spec violation)', () => { expect(() => fromL402Challenge({ ...baseChallenge, invoice: INV_AMOUNTLESS })) .toThrow(/amountless/); diff --git a/typescript/test/compat-mpp.test.ts b/typescript/test/compat-mpp.test.ts index e10a5e6..f3c3946 100644 --- a/typescript/test/compat-mpp.test.ts +++ b/typescript/test/compat-mpp.test.ts @@ -1,5 +1,5 @@ /** - * Unit tests for s402/compat-mpp — MPP read-path interop. + * Unit tests for s402/compat/mpp — MPP read-path interop. * * Fixtures are drawn from the actual MPP spec drafts in tempoxyz/mpp-specs: * - specs/core/draft-httpauth-payment-00.md (§5.1 challenge, §5.2 credential, §6.1 Accept-Payment) @@ -14,7 +14,7 @@ import { decodeMppCredential, fromMppChargeChallenge, type MppChallenge, -} from '../src/compat-mpp.js'; +} from '../src/compat/mpp.js'; import { s402Error } from '../src/errors.js'; function base64url(input: string): string { diff --git a/typescript/test/compat.test.ts b/typescript/test/compat.test.ts index 4b04ea4..77bb482 100644 --- a/typescript/test/compat.test.ts +++ b/typescript/test/compat.test.ts @@ -17,7 +17,7 @@ import { normalizeRequirements, type x402PaymentRequirements, type x402PaymentRequiredEnvelope, -} from '../src/compat.js'; +} from '../src/compat/x402.js'; const VALID_PAY_TO = '0x' + 'a'.repeat(64); diff --git a/typescript/test/conformance/conformance.test.ts b/typescript/test/conformance/conformance.test.ts index 436e524..14a58f0 100644 --- a/typescript/test/conformance/conformance.test.ts +++ b/typescript/test/conformance/conformance.test.ts @@ -27,7 +27,7 @@ import { decodeSettleBody, } from '../../src/http.js'; -import { normalizeRequirements } from '../../src/compat.js'; +import { normalizeRequirements } from '../../src/compat/x402.js'; import { formatReceiptHeader, parseReceiptHeader } from '../../src/receipts.js'; import { s402Error } from '../../src/errors.js'; diff --git a/typescript/test/fuzz.test.ts b/typescript/test/fuzz.test.ts index b3b173d..cf42c28 100644 --- a/typescript/test/fuzz.test.ts +++ b/typescript/test/fuzz.test.ts @@ -31,7 +31,7 @@ import { isS402, isX402, isX402Envelope, -} from '../src/compat.js'; +} from '../src/compat/x402.js'; // ══════════════════════════════════════════════════════════════ // Arbitraries — structured generators for protocol objects diff --git a/typescript/tsdown.config.ts b/typescript/tsdown.config.ts index 4b26a4e..1db522b 100644 --- a/typescript/tsdown.config.ts +++ b/typescript/tsdown.config.ts @@ -5,9 +5,9 @@ export default defineConfig({ index: 'src/index.ts', types: 'src/types.ts', http: 'src/http.ts', - compat: 'src/compat.ts', - 'compat-mpp': 'src/compat-mpp.ts', - 'compat-l402': 'src/compat-l402.ts', + 'compat/x402': 'src/compat/x402.ts', + 'compat/mpp': 'src/compat/mpp.ts', + 'compat/l402': 'src/compat/l402.ts', errors: 'src/errors.ts', receipts: 'src/receipts.ts', 'test-utils': 'src/test-utils.ts', From 24ff521c246e807762efebe3d43811a4be586e33 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Tue, 21 Apr 2026 21:59:48 -0700 Subject: [PATCH 6/7] =?UTF-8?q?chore(ci):=20drop=20Node=2018=20=E2=80=94?= =?UTF-8?q?=20bump=20engines=20to=20>=3D20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 18 reached end-of-life April 2025. envelope.ts's computeTxBinding relies on globalThis.crypto.subtle which is only available unflagged in Node 19+, causing CI to fail on the Node 18 matrix slot. The Node 18 slot was masking what would otherwise be green CI on 20 + 22. - .github/workflows/ci.yml: matrix [18, 20, 22] -> [20, 22] - typescript/package.json: engines.node ">=18" -> ">=20" - typescript/README.md, README.md, docs/guide/quickstart.md, docs/guide/tutorial.md: update Node requirement text - CHANGELOG: log the breaking minimum-Node bump under v0.7.0 Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- docs/guide/quickstart.md | 2 +- docs/guide/tutorial.md | 2 +- typescript/CHANGELOG.md | 6 +++++- typescript/package.json | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 70083e6..80a7251 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ bun add s402 deno add npm:s402 ``` -> **ESM-only.** This package ships ES modules only (`"type": "module"`). Requires Node.js >= 18. CommonJS `require()` is not supported. +> **ESM-only.** This package ships ES modules only (`"type": "module"`). Requires Node.js >= 20. CommonJS `require()` is not supported. ## Governing Principle diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index bebee17..4df39c2 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -20,7 +20,7 @@ pip install s402 ::: -> **TypeScript:** ESM-only. Requires Node.js >= 18. **Python:** Requires Python >= 3.10. Both have **zero runtime dependencies**. +> **TypeScript:** ESM-only. Requires Node.js >= 20. **Python:** Requires Python >= 3.10. Both have **zero runtime dependencies**. ## Sub-path Imports (TypeScript) diff --git a/docs/guide/tutorial.md b/docs/guide/tutorial.md index 4f273c6..98c033d 100644 --- a/docs/guide/tutorial.md +++ b/docs/guide/tutorial.md @@ -28,7 +28,7 @@ npm init -y npm install s402 # or: pnpm add s402 / bun add s402 / deno add npm:s402 ``` -> **Node.js >= 18 required** (for native `fetch` and `Headers`). +> **Node.js >= 20 required** (for native `fetch`, `Headers`, and `globalThis.crypto`). ## Step 1: The Server diff --git a/typescript/CHANGELOG.md b/typescript/CHANGELOG.md index 007ccc0..2663e49 100644 --- a/typescript/CHANGELOG.md +++ b/typescript/CHANGELOG.md @@ -30,9 +30,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `docs/integrations.md` — added L402 compat-layer row (✅ v0.7). - `docs/guide/upgrade-l402.md` — new migration guide covering consumption, coexistence via `Accept-Payment`, BOLT-11 multiplier table, and honest comparison with L402. +### Breaking + +- **Minimum Node.js bumped to 20** (from 18). Node 18 reached end-of-life April 2025; `envelope.ts`'s `computeTxBinding` relies on `globalThis.crypto.subtle` which is only available unflagged in Node 19+. `engines.node` updated to `>=20`, CI matrix dropped Node 18, README/docs updated. Node 20 and Node 22 remain fully supported. + ### Compatibility -- **Purely additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors. Existing 0.6.x consumers require no code changes. +- **Non-compat consumers are additive.** No changes to existing types, scheme interfaces, wire format, or conformance vectors. - **Compat sub-path exports reorganized**: all three compat layers now live under `s402/compat/*` for symmetry and clearer intent. - `s402/compat` → **`s402/compat/x402`** (breaking rename — x402 is now explicit, not the unlabeled default) - `s402/compat-mpp` → **`s402/compat/mpp`** diff --git a/typescript/package.json b/typescript/package.json index ac1a15f..716fff2 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -32,7 +32,7 @@ "agent-payments" ], "engines": { - "node": ">=18" + "node": ">=20" }, "sideEffects": false, "files": [ From fab7bc30640956b11bdbd21de55ed5fa7c3f8290 Mon Sep 17 00:00:00 2001 From: Daniel Ahn Date: Tue, 21 Apr 2026 22:01:02 -0700 Subject: [PATCH 7/7] chore(ci): actually drop Node 18 from matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to 24ff521 — that commit landed the engines.node bump and docs updates but the .github/workflows/ci.yml edit silently didn't persist (Edit tool reported success but file was unchanged on disk; caught on post-commit verification). This commit lands the matrix change [18, 20, 22] -> [20, 22] for real. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78deea8..fae0f22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: working-directory: typescript strategy: matrix: - node-version: [18, 20, 22] + node-version: [20, 22] steps: - uses: actions/checkout@v4