Skip to content

Design Principles

How s402 is built and why it's built that way.

Three-Actor Model

Every s402 interaction involves three roles:

┌──────────┐     ┌──────────────────┐     ┌──────────────┐
│  Client   │────▶│  Resource Server  │────▶│  Facilitator │
│  (payer)  │◀────│  (payee)         │◀────│  (settler)   │
└──────────┘     └──────────────────┘     └──────────────┘
RoleResponsibilityClass
ClientBuilds and signs payment transactionss402Client
Resource ServerSends 402 requirements, serves content after payments402ResourceServer
FacilitatorVerifies and broadcasts transactions on-chains402Facilitator

Direct settlement collapses client + facilitator — the client broadcasts its own transaction without a middleman.

Scheme Registry Pattern

Each role is a registry of scheme implementations. You register only the schemes you need:

typescript
const client = new s402Client();
client.register('sui:mainnet', myExactScheme);
client.register('sui:mainnet', myStreamScheme);

// Client auto-selects the best scheme from server's accepts array
const payment = await client.createPayment(requirements);

This pattern means:

  • No dead code — don't pay for schemes you don't use
  • Extensible — add new schemes without modifying the core
  • Network-aware — different scheme implementations per network

Scheme Interfaces

Each scheme implements a specific interface depending on its role:

typescript
// Client-side: builds payment payloads
interface s402ClientScheme {
  readonly scheme: s402Scheme;
  createPayment(requirements: s402PaymentRequirements): Promise<s402PaymentPayload>;
}

// Server-side: builds requirements
interface s402ServerScheme {
  readonly scheme: s402Scheme;
  buildRequirements(config: s402RouteConfig): s402PaymentRequirements;
}

// Facilitator-side: verifies and settles
interface s402FacilitatorScheme {
  readonly scheme: s402Scheme;
  verify(payload, requirements): Promise<s402VerifyResponse>;
  settle(payload, requirements): Promise<s402SettleResponse>;
}

// Direct settlement: no facilitator needed
interface s402DirectScheme {
  readonly scheme: s402Scheme;
  settleDirectly(requirements): Promise<s402SettleResponse>;
}

Each scheme has its own verify logic. Exact verification (signature recovery + dry-run) is fundamentally different from stream verification (deposit check) or escrow verification (deadline + arbiter check). The facilitator dispatches to the correct scheme — it never shares verification logic across schemes.

Zero Runtime Dependencies

The s402 package has zero production dependencies. The entire protocol layer is pure TypeScript using only:

  • TextEncoder / TextDecoder (built-in)
  • btoa / atob (built-in)
  • JSON.parse / JSON.stringify (built-in)

Sui SDK, cryptographic signing, and RPC calls live in scheme implementations that you build on top of the protocol layer. This keeps the core small, auditable, and free from supply chain risk.

Wire Compatibility

s402 uses the same HTTP headers as x402:

HeaderDirectionContent
payment-requiredServer → ClientBase64-encoded JSON requirements
X-PAYMENTClient → ServerBase64-encoded JSON payload
payment-responseServer → ClientBase64-encoded JSON settle result

The presence of s402Version in the decoded JSON distinguishes s402 from x402. The normalizeRequirements() function handles auto-detection.

Validation at Trust Boundaries

All decode functions validate the shape of incoming data:

typescript
// This is a trust boundary — data comes from the network
const requirements = decodePaymentRequired(header);
// ✓ Validates: accepts (array), network (string), asset (string),
//              amount (string), payTo (string)
// ✗ Throws s402Error('INVALID_PAYLOAD') if malformed

Validation happens at decode time, not deep in business logic. Once data passes the trust boundary, internal code can rely on the types.

Expiration Guard

The facilitator's process() method provides an expiration guard:

typescript
async process(payload, requirements): Promise<s402SettleResponse> {
  // 1. Reject expired requirements
  if (requirements.expiresAt != null && Date.now() > requirements.expiresAt) {
    return { success: false, errorCode: 'REQUIREMENTS_EXPIRED' };
  }
  // 2. Verify
  const result = await scheme.verify(payload, requirements);
  if (!result.valid) return { success: false, errorCode: 'VERIFICATION_FAILED' };
  // 3. Settle
  return scheme.settle(payload, requirements);
}

Atomicity comes from Sui's PTBs in the scheme implementation, not from this orchestration layer. The process() method provides the temporal guard — the PTB provides the atomic guarantee.

Forward Compatibility

  • extra 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

Released under the MIT License.