Skip to content

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.

Terminal window
export RPC=https://rpc.example.network
export RAIL0=0x... # the rail0 deployment on that chain
export TOKEN=0x... # an accepted stablecoin
export PAYER=0x... # buyer wallet
export PAYEE=0x... # merchant wallet
export 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 decimals
export 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.

The buyer signs an EIP-3009 ReceiveWithAuthorization off-chain; the merchant submits it and the funds move into the rail0 escrow.

Terminal window
# 1. The deterministic EIP-3009 nonce rail0 expects — it binds the payment's terms
CONFIG_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, s
SIG=$(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 gas
cast send $RAIL0 "authorize(bytes32,$PAYMENT_TYPE,uint8,bytes32,bytes32)" \
$PAYMENT_ID "$PAYMENT" $V $R $S \
--rpc-url $RPC --private-key $PAYEE_KEY

It 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.

Merchant only. Moves escrowed funds to the merchant before authorizationExpiry — in full, or in parts across several calls.

Terminal window
# Capture 100 USDC (6 decimals) of the escrowed authorization
cast send $RAIL0 "capture(bytes32,$PAYMENT_TYPE,uint256)" \
$PAYMENT_ID "$PAYMENT" 100000000 \
--rpc-url $RPC --private-key $PAYEE_KEY

Every captured token reaches the merchant in full — rail0 takes no fee.

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.

Terminal window
# 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 = refundExpiry
STRUCT_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, s
SIG=$(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 $PAYER
cast send $RAIL0 "refund(bytes32,$PAYMENT_TYPE,uint256,uint8,bytes32,bytes32)" \
$PAYMENT_ID "$PAYMENT" 50000000 $V $R $S \
--rpc-url $RPC --private-key $PAYEE_KEY
Terminal window
# 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 $PAYER
cast send $RAIL0 "release(bytes32,$PAYMENT_TYPE)" \
$PAYMENT_ID "$PAYMENT" --rpc-url $RPC --private-key $PAYER_KEY
Terminal window
# (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 accepts
cast call $RAIL0 "acceptedTokens()(address[])" --rpc-url $RPC

capturable is what is still held in escrow; refundable is what has reached the merchant and can still be refunded.