Accept USDG on Robinhood Chain without asking anyone for gas. The payer signs an EIP-3009 authorization; the facilitator submits the transaction and pays the ETH.
This is the standard x402 exact scheme for EVM — the same one used with USDC
on Base. Nothing here is bespoke crypto: if you already speak x402, you only need the
chain and token values below.
Four steps. The payer never sends a transaction, so they need no ETH — only USDG.
| Step | Who | What happens |
|---|---|---|
| Quote | client → server | Request the resource, get 402 with the terms |
| Sign | payer's wallet | Sign a TransferWithAuthorization (EIP-712). Off-chain, free |
| Verify | server → facilitator | Signature, recipient, deadline and balance are checked |
| Settle | facilitator → chain | transferWithAuthorization() moves USDG payer → payee. Facilitator pays gas |
Because EIP-3009 nonces are random 32-byte values rather than a sequential counter,
two payments from the same wallet can be in flight at once without invalidating each
other. That is the main reason this uses EIP-3009 and not EIP-2612 permit.
| Network | Robinhood Chain — chain ID 4663 |
| RPC | https://rpc.mainnet.chain.robinhood.com |
| Explorer | robinhoodchain.blockscout.com |
| Native gas | ETH — needed by the facilitator, never by the payer |
| Token | USDG "Global Dollar" (Paxos) |
| Address | 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168 |
| Decimals | 6 — 1 USDG = 1000000, not 1e18 |
| x402 network id | robinhood |
USDG has no version() function — calling it reverts. x402 falls
back to that call when building the EIP-712 domain, so every payment requirement must
carry the domain explicitly:
extra: { name: "Global Dollar", version: "1" }
Omit it and verification fails with invalid_network, which points nowhere near
the real cause. The verifyingContract is the proxy address above, not
the implementation behind it.
| Method | Path | Returns |
|---|---|---|
| GET · POST | /pay/1 /pay/5 /pay/25 | 402 with the terms for that amount, or 200 when an X-PAYMENT header settles |
| GET | /x402 /.well-known/x402 | Discovery: every tier's payment requirements in one document. This is what an agent reads, not this page |
| GET | /llms.txt | This guide as plain text |
| GET | /docs | This page |
| Method | Path | Body / returns |
|---|---|---|
| POST | /verify | {paymentPayload, paymentRequirements} → {isValid, invalidReason, payer} |
| POST | /settle | Same body → {success, transaction, payer}. Non-2xx if settlement failed |
| GET | /supported | The schemes this facilitator serves |
The facilitator accepts exact + eip3009 on robinhood only. A
permit payload, or one for another network, is rejected with a 400.
Three amounts are on sale: 1, 5 and 25 USDG,
at /pay/1, /pay/5 and /pay/25. Each quotes only itself;
/x402 lists all three at once.
# Ask what the resource costs
curl -X POST https://vled.example/pay/1
# 402 Payment Required
{
"x402Version": 1,
"accepts": [{
"scheme": "exact",
"network": "robinhood",
"maxAmountRequired": "1000000", // 1 USDG, 6 decimals
"asset": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
"payTo": "{{PAY_TO}}",
"maxTimeoutSeconds": 3600,
"extra": { "name": "Global Dollar", "version": "1" }
}],
"error": "Payment required"
}
Sign the EIP-712 struct below, then base64 the payload into an X-PAYMENT header
and repeat the request. Take name and version from the quote's
extra — don't read them from the contract, it can't tell you.
const authorization = {
from: payer,
to: req.payTo,
value: req.maxAmountRequired,
validAfter: "0",
validBefore: String(Math.floor(Date.now() / 1000) + 3600),
nonce: randomBytes32(), // random, not sequential
}
const signature = await wallet.signTypedData({
primaryType: "TransferWithAuthorization",
domain: {
name: req.extra.name, // "Global Dollar"
version: req.extra.version, // "1"
chainId: 4663,
verifyingContract: req.asset, // the USDG proxy
},
types: { TransferWithAuthorization: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "validAfter", type: "uint256" },
{ name: "validBefore", type: "uint256" },
{ name: "nonce", type: "bytes32" },
]},
message: authorization,
})
const header = btoa(JSON.stringify({
x402Version: 1,
scheme: "exact",
network: "robinhood",
payload: { authorizationType: "eip3009", signature, authorization },
}))
await fetch("/pay/1", { method: "POST", headers: { "X-PAYMENT": header } })
// 200 → { message, tier, payer, transaction }
A rejected payment comes back as 402 with a reason. These are the ones
you will actually hit:
| Reason | What it really means |
|---|---|
| insufficient_funds | The payer's USDG balance is below the amount. Not a gas problem — payers never need gas |
| invalid_exact_evm_payload_signature | The signature doesn't recover to from. Usually a wrong EIP-712 domain |
| invalid_network | Often the missing extra.name/extra.version, not a wrong chain |
| invalid_exact_evm_payload_recipient_mismatch | authorization.to isn't the quoted payTo |
| invalid_exact_evm_payload_authorization_valid_before | The authorization expired before it could settle |
| settlement_error | The transaction failed on-chain. Commonly the facilitator wallet is out of ETH |
A failed settlement is never a 200. If the facilitator can't move the funds, you
don't get the resource.
EIP-3009 — Transfer With Authorization · x402 protocol · Robinhood Chain explorer