Protocol
Quickstart
rail0 is one immutable contract, and anyone can call it. This page runs the full payment
lifecycle against it with Foundry’s cast — the bare
metal. In production a wallet signs the EIP-712 payloads and a library builds the calls, or the
rail0 Gateway does that work for you.
Every call has the same shape: the merchant (payee) submits the transaction and pays gas; the
buyer (payer) only ever signs, off-chain. There is no approve step: an EIP-3009
receiveWithAuthorization signature authorizes one specific transfer, so nobody grants the
contract an allowance first and no standing permission is left open afterwards.
Pick a chain and one of the stablecoins its deployment accepts, from Chains, then describe the payment. Its terms are committed on the first call and passed, unchanged, on every call after it.
export RPC=https://rpc.example.networkexport RAIL0=0x... # the rail0 deployment on that chainexport TOKEN=0x... # an accepted stablecoinexport PAYER=0x... # buyer walletexport PAYEE=0x... # merchant walletexport PAYER_KEY=0x... # buyer signing key (signs EIP-3009 off-chain)export PAYEE_KEY=0x... # merchant signing key (submits transactions)
export AMOUNT=100000000 # 100 USDC at 6 decimalsexport AUTH_EXPIRY=1736294400 # authorize-by / capture-by deadline (Unix seconds)export REFUND_EXPIRY=1738972800 # refund-by deadline (Unix seconds)
# (payer, payee, token, amount, authorizationExpiry, refundExpiry)export PAYMENT="($PAYER,$PAYEE,$TOKEN,$AMOUNT,$AUTH_EXPIRY,$REFUND_EXPIRY)"export PAYMENT_TYPE='(address,address,address,uint120,uint48,uint48)'export PAYMENT_ID=$(cast keccak "order-12345")REFUND_EXPIRY must be at least one day after AUTH_EXPIRY, so every payment keeps a usable
refund window.
1 — Authorize a payment
Section titled “1 — Authorize a payment”The buyer signs an EIP-3009 ReceiveWithAuthorization off-chain; the merchant submits it and
the funds move into the rail0 escrow.
# 1. The deterministic EIP-3009 nonce rail0 expects — it binds the payment's termsCONFIG_HASH=$(cast call $RAIL0 "hashPayment($PAYMENT_TYPE)(bytes32)" "$PAYMENT" --rpc-url $RPC)NONCE=$(cast call $RAIL0 "authorizeNonce(bytes32,bytes32)(bytes32)" $PAYMENT_ID $CONFIG_HASH --rpc-url $RPC)
# 2. The ReceiveWithAuthorization digest, over the TOKEN's EIP-712 domain.# rail0 pins validAfter = 0 and validBefore = authorizationExpiry; value = amount.TOKEN_DOMAIN=$(cast call $TOKEN "DOMAIN_SEPARATOR()(bytes32)" --rpc-url $RPC)RWA_TYPEHASH=$(cast keccak \ "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)")STRUCT_HASH=$(cast keccak $(cast abi-encode \ "f(bytes32,address,address,uint256,uint256,uint256,bytes32)" \ $RWA_TYPEHASH $PAYER $RAIL0 $AMOUNT 0 $AUTH_EXPIRY $NONCE))DIGEST=$(cast keccak 0x1901${TOKEN_DOMAIN:2}${STRUCT_HASH:2})
# 3. The buyer signs the digest; split the signature into v, r, sSIG=$(cast wallet sign --no-hash --private-key $PAYER_KEY $DIGEST)R=0x${SIG:2:64}; S=0x${SIG:66:64}; V=0x${SIG:130:2}
# 4. The merchant submits and pays gascast send $RAIL0 "authorize(bytes32,$PAYMENT_TYPE,uint8,bytes32,bytes32)" \ $PAYMENT_ID "$PAYMENT" $V $R $S \ --rpc-url $RPC --private-key $PAYEE_KEYIt must be ReceiveWithAuthorization, not TransferWithAuthorization: the field lists match
but the typehashes differ, and rail0 spends the signature through receiveWithAuthorization.
A buyer’s wallet shows this as a standard EIP-3009 prompt and signs it with
eth_signTypedData_v4.
To pay straight through with no hold, use chargeNonce and charge in place of
authorizeNonce and authorize — the distinct nonce means an authorize signature can never be
replayed as a charge.
2 — Capture
Section titled “2 — Capture”Merchant only. Moves escrowed funds to the merchant before authorizationExpiry — in full, or
in parts across several calls.
# Capture 100 USDC (6 decimals) of the escrowed authorizationcast send $RAIL0 "capture(bytes32,$PAYMENT_TYPE,uint256)" \ $PAYMENT_ID "$PAYMENT" 100000000 \ --rpc-url $RPC --private-key $PAYEE_KEYEvery captured token reaches the merchant in full — rail0 takes no fee.
3 — Refund
Section titled “3 — Refund”Captured funds live in the merchant’s wallet, so a refund is the merchant signing its own
EIP-3009 authorization and submitting it. The funds always go back to the payer, before
refundExpiry.
# 1. The refund nonce encodes the CURRENT balances — still in escrow, still refundable —# so every partial refund gets a distinct nonce. Read them from getPaymentState.# Here: nothing left in escrow, 50 USDC refundable.NONCE=$(cast call $RAIL0 "refundNonce(bytes32,bytes32,uint120,uint120)(bytes32)" \ $PAYMENT_ID $CONFIG_HASH 0 50000000 --rpc-url $RPC)
# 2. The digest, now from the payee, with validBefore = refundExpirySTRUCT_HASH=$(cast keccak $(cast abi-encode \ "f(bytes32,address,address,uint256,uint256,uint256,bytes32)" \ $RWA_TYPEHASH $PAYEE $RAIL0 50000000 0 $REFUND_EXPIRY $NONCE))DIGEST=$(cast keccak 0x1901${TOKEN_DOMAIN:2}${STRUCT_HASH:2})
# 3. The merchant signs; split into v, r, sSIG=$(cast wallet sign --no-hash --private-key $PAYEE_KEY $DIGEST)R=0x${SIG:2:64}; S=0x${SIG:66:64}; V=0x${SIG:130:2}
# 4. The merchant submits — the funds reach $PAYERcast send $RAIL0 "refund(bytes32,$PAYMENT_TYPE,uint256,uint8,bytes32,bytes32)" \ $PAYMENT_ID "$PAYMENT" 50000000 $V $R $S \ --rpc-url $RPC --private-key $PAYEE_KEYReturning the escrow
Section titled “Returning the escrow”# Void — the merchant cancels the hold, returning all of it to the buyer.# Only while nothing has been captured (otherwise it reverts AlreadyCaptured).cast send $RAIL0 "void(bytes32,$PAYMENT_TYPE)" \ $PAYMENT_ID "$PAYMENT" --rpc-url $RPC --private-key $PAYEE_KEY
# Release — after authorizationExpiry, either party returns what is left in escrow to $PAYERcast send $RAIL0 "release(bytes32,$PAYMENT_TYPE)" \ $PAYMENT_ID "$PAYMENT" --rpc-url $RPC --private-key $PAYER_KEYReading state
Section titled “Reading state”# (bool exists, uint120 capturable, uint120 refundable, bool disputed)cast call $RAIL0 "getPaymentState(bytes32)((bool,uint120,uint120,bool))" $PAYMENT_ID --rpc-url $RPC
# Which stablecoins this deployment acceptscast call $RAIL0 "acceptedTokens()(address[])" --rpc-url $RPCcapturable is what is still held in escrow; refundable is what has reached the merchant
and can still be refunded.