Skip to content

Latest commit

 

History

History
251 lines (194 loc) · 9.64 KB

File metadata and controls

251 lines (194 loc) · 9.64 KB

x402 payments in Unchained

This document explains how Unchained integrates the x402 protocol (HTTP 402 Payment Required) to charge for API/content access directly over HTTP, using either native Unchained payments or an optional EVM facilitator method.

Overview

  • Server returns HTTP 402 with a JSON challenge when a protected resource is requested without a valid payment.
  • Clients fulfill the challenge by paying via:
    • Native Unchained method (hashlock spends addressed to the server wallet), or
    • Optional EVM method via an x402 facilitator.
  • After payment, clients resubmit the request with X-PAYMENT and the server grants access.

Key files:

  • src/x402.rs: types and helpers for challenges and receipts
  • src/bridge.rs: HTTP routes, challenge emission, and receipt verification
  • src/wallet.rs: client helpers (x402_pay_from_challenge, pay_with_binding)
  • src/config.rs: x402 configuration under [bridge]
  • src/main.rs: CLI command x402-pay

Enable x402

In config.toml under [bridge]:

[bridge]
x402_enabled = true
x402_min_confs = 0                  # confirmations for native method; 0 supported
x402_invoice_ttl_ms = 300000        # 5 minutes
x402_protected_prefixes = ["/paid"] # any path prefix to protect

# Optional: advertise EVM facilitator method along with native Unchained
# x402_facilitator_url = "https://x402.org/facilitator"  # example test facilitator
# x402_evm_network = "base-sepolia"
# x402_evm_recipient = "0xYourRecipientAddress"
# x402_price_usd_micros = 1000                          # $0.001

# Optional: override native Unchained recipient handle (stealth address or KeyDoc JSON)
# x402_recipient_handle = "<stealth_or_keydoc>"

Protected resources

Any path under a configured protected prefix is gated. A minimal example route is included:

  • GET /paid/hello → returns {"hello":"world","paid":true} when paid; otherwise responds 402 with a challenge.

Challenge format

Server responds with status 402 and JSON (simplified example):

{
  "version": "x402-unchained-v1",
  "invoice_id": "<invoice-id>",
  "resource": "/paid/hello",
  "methods": [
    {
      "chain": "unchained",
      "chain_id": "<hex32-chain-id>",
      "recipient": "<recipient-handle>",
      "amount": 1,
      "expiry_ms": 1737056435000,
      "note_binding_b64": "<binding-32-bytes-b64url>",
      "min_confs": 0
    }
    // Optional second method if EVM facilitator is configured:
    // {
    //   "chain": "evm",
    //   "chain_id": "base-sepolia",
    //   "recipient": "0x...",
    //   "expiry_ms": 1737056435000,
    //   "note_binding_b64": "...",
    //   "min_confs": 0,
    //   "network": "base-sepolia",
    //   "facilitator_url": "https://x402.org/facilitator",
    //   "recipient_evm": "0x...",
    //   "price_usd_micros": 1000
    // }
  ]
}

Client: CLI e2e

Use the x402-pay command to pay a challenge and automatically resubmit the request:

# 1) Request the protected resource (will receive 402)
curl -i http://127.0.0.1:9110/paid/hello

# 2) Pay and auto-resubmit with native Unchained method
unchained x402-pay --url http://127.0.0.1:9110/paid/hello --auto-resubmit true
# prints: X-PAYMENT: <base64-url>
# and then the resource body if --auto-resubmit is true

If you prefer manual resubmission:

HEADER=$(unchained x402-pay --url http://127.0.0.1:9110/paid/hello --auto-resubmit false | awk '{print $2}')
curl -H "X-PAYMENT: $HEADER" http://127.0.0.1:9110/paid/hello

Client: library

From Rust, if you already have a 402 challenge JSON body:

let header = wallet.x402_pay_from_challenge(&challenge_json, &net).await?;
let body = reqwest::Client::new()
    .get("http://127.0.0.1:9110/paid/hello")
    .header("X-PAYMENT", header)
    .send().await?
    .text().await?;

Receipt formats

Unchained-native receipt (AnyReceipt::Unchained) includes spend hashes and the binding:

{
  "type": "unchained",
  "invoice_id": "...",
  "spend_hashes": ["..."],
  "amount": 1,
  "binding_b64": "..."
}

When EVM facilitator method is used, a facilitator proof is sent (AnyReceipt::Evm). The server posts this to <facilitator_url>/verify and expects { "ok": true } on success.

The receipt payload is base64-url encoded and carried in the X-PAYMENT header.

Server verification

Native Unchained method:

  • Verifies that the referenced spends exist in local state, are addressed to the server wallet, and meet the requested amount.
  • The challenge’s note_binding_b64 is used to bind the payment to the invoice/resource at send time (carried as note_s).

EVM facilitator method:

  • Posts the client-provided proof to the facilitator /verify endpoint.
  • Grants access when the facilitator returns success.

Endpoints

  • GET /402/challenge?resource=<path>: returns a 402 challenge (also emitted automatically on protected paths).
  • POST /402/verify: accepts an X-PAYMENT body (base64-url string or JSON); generally not needed by clients using headers.
  • GET /paid/hello: demo paid endpoint (subject to prefixes in x402_protected_prefixes).

Production notes

  • Keep x402_invoice_ttl_ms short to reduce stale invoices.
  • For native method, amounts are in Unchained coin units. Extend the challenge builder if you need per-resource pricing.
  • If using EVM facilitator, set x402_evm_recipient to a wallet you control. For test, a public facilitator may be used; for production, configure your own or a trusted provider.
  • Avoid printing secrets. Native payments derive note bindings deterministically; no refund secrets are used in x402.

Troubleshooting

  • 402 returned repeatedly: ensure you include X-PAYMENT from the CLI output or library helper.
  • Native verification failed: allow the node to sync spends, or retry after a brief delay.
  • Facilitator verify failed: confirm x402_facilitator_url, network, recipient address, and the facilitator’s test/mainnet environment.

Compatibility

  • The integration is additive and does not modify consensus or topic names.
  • Existing bridge APIs remain available; x402 routes run on the same rpc_bind.

Meta‑transfer (EIP‑3009‑like) gasless authorizations

This additive flow enables sponsored (gasless) transfers by having the sender sign an off‑chain authorization that a facilitator submits as regular V3 spends. Consensus remains unchanged: the chain still validates hashlock preimages and nullifiers.

  • What it is: An off‑chain, signed document listing inputs and receiver commitments. Each per‑coin unlock preimage is encrypted to the facilitator’s Kyber PK. The facilitator verifies, decrypts, creates V3 spends, and gossips them.
  • Why: Lets services pay network fees/ops on behalf of users while preserving Unchained’s PQ model and hashlock consensus.

CLI: create an authorization

unchained meta-authz-create \
  --to <PAYCODE> \
  --amount <AMOUNT> \
  --valid-after <EPOCH_INCLUSIVE> \
  --valid-before <EPOCH_EXCLUSIVE> \
  --facilitator-kyber-b64 <B64_KYBER_PK> \
  --out authz.json \
  [--binding-b64 <B64_32>]
  • --to: recipient handle (stealth address or KeyDoc JSON).
  • --facilitator-kyber-b64: facilitator’s Kyber768 public key (base64‑url of raw bytes).
  • --binding-b64 (optional): 32‑byte binding for receipt correlation (x402‑style); strengthens unlinkability.

Authorization JSON shape (V1)

{
  "version": 1,
  "chain_id": "<32 byte hex or raw in POST>",
  "from_address": "<blake3(dili_pk)>",
  "from_dili_pk": "<raw bytes>",
  "to_handle": "<recipient handle>",
  "total_amount": 3,
  "valid_after_epoch": 123,
  "valid_before_epoch": 130,
  "nonce": "<32 bytes>",
  "coins": [
    {
      "coin_id": "<32 bytes>",
      "receiver_commitment": {
        "one_time_pk": "<bytes>",
        "kyber_ct": "<bytes>",
        "next_lock_hash": "<32 bytes>",
        "commitment_id": "<32 bytes>",
        "amount_le": 1
      },
      "kem_ct": "<kyber768 ct>",
      "aead_nonce24": "<24 bytes>",
      "unlock_preimage_ct": "<xchacha20-poly1305 ciphertext>"
    }
  ],
  "sig": "<dilithium3 detached signature over domain|bincode(signable)>"
}

Notes:

  • receiver_commitment binds destination OTP bytes, Kyber CT, and next‑hop lock hash; integrity enforced via commitment_id_v1.
  • unlock_preimage_ct is the 32‑byte preimage encrypted under a KEM‑derived key: Kyber768→XChaCha20‑Poly1305.

Facilitator HTTP endpoint

  • POST /meta-transfer/submit (body: JSON or bincode of the structure above)
  • Response: { "spend_hashes": ["<hex>", ...] } on success.

Server‑side checks:

  • Verify chain_id, validity window (valid_after_epoch ≤ now < valid_before_epoch).
  • Verify Dilithium3 signature and from_address == blake3(from_dili_pk).
  • Two‑phase replay protection in RocksDB CF meta_authz_used:
    • Pending key: P|from_address|nonce (set before processing)
    • Used key: U|from_address|nonce (set after successful submission)
  • For each coin: decapsulate KEM using facilitator Kyber SK, decrypt preimage, enforce commitment_id_v1, nullifier precheck, build Spend::create_hashlock, validate, apply, gossip.
  • Enforce sum(amount_le) ≥ total_amount across constructed spends.

Security model & operational notes

  • The facilitator learns per‑coin preimages to spend on behalf of the sender—this is required in a hashlock system. The signed document constrains outputs via receiver_commitment and window/nonce checks.
  • No consensus changes; all spends remain standard V3 hashlock transfers with nullifier uniqueness.
  • PQ primitives only: Dilithium3 (sign), Kyber768 (KEM), XChaCha20‑Poly1305 (AEAD).
  • If submission fails part‑way, pending marker prevents concurrent duplicate processing; a successful run atomically flips to “used”.