Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 51 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/adr/001-protocol-boundaries.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/adr/002-s402-is-pure-protocol.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/adr/003-upto-scheme.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/004-extensions-architecture.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/adr/005-interop-superset-principle.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs/adr/006-version-negotiation-and-scheme-digests.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -95,7 +96,18 @@ advertised = "sha256-" || base64url_no_pad(digest)

**Target is the prose spec, not derived artifacts.** The markdown spec at `docs/schemes/<scheme>.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

Expand Down
1 change: 1 addition & 0 deletions docs/adr/007-settlement-response-envelope.md
Original file line number Diff line number Diff line change
@@ -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`)
Expand Down
1 change: 1 addition & 0 deletions docs/adr/008-safety-invariants-s9-s13.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/adr/009-open-gaps-rotation-reorg.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/adr/010-safety-invariants-s15-s16.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
1 change: 1 addition & 0 deletions docs/adr/011-transport-abstraction.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
21 changes: 16 additions & 5 deletions docs/guide/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions docs/specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.

Expand Down
9 changes: 7 additions & 2 deletions mcp-demo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions mcp-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 23 additions & 8 deletions mcp-demo/src/agent.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<T>(method: string, params: Record<string, unknown> = {}, headers: Record<string, string> = {}): Promise<T> {
const res = await fetch(SERVER_URL, {
Expand All @@ -28,17 +32,28 @@ async function settleViaS402(): Promise<string> {

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() {
Expand Down
Loading