Skip to content

Protocol

Contract reference

Everything the rail0 contract exposes: the eight functions a payment goes through, the view helpers off-chain code uses to build signatures and read state, the events an indexer follows, and every error it can revert with. Signatures are Solidity; amounts are token base units; p is the Payment struct described in the protocol overview, passed unchanged on every call. For the calls in order, see the Quickstart; for what the contract guarantees, the security model.

function authorize(bytes32 paymentId, Payment calldata p, uint8 v, bytes32 r, bytes32 s) external;
function charge(bytes32 paymentId, Payment calldata p, uint8 v, bytes32 r, bytes32 s) external;
function capture(bytes32 paymentId, Payment calldata p, uint256 amount) external;
function void(bytes32 paymentId, Payment calldata p) external;
function release(bytes32 paymentId, Payment calldata p) external;
function refund(bytes32 paymentId, Payment calldata p, uint256 amount, uint8 v, bytes32 r, bytes32 s) external;
function dispute(bytes32 paymentId, Payment calldata p, bytes32 reason) external;
function closeDispute(bytes32 paymentId, Payment calldata p, bytes32 reason) external;

Escrows p.amount from the buyer. The buyer signs an EIP-3009 ReceiveWithAuthorization with from = p.payer, to = rail0, value = p.amount, validAfter = 0, validBefore = p.authorizationExpiry and the authorizeNonce; the payee submits. The contract validates the terms — expiries in order, at least a day apart and not in the past; non-zero, distinct parties; an allowlisted token — records the payment, and pulls the funds.

Like authorize, with the chargeNonce, but sends p.amount straight to the payee. The payment starts with nothing capturable and p.amount refundable, so refunds stay available until refundExpiry. Use it when there is nothing to capture later.

Payee only, before authorizationExpiry, with 0 < amount ≤ capturableAmount. Moves amount from escrow to the payee in full. Repeatable up to p.amount.

Payee only, and only while nothing has been captured. Returns the whole escrow to the buyer. After any capture it reverts AlreadyCaptured — the remainder then comes back through release.

Payer or payee, from authorizationExpiry on. Returns everything still in escrow to the buyer, whoever submits it. This is the buyer’s recourse if the merchant never captures.

Payee only, before refundExpiry, with 0 < amount ≤ refundableAmount. Captured funds sit in the merchant’s wallet, so the payee signs an EIP-3009 ReceiveWithAuthorization from itself to rail0 (validBefore = p.refundExpiry, the refundNonce); rail0 pulls the funds and forwards them to the payer. The nonce encodes both current balances, so every partial refund has its own.

Payer only. dispute raises the flag while refundableAmount > 0 and before refundExpiry; closeDispute withdraws it at any time. Neither moves funds. The merchant cannot dismiss a dispute — a full refund closes it automatically.

FunctionReturns
getPaymentState(paymentId)(bool exists, uint120 capturable, uint120 refundable, bool disputed)
getConfigHash(paymentId)The stored EIP-712 hash of the payment’s terms.
hashPayment(p)The EIP-712 hash of a Payment, as stored on the first call.
authorizeNonce(paymentId, configHash)The EIP-3009 nonce for authorize.
chargeNonce(paymentId, configHash)The EIP-3009 nonce for charge.
refundNonce(paymentId, configHash, capturable, refundable)The EIP-3009 nonce for the next refund.
DOMAIN_SEPARATOR()rail0’s EIP-712 domain separator.
isAcceptedToken(token) / acceptedTokens()The deployment’s token allowlist.

Every lifecycle event indexes paymentId, payer and payee. The fund-moving events carry the amount moved and both balances after the operation, so each one is self-sufficient for an indexer.

event TokenAccepted(address indexed token);
event PaymentAuthorized(bytes32 indexed paymentId, address indexed payer, address indexed payee, Payment payment);
event PaymentCharged (bytes32 indexed paymentId, address indexed payer, address indexed payee, Payment payment);
event PaymentCaptured(bytes32 indexed paymentId, address indexed payer, address indexed payee, uint256 amount, uint120 capturableAmount, uint120 refundableAmount);
event PaymentVoided (bytes32 indexed paymentId, address indexed payer, address indexed payee, uint256 amount, uint120 capturableAmount, uint120 refundableAmount);
event PaymentReleased(bytes32 indexed paymentId, address indexed payer, address indexed payee, uint256 amount, uint120 capturableAmount, uint120 refundableAmount);
event PaymentRefunded(bytes32 indexed paymentId, address indexed payer, address indexed payee, uint256 amount, uint120 capturableAmount, uint120 refundableAmount);
event PaymentDisputed(bytes32 indexed paymentId, address indexed payer, address indexed payee, bytes32 reason);
event DisputeClosed (bytes32 indexed paymentId, address indexed payer, address indexed payee, address closedBy, bytes32 reason);

DisputeClosed.closedBy is the payer on a withdrawal, or the payee when a full refund closed it — then reason is REASON_FULL_REFUND (keccak256("rail0.dispute.full_refund")).

ErrorCause
NotPayeeCaller is not the merchant on authorize, charge, capture, void or refund.
NotPayerCaller is not the buyer on dispute or closeDispute.
NotPayerOrPayeerelease caller is neither party.
PaymentAlreadyExistspaymentId was already used.
PaymentNotFoundpaymentId has no state.
PaymentMismatchThe Payment passed does not match the stored hash.
InvalidAmountp.amount is zero.
InvalidExpiriesExpiries are zero, out of order, or less than a day apart.
AuthorizationExpiredauthorize, charge or capture at or after authorizationExpiry.
AuthorizationNotExpiredrelease before authorizationExpiry.
RefundExpiredrefund or dispute at or after refundExpiry.
ZeroAddresspayer, payee or token is the zero address.
SelfPaymentpayer equals payee.
InvalidCaptureAmountCapture amount is zero or above capturableAmount.
InvalidRefundAmountRefund amount is zero or above refundableAmount.
NothingToVoidvoid with nothing in escrow.
AlreadyCapturedvoid after any capture.
NothingToReleaserelease with nothing in escrow.
NothingToDisputedispute with nothing refundable.
AlreadyDisputeddispute while one is open.
NotDisputedcloseDispute with no open dispute.
TokenNotAcceptedp.token is not on the allowlist.
TransferFailedA token transfer failed.
ReentrancyA nested call tried to re-enter.
  • Amounts are capped at type(uint120).max — effectively unbounded for stablecoins.
  • An authorization cannot be topped up or extended; more needs a new paymentId.
  • void and release are all-or-nothing: they return the entire remaining escrow.
  • The token allowlist is fixed at deployment.