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.
- 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-PAYMENTand the server grants access.
Key files:
src/x402.rs: types and helpers for challenges and receiptssrc/bridge.rs: HTTP routes, challenge emission, and receipt verificationsrc/wallet.rs: client helpers (x402_pay_from_challenge,pay_with_binding)src/config.rs: x402 configuration under[bridge]src/main.rs: CLI commandx402-pay
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>"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.
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
// }
]
}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 trueIf 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/helloFrom 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?;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.
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_b64is used to bind the payment to the invoice/resource at send time (carried asnote_s).
EVM facilitator method:
- Posts the client-provided proof to the facilitator
/verifyendpoint. - Grants access when the facilitator returns success.
GET /402/challenge?resource=<path>: returns a 402 challenge (also emitted automatically on protected paths).POST /402/verify: accepts anX-PAYMENTbody (base64-url string or JSON); generally not needed by clients using headers.GET /paid/hello: demo paid endpoint (subject to prefixes inx402_protected_prefixes).
- Keep
x402_invoice_ttl_msshort 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_recipientto 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.
- 402 returned repeatedly: ensure you include
X-PAYMENTfrom 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.
- 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.
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.
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.
{
"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_commitmentbinds destination OTP bytes, Kyber CT, and next‑hop lock hash; integrity enforced viacommitment_id_v1.unlock_preimage_ctis the 32‑byte preimage encrypted under a KEM‑derived key: Kyber768→XChaCha20‑Poly1305.
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)
- Pending key:
- For each coin: decapsulate KEM using facilitator Kyber SK, decrypt preimage, enforce
commitment_id_v1, nullifier precheck, buildSpend::create_hashlock,validate,apply, gossip. - Enforce
sum(amount_le) ≥ total_amountacross constructed spends.
- 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_commitmentand 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”.