diff --git a/README.md b/README.md index 383ec89..911caf1 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,48 @@ HTTP 402 ("Payment Required") has been reserved since 1999 — waiting for a pay **s402 is Sui-native by design.** These advantages come from Sui's object model, PTBs, and sub-second finality. They can't be replicated on EVM — and they don't need to be. x402 already handles EVM well. s402 handles Sui better. +## Who This Is For + +**Use s402 if** you are charging for an HTTP endpoint that AI agents call, and either you settle +on Sui, or you need a payment model x402 does not have — metered draw-down, streaming with rate +enforcement, escrow with an arbiter, or pay-to-decrypt. Also use it if you are implementing the +protocol in another language: the wire format is specified independently of this codebase and +ships with conformance vectors to check yourself against. + +**Do not use s402 if** you are EVM-only and `exact` covers you — x402 is the better fit and we +say so. Also skip it if you want a payments *product*: s402 is a wire format and a set of types +with zero runtime dependencies. It does not move money, custody funds, or run a facilitator for +you. Fiat and card rails are out of scope and will stay that way. + +## What Would Prove This Should Stop + +s402's bet is narrow enough to be wrong in public. Any of the following would falsify it, and we +would rather retire the project than defend it past its evidence: + +1. **x402 absorbs the superset.** This README claims x402's two-scheme governance envelope + cannot take on prepaid, stream, escrow and unlock without re-ratification. If x402 ratifies + equivalents, the reason to run a second protocol is gone — the correct response is to fold + the schemes upstream and delete this repo, not to compete. + +2. **The prepaid economics do not survive contact with production.** The case for micropayments + rests on batching 1,000 payments into 2 on-chain transactions. If real deployments do not land + near the claimed ~$0.014 per 1,000 calls — because of contention on shared objects, gas-price + regimes, or settlement patterns we have not modelled — then the central advantage over + per-call settlement is arithmetic that only works on paper. + +3. **No independent implementation ever passes the vectors.** s402 claims to be a protocol, not a + library. A protocol with exactly one implementation is a library wearing a specification. If + the conformance vectors go unclaimed by any second-language implementation, the honest + description is "the wire format of the s402 npm package," and the spec should be demoted to + internal documentation. + +4. **Agent commerce settles somewhere other than HTTP 402.** If agent-to-service payment + converges on mandate-passing over a non-402 channel, then the status code this protocol is + built around is the wrong integration point, and being excellent at it does not matter. + +Points 1 and 3 are the ones to watch: both are observable from outside this repo, and neither +depends on our own judgement of our own work. + ## Which Scheme Should I Use? | Your situation | Scheme | Gas per 1K calls | Latency | @@ -349,16 +391,21 @@ const requirements: s402PaymentRequirements = { ## Conformance Testing -s402 ships 133 machine-readable JSON test vectors for cross-language conformance. If you're implementing s402 in Go, Python, Rust, or any other language, use these vectors to verify your implementation matches the spec. +s402 ships machine-readable JSON test vectors for cross-language conformance — 167 vectors across 14 files. If you're implementing s402 in Go, Python, Rust, or any other language, use these vectors to verify your implementation matches the spec. ```bash -# Vectors are in the npm package +# From the npm package ls node_modules/s402/test/conformance/vectors/ -# Or clone the repo -ls test/conformance/vectors/ +# Or from a clone of this repo +ls spec/vectors/ ``` +The two paths hold the same files. `spec/vectors/` is the canonical, version-controlled +location; `scripts/prepare-publish.sh` copies it into `test/conformance/vectors/` at publish +time so it lands inside the npm tarball. **In a fresh clone only `spec/vectors/` exists** — +`test/conformance/vectors/` is generated and git-ignored. + See [`test/conformance/README.md`](./typescript/test/conformance/README.md) for the vector format, encoding scheme, and implementation guide. ## Related diff --git a/docs/adr/001-protocol-boundaries.md b/docs/adr/001-protocol-boundaries.md index 096c8b0..001df05 100644 --- a/docs/adr/001-protocol-boundaries.md +++ b/docs/adr/001-protocol-boundaries.md @@ -1,6 +1,7 @@ # ADR-001: Protocol Boundaries — Facilitator Trust, Receipt Cardinality, Scheme Cap, and Extension Hygiene **Status:** Accepted +**Implementation:** shipped **Date:** 2026-04-11 **Supersedes:** (none — first ADR) diff --git a/docs/adr/002-s402-is-pure-protocol.md b/docs/adr/002-s402-is-pure-protocol.md index 6a8f263..0491c7f 100644 --- a/docs/adr/002-s402-is-pure-protocol.md +++ b/docs/adr/002-s402-is-pure-protocol.md @@ -1,6 +1,7 @@ # ADR-002: s402 is a Pure Protocol Repo — mcp-server Moves to SweeFi **Status:** Accepted +**Implementation:** shipped **Date:** 2026-04-11 **Supersedes:** (none — refines the boundary that ADR-001 § Decision 1 named but did not enforce at the repo level) diff --git a/docs/adr/003-upto-scheme.md b/docs/adr/003-upto-scheme.md index 9bd9512..9b6482e 100644 --- a/docs/adr/003-upto-scheme.md +++ b/docs/adr/003-upto-scheme.md @@ -1,6 +1,7 @@ # ADR-003: `upto` Scheme — Usage-Based Payments with Settlement Overrides **Status:** Accepted (implemented — see note) +**Implementation:** shipped **Date:** 2026-04-12 **Supersedes:** (none) **Linear:** DAN-284 diff --git a/docs/adr/004-extensions-architecture.md b/docs/adr/004-extensions-architecture.md index 8e55efe..f501e36 100644 --- a/docs/adr/004-extensions-architecture.md +++ b/docs/adr/004-extensions-architecture.md @@ -1,6 +1,8 @@ # ADR-004: Extensions Architecture — Typed, Lifecycle-Aware Plugin System **Status:** Proposed +**Implementation:** shipped — `src/extensions.ts` (10KB), exported as the `s402/extensions` subpath in `package.json`. +⚠️ **`Status: Proposed` above is stale and is left as-is deliberately** (found 2026-08-16, DAN-855): this was built without the ADR ever being moved to Accepted. Flipping a governance field is a decision, not a cleanup, so it is surfaced here rather than changed unilaterally. It is also the exact gap the `Implementation:` field exists to expose — under `Status` alone, "proposed and never built" and "proposed and shipped" were the same page. **Date:** 2026-04-12 **Supersedes:** (none — implements the framework that ADR-001 Decision 4 described as hygiene rules) **Linear:** DAN-285 diff --git a/docs/adr/005-interop-superset-principle.md b/docs/adr/005-interop-superset-principle.md index 3907404..bd339ad 100644 --- a/docs/adr/005-interop-superset-principle.md +++ b/docs/adr/005-interop-superset-principle.md @@ -1,6 +1,7 @@ # ADR-005: Interop When Possible, Superset When Wise **Status:** Accepted +**Implementation:** upheld **Date:** 2026-04-14 **Supersedes:** (none — formalizes informal positioning against x402 and MPP) **Linear:** DAN-313 diff --git a/docs/adr/006-version-negotiation-and-scheme-digests.md b/docs/adr/006-version-negotiation-and-scheme-digests.md index 5efe549..6d4371a 100644 --- a/docs/adr/006-version-negotiation-and-scheme-digests.md +++ b/docs/adr/006-version-negotiation-and-scheme-digests.md @@ -1,6 +1,7 @@ # ADR-006: Version Negotiation and Scheme Content-Hashing **Status:** Draft +**Implementation:** not-started **Date:** 2026-04-19 **Supersedes:** implicit version string in `s402Version` field **Related:** ADR-001 (Protocol Boundaries), ADR-002 (Protocol-Pure), ADR-004 (Extensions), ADR-005 (Interop-Superset), INVARIANTS S3 @@ -95,7 +96,18 @@ advertised = "sha256-" || base64url_no_pad(digest) **Target is the prose spec, not derived artifacts.** The markdown spec at `docs/schemes/.md` is normative — it is the human-readable law of the scheme. JSON Schema files and BCS type definitions are derivative; their content-hashes are advertised separately in `spec/scheme-digests.json` under a `derived` key but do not drive version negotiation. -**Implementation:** `scripts/compute-scheme-digests.mjs` runs in CI and writes `spec/scheme-digests.json`. Any PR that modifies `docs/schemes/**/*.md` without updating the digests file fails CI. +**Implementation plan — NOT BUILT (verified 2026-08-16, DAN-855).** The intent is that +`scripts/compute-scheme-digests.mjs` runs in CI and writes `spec/scheme-digests.json`, so any PR +modifying `docs/schemes/**/*.md` without updating the digests file fails CI. + +⚠️ **None of that exists.** Neither file appears in any commit on any branch (`git log --all -- +'**/compute-scheme-digests.mjs' '**/scheme-digests.json'` returns nothing), and CI's five jobs +contain no such gate. This paragraph previously asserted the mechanism in the present tense, which +made an unbuilt plan read as a shipped guarantee — in the one field the ADR convention reserves for +answering exactly that question. The header now carries `Implementation: not-started`. + +`docs/schemes/` itself is real (`escrow.md`, `exact.md`, `prepaid.md`), so the thing this would +guard exists; only the guard is missing. ### Amendment chain diff --git a/docs/adr/007-settlement-response-envelope.md b/docs/adr/007-settlement-response-envelope.md index 12c893e..13bd685 100644 --- a/docs/adr/007-settlement-response-envelope.md +++ b/docs/adr/007-settlement-response-envelope.md @@ -1,6 +1,7 @@ # ADR-007: Settlement Response Envelope **Status:** Draft (v2 — post /vet wave review) +**Implementation:** shipped **Date:** 2026-04-19 **Related:** ADR-001 (Protocol Boundaries), ADR-006 (Version Negotiation), ADR-008 (Safety Invariants S9-S13), ADR-009 (Open Gaps), INVARIANTS S7, S8 **Supersedes:** `s402SettleResponse` (legacy flat shape in `typescript/src/scheme.ts`) diff --git a/docs/adr/008-safety-invariants-s9-s13.md b/docs/adr/008-safety-invariants-s9-s13.md index e894782..651dbe2 100644 --- a/docs/adr/008-safety-invariants-s9-s13.md +++ b/docs/adr/008-safety-invariants-s9-s13.md @@ -1,6 +1,7 @@ # ADR-008: Safety Invariants S9–S14 **Status:** Draft (v2 — post /vet wave review; S14 added, S11 hardened) +**Implementation:** in-progress — **S11** and **S14** are built (`src/envelope.ts:77`, `:430`, `:443`; S14's constant-time comparison has its own test at `test/envelope.test.ts:429`). **S9, S10, S12 and S13** have no enforcement site found. Determined 2026-08-16 (DAN-855) by searching for the mechanisms, not only the labels — but treat the negatives as *no witness found*, not *proven absent*. **Date:** 2026-04-19 **Related:** INVARIANTS.md (S1–S8), ADR-004 (Extensions), ADR-007 (Settlement Envelope), ADR-009 (Open Gaps) diff --git a/docs/adr/009-open-gaps-rotation-reorg.md b/docs/adr/009-open-gaps-rotation-reorg.md index d124a6f..92b2adf 100644 --- a/docs/adr/009-open-gaps-rotation-reorg.md +++ b/docs/adr/009-open-gaps-rotation-reorg.md @@ -1,6 +1,7 @@ # ADR-009: Open Gaps — Facilitator Key Rotation, Chain Reorg Tolerance, Scheme Acceptance Process **Status:** Placeholder — tracked gaps, not yet resolved +**Implementation:** not-started **Date:** 2026-04-19 **Related:** ADR-006, ADR-007, ADR-008, INVARIANTS S8, S11 diff --git a/docs/adr/010-safety-invariants-s15-s16.md b/docs/adr/010-safety-invariants-s15-s16.md index aac5239..725121b 100644 --- a/docs/adr/010-safety-invariants-s15-s16.md +++ b/docs/adr/010-safety-invariants-s15-s16.md @@ -1,6 +1,7 @@ # ADR-010: Safety Invariants S15–S16 — Session Binding and Version Binding **Status:** Draft +**Implementation:** in-progress — **S16 is half-built**: the protocol version *is* bound into the signed envelope rather than only into transport headers (`src/envelope.ts:54`, `:173`, `:219`, and required at `:268`). Its *scheme-digest* half is blocked on ADR-006, which is `not-started`. **S15** (binding long-running scheme state to the mandate/capability object rather than the signer's key) has no enforcement site in this repo — consistent with ADR-002, since mandates live upstack. Determined 2026-08-16 (DAN-855) by searching for the mechanisms rather than the labels; a label-only search finds where someone wrote "S16", not where it is enforced. **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) diff --git a/docs/adr/011-transport-abstraction.md b/docs/adr/011-transport-abstraction.md index c799646..e38a747 100644 --- a/docs/adr/011-transport-abstraction.md +++ b/docs/adr/011-transport-abstraction.md @@ -1,6 +1,7 @@ # ADR-011: Transport Abstraction — Payment Rides Any Carrier **Status:** Accepted (2026-06-28; revised after blind-spot review; Chunks 1a-i, 1a-ii, 1a-iii, and 2 landed + verified — HTTP + MCP + A2A all behind one seam, 1075 tests green) +**Implementation:** shipped **Date:** 2026-06-28 **Supersedes:** (none) **Related:** ADR-005 (interop/superset), ADR-002 (s402 is pure protocol), S7 (chain-agnostic boundary) diff --git a/docs/guide/tutorial.md b/docs/guide/tutorial.md index 98c033d..ba05893 100644 --- a/docs/guide/tutorial.md +++ b/docs/guide/tutorial.md @@ -233,20 +233,31 @@ This is the complete s402 protocol flow. In production, step 3 uses the Sui SDK ## Making It Real -To connect this to Sui, replace the mock payment in `client.ts` with real Sui SDK code: +To connect this to Sui, replace the mock payment in `client.ts` with real Sui SDK code. + +Use the **gRPC** client, not `SuiClient`: Sui has deprecated JSON-RPC on public fullnodes, and +every core method there now answers `-32601`. The URL is unchanged — gRPC is served from the same +host and port — so this is a client swap, not an endpoint move. It needs `@mysten/sui` **2.x**; +the 1.x gRPC client cannot execute against current fullnodes. ```typescript -import { SuiClient } from '@mysten/sui/client'; +import { SuiGrpcClient } from '@mysten/sui/grpc'; import { Transaction } from '@mysten/sui/transactions'; import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; -// Build a real payment +const keypair = new Ed25519Keypair(); // or load from keystore +const suiClient = new SuiGrpcClient({ + network: 'testnet', + baseUrl: 'https://fullnode.testnet.sui.io:443', +}); + +// Build a real payment. The sender must be set explicitly — there is no +// signAndExecuteTransaction on the gRPC client to infer it from a signer. const tx = new Transaction(); +tx.setSender(keypair.toSuiAddress()); const [coin] = tx.splitCoins(tx.gas, [requirements.amount]); tx.transferObjects([coin], requirements.payTo); -const keypair = new Ed25519Keypair(); // or load from keystore -const suiClient = new SuiClient({ url: 'https://fullnode.testnet.sui.io' }); const txBytes = await tx.build({ client: suiClient }); const { bytes, signature } = await keypair.signTransaction(txBytes); diff --git a/docs/specification.md b/docs/specification.md index 92dc0a1..e04e29f 100644 --- a/docs/specification.md +++ b/docs/specification.md @@ -8,7 +8,7 @@ description: s402 Wire Format Specification v1 — the formal, field-by-field de This document defines the s402 wire format — the exact encoding, field definitions, validation rules, and error semantics for the s402 HTTP 402 payment protocol. It is the authoritative reference for any implementation in any language. -The TypeScript reference implementation lives at [github.com/s402-protocol/core](https://github.com/s402-protocol/core). Machine-readable conformance test vectors ship in the npm package (161 vectors across 13 files). +The TypeScript reference implementation lives at [github.com/s402-protocol/core](https://github.com/s402-protocol/core). Machine-readable conformance test vectors ship in the npm package (167 vectors across 14 files). ## 1. Terminology @@ -510,7 +510,7 @@ An implementation is **s402-conformant** if it: 2. Validates all required fields per §4.1, §5.1, and §6 3. Rejects malformed input with the appropriate error code from §8 4. Strips unknown keys on decode per §10 -5. Passes the 161 machine-readable conformance test vectors shipped in the `s402` npm package +5. Passes **every** machine-readable conformance test vector shipped in the `s402` npm package (167 vectors across 14 files as of v0.9.0) The conformance vectors cover: encode, decode, body transport, x402 compat normalization, receipt format/parse, settlement verification, validation rejection, key stripping, and roundtrip identity. See the [Conformance Vectors guide](/guide/conformance) for the vector format and implementation instructions. diff --git a/mcp-demo/README.md b/mcp-demo/README.md index 0fb1454..21b9c1e 100644 --- a/mcp-demo/README.md +++ b/mcp-demo/README.md @@ -65,10 +65,13 @@ Look at the `package.json`. Four runtime dependencies: "s402": "workspace:*", "hono": "^4.6.0", "@hono/node-server": "^1.13.0", - "@mysten/sui": "^1.18.0" + "@mysten/sui": "^2.24.0" } ``` +`@mysten/sui` is pinned to 2.x deliberately. Settlement runs over gRPC because Sui deprecated +JSON-RPC on public fullnodes, and the 1.x gRPC client cannot execute against current fullnodes. + **No `@coinbase/x402` SDK. No Stripe SDK.** The s402 wire-format library natively emits both x402 V2 PaymentRequirements (via `s402/compat/x402`'s `toX402V2Requirements()`) AND MPP-shaped charge challenges (via `s402/compat/mpp`'s `toMppChargeChallenge()`). The x402 entry matches upstream `@x402/core/types/payments.ts` HEAD: slimmer V2 shape with required `extra: {}`, no per-requirement `x402Version`, no `maxAmountRequired` alias. The Stripe MPP entry's `request` field is real base64url-encoded JCS — decode it and you get the canonical MPP Charge Request JSON. Both write paths are roundtrip-stable against the read-path inverses (`fromX402Envelope`, `decodeMppChargeRequest`). The architectural argument — *s402 is a superset by chain-feature construction* — is visible in the import list, not pitched in prose. ## Quick start @@ -113,7 +116,9 @@ By default, the demo runs in **envelope-only** mode (no on-chain transactions). pnpm --filter s402-mcp-demo dev:agent ``` -The server (which must also be run with `SUI_REAL_SETTLEMENT=1`) verifies the on-chain settlement via `SuiClient.getTransactionBlock` and checks the balance change credits the demo provider address before executing the tool. +The server (which must also be run with `SUI_REAL_SETTLEMENT=1`) verifies the on-chain settlement via the gRPC client's `core.getTransaction` and checks the balance change credits the demo provider address before executing the tool. + +The verifying read retries on `NOT_FOUND`. The public endpoint is load-balanced, so the read can reach a fullnode that has not yet caught up to the transaction the agent just executed — without the retry the server would intermittently reject payments that actually settled. ## What this demo proves diff --git a/mcp-demo/package.json b/mcp-demo/package.json index 2d33756..ccccde6 100644 --- a/mcp-demo/package.json +++ b/mcp-demo/package.json @@ -12,10 +12,10 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "s402": "workspace:*", - "hono": "^4.6.0", "@hono/node-server": "^1.13.0", - "@mysten/sui": "^1.18.0" + "@mysten/sui": "^2.24.0", + "hono": "^4.6.0", + "s402": "workspace:*" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/mcp-demo/src/agent.ts b/mcp-demo/src/agent.ts index 22e2141..102312a 100644 --- a/mcp-demo/src/agent.ts +++ b/mcp-demo/src/agent.ts @@ -1,4 +1,4 @@ -import { SuiClient, getFullnodeUrl } from '@mysten/sui/client'; +import { SuiGrpcClient } from '@mysten/sui/grpc'; import { Ed25519Keypair } from '@mysten/sui/keypairs/ed25519'; import { Transaction } from '@mysten/sui/transactions'; @@ -7,8 +7,12 @@ import { s402PaymentObject, DEMO_PROVIDER_ADDRESS } from './protocols/s402.js'; const SERVER_URL = process.env.SERVER_URL ?? 'http://localhost:3000'; const REAL_SETTLEMENT = process.env.SUI_REAL_SETTLEMENT === '1'; const MNEMONIC = process.env.SUI_TESTNET_MNEMONIC; +const SUI_RPC = process.env.SUI_RPC_URL ?? 'https://fullnode.testnet.sui.io:443'; -const sui = new SuiClient({ url: getFullnodeUrl('testnet') }); +// gRPC, not JSON-RPC: Sui deprecated JSON-RPC on public fullnodes and every core +// method now answers -32601. Note the URL is unchanged — gRPC is served from the +// same host and port, so this is a client swap and not an endpoint move. +const sui = new SuiGrpcClient({ network: 'testnet', baseUrl: SUI_RPC }); async function rpc(method: string, params: Record = {}, headers: Record = {}): Promise { const res = await fetch(SERVER_URL, { @@ -28,17 +32,28 @@ async function settleViaS402(): Promise { const keypair = Ed25519Keypair.deriveKeypair(MNEMONIC); const tx = new Transaction(); + // The gRPC client has no signAndExecuteTransaction, so the sender is no longer + // implied by a signer argument and must be set explicitly before the build. + tx.setSender(keypair.toSuiAddress()); const [coin] = tx.splitCoins(tx.gas, [s402PaymentObject.amount]); tx.transferObjects([coin], DEMO_PROVIDER_ADDRESS); - const result = await sui.signAndExecuteTransaction({ - signer: keypair, - transaction: tx, - options: { showEffects: true } + const bytes = await tx.build({ client: sui }); + const { signature } = await keypair.signTransaction(bytes); + + const result = await sui.core.executeTransaction({ + transaction: bytes, + signatures: [signature] }); - console.log(` Sui testnet tx: ${result.digest}`); - return result.digest; + // TransactionResult is a union discriminated on $kind; the failure arm carries + // the transaction under a different key, so narrow rather than reach for .Transaction. + if (result.$kind !== 'Transaction') { + throw new Error(`Settlement did not execute: ${result.FailedTransaction.digest}`); + } + + console.log(` Sui testnet tx: ${result.Transaction.digest}`); + return result.Transaction.digest; } async function main() { diff --git a/mcp-demo/src/server.ts b/mcp-demo/src/server.ts index 89486fb..4fc0528 100644 --- a/mcp-demo/src/server.ts +++ b/mcp-demo/src/server.ts @@ -1,6 +1,6 @@ import { Hono } from 'hono'; import { serve } from '@hono/node-server'; -import { SuiClient } from '@mysten/sui/client'; +import { SuiGrpcClient } from '@mysten/sui/grpc'; import { summarizeTool, summarize } from './tools/summarize.js'; import { s402PaymentObject, DEMO_PROVIDER_ADDRESS } from './protocols/s402.js'; @@ -11,7 +11,27 @@ const PORT = Number(process.env.PORT ?? 3000); const SUI_RPC = process.env.SUI_RPC_URL ?? 'https://fullnode.testnet.sui.io:443'; const REAL_SETTLEMENT = process.env.SUI_REAL_SETTLEMENT === '1'; -const sui = new SuiClient({ url: SUI_RPC }); +// gRPC, not JSON-RPC: Sui deprecated JSON-RPC on public fullnodes. SUI_RPC is +// unchanged — gRPC is served from the same host and port. +const sui = new SuiGrpcClient({ network: 'testnet', baseUrl: SUI_RPC }); + +// The public endpoint is load-balanced, so the verifying read can land on a node +// that has not yet caught up to the transaction the agent just executed. Observed: +// the first read returns NOT_FOUND and the second, ~4s later, succeeds — for a +// transaction whose own execute call already reported success. Without this retry +// the demo intermittently rejects payments that actually settled, and it does so +// more often on a fast machine. +const SETTLEMENT_READ_ATTEMPTS = 6; +const SETTLEMENT_READ_DELAY_MS = 2_000; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const isNotFound = (err: unknown): boolean => { + const code = (err as { code?: unknown })?.code; + if (code === 'NOT_FOUND') return true; + const message = err instanceof Error ? err.message : String(err); + return /not[ %]?found/i.test(message); +}; const paymentRequired = (id: number | string) => ({ jsonrpc: '2.0' as const, @@ -28,21 +48,35 @@ const paymentRequired = (id: number | string) => ({ async function verifyS402Settlement(txDigest: string): Promise { if (!REAL_SETTLEMENT) return true; - try { - const tx = await sui.getTransactionBlock({ - digest: txDigest, - options: { showEffects: true, showBalanceChanges: true } - }); - if (tx.effects?.status?.status !== 'success') return false; - const credited = tx.balanceChanges?.some( - (c) => c.owner && typeof c.owner === 'object' && 'AddressOwner' in c.owner && - c.owner.AddressOwner === DEMO_PROVIDER_ADDRESS && - BigInt(c.amount) >= BigInt(s402PaymentObject.amount) - ); - return Boolean(credited); - } catch { - return false; + + for (let attempt = 1; attempt <= SETTLEMENT_READ_ATTEMPTS; attempt++) { + try { + // balanceChanges is empty unless it is requested. Omitting `include` returns + // the field present and undefined, which would make the credit check below + // fail closed on every real payment. + const result = await sui.core.getTransaction({ + digest: txDigest, + include: { effects: true, balanceChanges: true } + }); + + if (result.$kind !== 'Transaction') return false; + const tx = result.Transaction; + if (!tx.effects.status.success) return false; + + // gRPC balance changes are flat — { coinType, address, amount } — where + // JSON-RPC nested the recipient under owner.AddressOwner. + return tx.balanceChanges.some( + (change) => + change.address === DEMO_PROVIDER_ADDRESS && + BigInt(change.amount) >= BigInt(s402PaymentObject.amount) + ); + } catch (err) { + if (!isNotFound(err) || attempt === SETTLEMENT_READ_ATTEMPTS) return false; + await sleep(SETTLEMENT_READ_DELAY_MS); + } } + + return false; } const app = new Hono(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 241fb39..2552a68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: ^1.13.0 version: 1.19.14(hono@4.12.18) '@mysten/sui': - specifier: ^1.18.0 - version: 1.45.2(typescript@5.9.3) + specifier: ^2.24.0 + version: 2.24.0(typescript@5.9.3) hono: specifier: ^4.6.0 version: 4.12.18 @@ -75,16 +75,16 @@ importers: packages: - '@0no-co/graphql.web@1.2.0': - resolution: {integrity: sha512-/1iHy9TTr63gE1YcR5idjx8UREz1s0kFhydf3bBLCXyqjhkIc6igAzTOx3zPifCwFR87tsh/4Pa9cNts6d2otw==} + '@0no-co/graphql.web@1.3.3': + resolution: {integrity: sha512-4gFGBdyaFmQ6n9euhp5JtIGS4ZeivwDr1tCPENUxTvy5wyv532yOtFCr9zzYAJh1s6uibgC+TRXUcay+mxzCoQ==} peerDependencies: graphql: ^14.0.0 || ^15.0.0 || ^16.0.0 peerDependenciesMeta: graphql: optional: true - '@0no-co/graphqlsp@1.15.4': - resolution: {integrity: sha512-Nt1DVHcZ08lKRKwhiU0amXH77fSdrO6DzyjLE0DkCxfbM/N1SAs32d76y1xtCzM5H9eT0iDS7SdksgRXWJu05g==} + '@0no-co/graphqlsp@1.17.4': + resolution: {integrity: sha512-5OneBcUU0dVbyrTqyFzmC1TtXmA1PxRKLLBO3O9wqmB6dzkidea1d+mK3ciqYDSejq22LzBQ2lb829aArK22/g==} peerDependencies: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 typescript: ^5.0.0 || ^6.0.0 @@ -537,25 +537,25 @@ packages: cpu: [x64] os: [win32] - '@gql.tada/cli-utils@1.7.3': - resolution: {integrity: sha512-3iQY5E/jvv3Lnh6D1Mh7zr+Bb9C/TGk1DHkm+lbIjQBnZAu2m+BcTcr1e3spUt6Aa6HG/xAN2XxpbWw9oZALEg==} + '@gql.tada/cli-utils@1.9.3': + resolution: {integrity: sha512-P1TiXErpJwIi73sei5fzwGA/SOeCaIHFWFR4RdZPLwqxZzQN0T6MAUivzXBgKCORL67rvYnLaaPoWeqWq/61ug==} peerDependencies: - '@0no-co/graphqlsp': ^1.12.13 - '@gql.tada/svelte-support': 1.0.2 - '@gql.tada/vue-support': 1.0.2 + '@0no-co/graphqlsp': ^1.16.0 + '@gql.tada/svelte-support': 1.0.3 + '@gql.tada/vue-support': 1.0.3 graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 - typescript: ^5.0.0 || ^6.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@gql.tada/svelte-support': optional: true '@gql.tada/vue-support': optional: true - '@gql.tada/internal@1.0.9': - resolution: {integrity: sha512-Bp8yi+kLrzIJ3l5Dfxhz48H4OCH2LCX+pShaPcJgh+oiBt6clrjUKDYNDD3Z78aDQ3+Tyrxe4dd0MfLgpSLPPg==} + '@gql.tada/internal@1.2.2': + resolution: {integrity: sha512-4lZcElPP6MC8Ct8KN70LR2WQsHjbAsyAmTGG09VsOPhx738UFIYaL0S1XiI2pqq2tj/sDs/y3b9jvKoWGL7iuQ==} peerDependencies: graphql: ^15.5.0 || ^16.0.0 || ^17.0.0 - typescript: ^5.0.0 || ^6.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 '@graphql-typed-document-node/core@3.2.0': resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} @@ -595,15 +595,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mysten/bcs@1.9.2': - resolution: {integrity: sha512-kBk5xrxV9OWR7i+JhL/plQrgQ2/KJhB2pB5gj+w6GXhbMQwS3DPpOvi/zN0Tj84jwPvHMllpEl0QHj6ywN7/eQ==} + '@mysten/bcs@2.1.0': + resolution: {integrity: sha512-rIR/cDAqDfBDxmYEZXppN/on8gmh1OW0dYi/Bk2kMBZcYMTaBaEtEX1VR6g7HicVxuMbFAIP0/SQkdH7J9Y5dQ==} - '@mysten/sui@1.45.2': - resolution: {integrity: sha512-gftf7fNpFSiXyfXpbtP2afVEnhc7p2m/MEYc/SO5pov92dacGKOpQIF7etZsGDI1Wvhv+dpph+ulRNpnYSs7Bg==} - engines: {node: '>=18'} + '@mysten/sui@2.24.0': + resolution: {integrity: sha512-VmIkgnMulRVeA0mnBgHiTFTci5XKJH5/UB8Kr2M9+1nZnKC1RKsNGRXWBtox+2HYtwHNdUjHxYchfWnfZ9yHkQ==} + engines: {node: '>=22'} - '@mysten/utils@0.2.0': - resolution: {integrity: sha512-CM6kJcJHX365cK6aXfFRLBiuyXc5WSBHQ43t94jqlCAIRw8umgNcTb5EnEA9n31wPAQgLDGgbG/rCUISCTJ66w==} + '@mysten/utils@0.4.0': + resolution: {integrity: sha512-nHlXECBl9kE+AHF/aw5a7JVNizae8Kb71A4H18BV/sXd5/l2BaWNGWVatq05fzJo3hF78AWorDxa++1TgcBKWw==} '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} @@ -611,13 +611,13 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 - '@noble/curves@1.9.4': - resolution: {integrity: sha512-2bKONnuM53lINoDrSmK8qP8W271ms7pygDhZt4SiLOoLwBtoHqeCFi6RG42V8zd3mLHuJFhU/Bmaqo4nX0/kBw==} - engines: {node: ^14.21.3 || >=16} + '@noble/curves@2.3.0': + resolution: {integrity: sha512-v7cY+4oWYPQszRj6ZFGzTVL7uP2TaLo1xMhWHzYC5wj0ZhOXQ5x+sBre8rF3hi8cAoi0bh1qXoovoOkdFtvqEg==} + engines: {node: '>= 20.19.0'} - '@noble/hashes@1.8.0': - resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} - engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.3.0': + resolution: {integrity: sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==} + engines: {node: '>= 20.19.0'} '@oxc-project/types@0.112.0': resolution: {integrity: sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ==} @@ -961,14 +961,14 @@ packages: cpu: [x64] os: [win32] - '@scure/base@1.2.6': - resolution: {integrity: sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==} + '@scure/base@2.3.0': + resolution: {integrity: sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==} - '@scure/bip32@1.7.0': - resolution: {integrity: sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==} + '@scure/bip32@2.3.0': + resolution: {integrity: sha512-mMPjNcXxJsvNveIgRIXrnwd4omu0wdXo4JowAUZO7/82+/bpAwVltclx6MG6nv2M3dA6IbfVv4xwWxaREm3OcA==} - '@scure/bip39@1.6.0': - resolution: {integrity: sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==} + '@scure/bip39@2.3.0': + resolution: {integrity: sha512-qdyWuxoYwi3+YmqIsfkpz1I029m980WkVPilj+kG7VxSm+gKQ2BmQru3nv3LbMtpSUXuyZwdFT65JkpKu5OHOQ==} '@shikijs/core@2.5.0': resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} @@ -1377,14 +1377,14 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true - gql.tada@1.9.2: - resolution: {integrity: sha512-QxRHVpxtrOVdYXz6oavq0lBM+Zdp0swapLGJcD4SLpXDcsD337BHDFrzqqjfkbepv0sSAiO0LGabu1kI5D5Gyg==} + gql.tada@1.11.3: + resolution: {integrity: sha512-5JCI4j2f0nug8ILaCQys/yjOP78QqqjVUf47OQsME63rZfemsHT3e5vcfbHsnZiG6vxyqpKUJArtXEVwSefUhw==} hasBin: true peerDependencies: - typescript: ^5.0.0 || ^6.0.0 + typescript: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 - graphql@16.14.0: - resolution: {integrity: sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-flag@4.0.0: @@ -1813,8 +1813,8 @@ packages: synckit: optional: true - valibot@1.4.0: - resolution: {integrity: sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg==} + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} peerDependencies: typescript: '>=5' peerDependenciesMeta: @@ -1934,14 +1934,14 @@ packages: snapshots: - '@0no-co/graphql.web@1.2.0(graphql@16.14.0)': + '@0no-co/graphql.web@1.3.3(graphql@16.14.2)': optionalDependencies: - graphql: 16.14.0 + graphql: 16.14.2 - '@0no-co/graphqlsp@1.15.4(graphql@16.14.0)(typescript@5.9.3)': + '@0no-co/graphqlsp@1.17.4(graphql@16.14.2)(typescript@5.9.3)': dependencies: - '@gql.tada/internal': 1.0.9(graphql@16.14.0)(typescript@5.9.3) - graphql: 16.14.0 + '@gql.tada/internal': 1.2.2(graphql@16.14.2)(typescript@5.9.3) + graphql: 16.14.2 typescript: 5.9.3 '@algolia/abtesting@1.15.2': @@ -2285,22 +2285,22 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true - '@gql.tada/cli-utils@1.7.3(@0no-co/graphqlsp@1.15.4(graphql@16.14.0)(typescript@5.9.3))(graphql@16.14.0)(typescript@5.9.3)': + '@gql.tada/cli-utils@1.9.3(@0no-co/graphqlsp@1.17.4(graphql@16.14.2)(typescript@5.9.3))(graphql@16.14.2)(typescript@5.9.3)': dependencies: - '@0no-co/graphqlsp': 1.15.4(graphql@16.14.0)(typescript@5.9.3) - '@gql.tada/internal': 1.0.9(graphql@16.14.0)(typescript@5.9.3) - graphql: 16.14.0 + '@0no-co/graphqlsp': 1.17.4(graphql@16.14.2)(typescript@5.9.3) + '@gql.tada/internal': 1.2.2(graphql@16.14.2)(typescript@5.9.3) + graphql: 16.14.2 typescript: 5.9.3 - '@gql.tada/internal@1.0.9(graphql@16.14.0)(typescript@5.9.3)': + '@gql.tada/internal@1.2.2(graphql@16.14.2)(typescript@5.9.3)': dependencies: - '@0no-co/graphql.web': 1.2.0(graphql@16.14.0) - graphql: 16.14.0 + '@0no-co/graphql.web': 1.3.3(graphql@16.14.2) + graphql: 16.14.2 typescript: 5.9.3 - '@graphql-typed-document-node/core@3.2.0(graphql@16.14.0)': + '@graphql-typed-document-node/core@3.2.0(graphql@16.14.2)': dependencies: - graphql: 16.14.0 + graphql: 16.14.2 '@hono/node-server@1.19.14(hono@4.12.18)': dependencies: @@ -2337,36 +2337,36 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@mysten/bcs@1.9.2': + '@mysten/bcs@2.1.0': dependencies: - '@mysten/utils': 0.2.0 - '@scure/base': 1.2.6 + '@mysten/utils': 0.4.0 + '@scure/base': 2.3.0 - '@mysten/sui@1.45.2(typescript@5.9.3)': + '@mysten/sui@2.24.0(typescript@5.9.3)': dependencies: - '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.0) - '@mysten/bcs': 1.9.2 - '@mysten/utils': 0.2.0 - '@noble/curves': 1.9.4 - '@noble/hashes': 1.8.0 + '@graphql-typed-document-node/core': 3.2.0(graphql@16.14.2) + '@mysten/bcs': 2.1.0 + '@mysten/utils': 0.4.0 + '@noble/curves': 2.3.0 + '@noble/hashes': 2.3.0 '@protobuf-ts/grpcweb-transport': 2.11.1 '@protobuf-ts/runtime': 2.11.1 '@protobuf-ts/runtime-rpc': 2.11.1 - '@scure/base': 1.2.6 - '@scure/bip32': 1.7.0 - '@scure/bip39': 1.6.0 - gql.tada: 1.9.2(graphql@16.14.0)(typescript@5.9.3) - graphql: 16.14.0 + '@scure/base': 2.3.0 + '@scure/bip32': 2.3.0 + '@scure/bip39': 2.3.0 + gql.tada: 1.11.3(graphql@16.14.2)(typescript@5.9.3) + graphql: 16.14.2 poseidon-lite: 0.2.1 - valibot: 1.4.0(typescript@5.9.3) + valibot: 1.4.2(typescript@5.9.3) transitivePeerDependencies: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' - typescript - '@mysten/utils@0.2.0': + '@mysten/utils@0.4.0': dependencies: - '@scure/base': 1.2.6 + '@scure/base': 2.3.0 '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: @@ -2375,11 +2375,11 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@noble/curves@1.9.4': + '@noble/curves@2.3.0': dependencies: - '@noble/hashes': 1.8.0 + '@noble/hashes': 2.3.0 - '@noble/hashes@1.8.0': {} + '@noble/hashes@2.3.0': {} '@oxc-project/types@0.112.0': {} @@ -2575,18 +2575,17 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.0': optional: true - '@scure/base@1.2.6': {} + '@scure/base@2.3.0': {} - '@scure/bip32@1.7.0': + '@scure/bip32@2.3.0': dependencies: - '@noble/curves': 1.9.4 - '@noble/hashes': 1.8.0 - '@scure/base': 1.2.6 + '@noble/curves': 2.3.0 + '@noble/hashes': 2.3.0 + '@scure/base': 2.3.0 - '@scure/bip39@1.6.0': + '@scure/bip39@2.3.0': dependencies: - '@noble/hashes': 1.8.0 - '@scure/base': 1.2.6 + '@noble/hashes': 2.3.0 '@shikijs/core@2.5.0': dependencies: @@ -3089,19 +3088,19 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - gql.tada@1.9.2(graphql@16.14.0)(typescript@5.9.3): + gql.tada@1.11.3(graphql@16.14.2)(typescript@5.9.3): dependencies: - '@0no-co/graphql.web': 1.2.0(graphql@16.14.0) - '@0no-co/graphqlsp': 1.15.4(graphql@16.14.0)(typescript@5.9.3) - '@gql.tada/cli-utils': 1.7.3(@0no-co/graphqlsp@1.15.4(graphql@16.14.0)(typescript@5.9.3))(graphql@16.14.0)(typescript@5.9.3) - '@gql.tada/internal': 1.0.9(graphql@16.14.0)(typescript@5.9.3) + '@0no-co/graphql.web': 1.3.3(graphql@16.14.2) + '@0no-co/graphqlsp': 1.17.4(graphql@16.14.2)(typescript@5.9.3) + '@gql.tada/cli-utils': 1.9.3(@0no-co/graphqlsp@1.17.4(graphql@16.14.2)(typescript@5.9.3))(graphql@16.14.2)(typescript@5.9.3) + '@gql.tada/internal': 1.2.2(graphql@16.14.2)(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@gql.tada/svelte-support' - '@gql.tada/vue-support' - graphql - graphql@16.14.0: {} + graphql@16.14.2: {} has-flag@4.0.0: {} @@ -3571,7 +3570,7 @@ snapshots: dependencies: rolldown: 1.0.0-rc.16 - valibot@1.4.0(typescript@5.9.3): + valibot@1.4.2(typescript@5.9.3): optionalDependencies: typescript: 5.9.3 diff --git a/typescript/CHANGELOG.md b/typescript/CHANGELOG.md index 306ad6d..e739504 100644 --- a/typescript/CHANGELOG.md +++ b/typescript/CHANGELOG.md @@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **The documented size of the conformance suite was wrong everywhere it appeared.** The repo + stated it three different ways — `README.md` said 133 vectors, `docs/specification.md` said + 161 in two places — while `spec/vectors/` holds **167 vectors across 14 files**. All three now + report the real number. The consequential one was §13 *Conformance*, criterion 5: that clause + defines what makes an implementation s402-conformant, so an implementer in Go, Python or Rust + reading the spec was told they conformed at 161 vectors, leaving six shipped vectors outside + the definition of conformance. +- **The README told you to look for the conformance vectors in a directory that does not exist + in a clone.** It pointed at `test/conformance/vectors/`, which is generated by + `scripts/prepare-publish.sh` at publish time and is git-ignored. Cloning the repo and following + the instructions produced *No such file or directory*. The README now points clones at + `spec/vectors/` — the canonical, version-controlled location — and explains how the two paths + relate, so the npm path stays correct for package consumers. + +- **An architecture decision record claimed a CI check that has never existed.** ADR-006 stated, + in the present tense, that `scripts/compute-scheme-digests.mjs` "runs in CI and writes + `spec/scheme-digests.json`", failing any PR that changed a scheme doc without updating digests. + Neither file appears in any commit on any branch, and CI has no such job. It was the only ADR + carrying an `Implementation:` field, so the project's single answer to "was this built?" was + false. The paragraph is now marked as an unbuilt plan and the record reads + `Implementation: not-started`. + +### Added + +- **Every ADR now records whether it was actually built.** All twelve carry an `Implementation:` + field (`shipped` · `in-progress` · `not-started` · `upheld`), each determined against the code + rather than the prose. `Status: Accepted` only ever meant *decided* — so a decision that shipped + and one that was ratified and quietly never built were indistinguishable on the page. The + conceived-to-shipped ratio is now countable: **7 of 12 shipped.** Two findings fell straight out + of the exercise: ADR-004's extensions framework ships as the `s402/extensions` subpath while its + `Status` still reads *Proposed*, and ADR-010's S16 turns out to be half-built — version binding + is enforced in the envelope, its scheme-digest half blocked on ADR-006. +- **A regression test that fails when a documented vector count drifts from reality** + (`test/spec-doc-counts.test.ts`). It counts `spec/vectors/` and compares against every + `" vectors across files"` claim in `README.md` and `docs/specification.md`, and it + refuses to pass vacuously: if the wording changes so no claim is found, the test fails rather + than silently verifying nothing. Counts written into prose are derived values maintained by + hand, and three independent wrong numbers in one repo is what that looks like after a while. +- **The README now says who s402 is for, who it is not for, and what would prove it should + stop.** The last of those is a falsification section naming four observable conditions that + would retire the project — chiefly x402 absorbing the superset schemes, and no independent + implementation ever passing the conformance vectors. + ## [0.8.0] - 2026-06-28 The transport-abstraction release (ADR-011): **one seam, three carriers — HTTP, MCP, and A2A.** diff --git a/typescript/test/spec-doc-counts.test.ts b/typescript/test/spec-doc-counts.test.ts new file mode 100644 index 0000000..0f8e6f7 --- /dev/null +++ b/typescript/test/spec-doc-counts.test.ts @@ -0,0 +1,141 @@ +/** + * Doc consistency — every conformance-vector count printed in the repo's prose + * must match what spec/vectors/ actually contains. + * + * ── WHY THIS EXISTS ───────────────────────────────────────────────────────── + * + * On 2026-08-16 (DAN-855) this repo stated the size of its own conformance + * suite THREE different ways, and all three were wrong: + * + * README.md "133 machine-readable JSON test vectors" + * specification.md §0 "161 vectors across 13 files" + * specification.md §13 "the 161 machine-readable conformance test vectors" + * spec/vectors/ 167 vectors across 14 files <- the truth + * + * The first two are cosmetic. The third is NORMATIVE: §13 "Conformance" + * criterion 5 defines what makes an implementation s402-conformant, so a + * third-party implementer in Go or Rust was told they were conformant at 161 + * vectors, leaving six shipped vectors outside the definition of conformance. + * + * A count written into prose is a derived value stored by hand. It rots + * silently every time a vector is added, and nothing fails when it does. Three + * independent numbers in one repo is what that rot looks like once it has been + * running for a while. This test is the thing that fails. + * + * ── WHY THESE PATHS AND NOT THE OTHER ONES ────────────────────────────────── + * + * There are two vector directories and only one of them exists everywhere: + * + * spec/vectors/ tracked, canonical, in every clone + * typescript/test/conformance/vectors/ GITIGNORED — a copy that + * scripts/prepare-publish.sh makes for + * `npm pack` + * + * The prose says the vectors "ship in the npm package", which points at the + * copy — so the naive reading is to count the copy. A check keyed there passes + * on any machine that has ever packed and is red on its first CI run, because a + * fresh checkout correctly does not have that directory. Every path read below + * is tracked and present in every checkout. conformance.test.ts resolves + * spec/vectors/ the same way, for the same reason. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +const REPO_ROOT = join(import.meta.dirname, '..', '..'); +const VECTORS_DIR = join(REPO_ROOT, 'spec', 'vectors'); + +/** + * The canonical claim phrasing, held as a SOURCE STRING rather than a shared + * RegExp object. A regex carrying /g is stateful (`lastIndex`), and + * `expect(...).toMatch(re)` calls `.test()` internally, which advances that + * state — sharing one global regex across `matchAll` and `toMatch` makes + * results depend on assertion order, which is an intermittent false green. + * Every use below builds a fresh regex. + * + * Keeping the phrasing identical in both documents is deliberate: it is what + * lets one narrow pattern cover them, instead of a loose pattern that would + * match numbers it was never meant to. + */ +const CLAIM_PATTERN = String.raw`(\d+)\s+vectors\s+across\s+(\d+)\s+files`; +const claimsIn = (text: string) => [...text.matchAll(new RegExp(CLAIM_PATTERN, 'g'))]; + +/** + * Documents that state the count, and the minimum number of times each must + * state it. The minimum is the anti-vacuum guard: without it, rewording a + * sentence yields zero matches, zero comparisons, and a green test that has + * verified nothing. + */ +const DOCS = [ + { label: 'docs/specification.md', rel: ['docs', 'specification.md'], minClaims: 2 }, + { label: 'README.md', rel: ['README.md'], minClaims: 1 }, +] as const; + +function actualCounts(): { files: number; vectors: number } { + const files = readdirSync(VECTORS_DIR) + .filter((f) => f.endsWith('.json')) + .sort(); + + let vectors = 0; + for (const f of files) { + const parsed: unknown = JSON.parse(readFileSync(join(VECTORS_DIR, f), 'utf8')); + if (!Array.isArray(parsed)) { + throw new Error( + `spec/vectors/${f} is not a top-level JSON array — this test counts array ` + + `entries and would silently undercount a different shape.`, + ); + } + vectors += parsed.length; + } + + return { files: files.length, vectors }; +} + +describe('conformance-vector counts stated in prose', () => { + const actual = actualCounts(); + + it('spec/vectors/ is non-empty (guards against counting nothing)', () => { + expect(actual.files).toBeGreaterThan(0); + expect(actual.vectors).toBeGreaterThan(0); + }); + + for (const doc of DOCS) { + const path = join(REPO_ROOT, ...doc.rel); + + it(`${doc.label} states the count at least ${doc.minClaims}x`, () => { + const claims = claimsIn(readFileSync(path, 'utf8')); + expect( + claims.length, + `expected the " vectors across files" claim at least ${doc.minClaims} ` + + `time(s) in ${doc.label}. If the wording changed deliberately, update ` + + `CLAIM_PATTERN in this file — do not delete the assertion, or this check ` + + `silently stops checking.`, + ).toBeGreaterThanOrEqual(doc.minClaims); + }); + + it(`${doc.label} counts match spec/vectors/ on disk`, () => { + const claims = claimsIn(readFileSync(path, 'utf8')).map((m) => ({ + vectors: Number(m[1]), + files: Number(m[2]), + })); + for (const claim of claims) { + expect(claim).toEqual({ vectors: actual.vectors, files: actual.files }); + } + }); + } + + it('specification.md §13 still binds conformance to the vector set', () => { + const doc = readFileSync(join(REPO_ROOT, 'docs', 'specification.md'), 'utf8'); + + // indexOf returning -1 must fail loudly. `slice(-1)` hands back the + // document's last character, which is a non-empty string, so a "not empty" + // assertion would PASS on a missing section. + const start = doc.indexOf('## 13. Conformance'); + expect(start, 'the "## 13. Conformance" heading is gone from the spec').toBeGreaterThan(-1); + + const section = doc.slice(start); + expect(section).toMatch(/conformance test vector/i); + expect(section).toMatch(new RegExp(CLAIM_PATTERN)); + }); +});