Whitepaper17 September 2026·10 min read

Payrail: Non-Custodial USDC Payment Reconciliation

Abstract

Merchants who accept stablecoin payments face the same problem as merchants who accept bank transfers: money arrives without saying which bill it pays. Payrail solves this by making the payment transaction carry the invoice ID as an argument to a contract, which forwards USDC directly to the merchant and emits a structured event. The backend matches the event to an invoice with three checks (ID, recipient, amount) and marks it PAID. The contract never holds funds, has no owner, and cannot be changed. Verification is idempotent and runs over two independent routes so that no payment is missed because of a client failure. Payrail runs on Robinhood Chain; every invoice is pinned to the network it was created on, and the design of the key makes a payment on any other network harmless.

1. Motivation

Payment matching is invisible, unpaid work. A transfer, bank or onchain, carries an amount, a time and a sender; none of them is a bill identifier. Merchants match by guessing from the leftovers, and wrong guesses produce awkward conversations with customers.

Existing solutions generally become custodial intermediaries: funds go to the provider, the provider matches, then forwards to the merchant minus a fee and a delay. This trades a matching problem for a trust problem, and adds a point of failure exactly in the path of the money.

A blockchain allows a third way: a payment that names itself without an intermediary holding anything. A contract can accept a bill ID as an argument, forward the buyer's funds to the merchant in the same call, and record that fact as an event that cannot be faked. Payrail is a minimal implementation of that idea.

2. Design principles

  1. Funds never stop. The contract forwards; it does not hold. Its balance is zero structurally, not by policy.
  2. The chain is the source of truth; the database is a cache. PAID can always be re-derived from onchain events. If the database is lost, the indexer rebuilds it.
  3. Clients give hints, not claims. No byte from the browser is used to decide PAID except as a lookup key into onchain data.
  4. Every operation is idempotent. Running verification twice, ten times, or from two concurrent processes yields the same result.
  5. The contract is as small as possible. Whatever can happen offchain happens offchain. The contract only moves funds and records.

3. Data model

Three offchain entities and one onchain mapping.

EntityKeyContents
Merchantidname, walletAddress (unique)
Invoiceid (cuid)onchainId (unique), chainId, merchantId, description, amount (smallest units, string), status, customerName?, dueAt?
PaymentidinvoiceId (unique), txHash (unique), payer, amount, blockNumber, paidAt
IndexerState1lastBlock

Onchain, mapping(bytes32 => Payment{payer, amount, merchant, paidAt}).

The bridge between the two is the payment key:

salt      = keccak256(invoice.id)
onchainId = keccak256(abi.encode(salt, merchant.walletAddress, amountUnits))

invoice.id is a random 25-character cuid, so salt cannot be guessed and cannot collide in practice. The key binds the salt to the terms (recipient and exact amount). It is computed after the record is created (to obtain the id) and stored as a unique column. The contract recomputes the same key from the three arguments to pay(), so the database and the chain agree by construction rather than by lookup.

status is one of PENDING, PAID, CANCELLED, EXPIRED. Only the transitions PENDING to PAID (by verification) and PENDING to CANCELLED (by the merchant) are implemented.

4. The contract

function invoiceKey(bytes32 salt, address merchant, uint256 amount) public pure returns (bytes32) {
    return keccak256(abi.encode(salt, merchant, amount));
}

function pay(bytes32 salt, address merchant, uint256 amount) external nonReentrant {
    if (merchant == address(0) || merchant == address(this)) revert InvalidMerchant();
    if (amount == 0 || amount > type(uint96).max) revert InvalidAmount();

    bytes32 invoiceId = invoiceKey(salt, merchant, amount);
    if (_payments[invoiceId].amount != 0) revert InvoiceAlreadyPaid(invoiceId);

    _payments[invoiceId] = Payment(msg.sender, uint96(amount), merchant, uint64(block.timestamp));
    usdc.safeTransferFrom(msg.sender, merchant, amount);
    emit PaymentReceived(invoiceId, salt, merchant, msg.sender, amount, block.timestamp);
}

4.1 Invariants

  • One payment per key. Enforced by the amount != 0 check before the write, with nonReentrant closing the reentrancy window. Because amount == 0 is rejected, amount != 0 is an exact proxy for "already paid".
  • The key is the terms. invoiceId is a pure function of (salt, merchant, amount). Two calls with different terms can never collide on a key, and a call with the wrong terms cannot occupy the key of a real invoice.
  • Contract balance = 0. The only token movement is safeTransferFrom(msg.sender, merchant, amount). No code path makes the contract a recipient.
  • No authority. No owner, pause, withdraw, upgrade, or adjustable parameter. usdc is immutable and must be a deployed contract at construction.
  • Two storage slots per payment. Payment is ordered (payer, amount:uint96), (merchant, paidAt:uint64). Amounts above 2^96 - 1 smallest units are rejected; that is 7.9e22 USDC, far beyond total supply.

4.2 What the contract does not know

The contract does not store invoices. It cannot tell whether a (salt, merchant, amount) triple corresponds to a real invoice. Moving that knowledge onchain would mean every invoice is a transaction (gas for the merchant, latency on creation) and every invoice detail is public. We chose to keep it offchain.

What v1 got wrong was letting the key be independent of the terms: pay(invoiceId, merchant, amount) keyed only on invoiceId, so a third party who saw a payment link could call pay(id, themselves, 1) and lock the real invoice for one unit of USDC. The database rejected the mismatched event, but the contract had already consumed the key. v2 closes this by deriving the key from all three terms (section 4). A wrong-terms call now lands on a key nobody else will ever use. Both the unit tests and the live end-to-end script perform the attack and confirm the real buyer can still pay.

5. Verification

Every route ends in one function:

applyPaymentLog(event, txHash, blockNumber):
  invoice <- Invoice where onchainId = event.invoiceId
  if none                                             -> reject "invoice not found"
  if invoice.payment exists                           -> ok (idempotent)
  if event.merchant != invoice.merchant.walletAddress -> reject "merchant mismatch"
  if event.amount != invoice.amount                   -> reject "amount mismatch"
  atomically: create Payment{txHash, ...}; invoice.status <- PAID

Since v2 the key already binds merchant and amount, so a matching invoiceId implies matching terms; the two explicit checks remain as defense in depth against a wrong deployment address or ABI. Amounts must match exactly: an overpayment or underpayment is a different key and never reaches the invoice. The payment exists check plus the txHash @unique constraint guarantee one record per invoice even when the two routes overlap or two processes run concurrently.

5.1 Fast route: txHash from the client

After pay() confirms, the client calls POST /api/invoices/:id/verify {txHash}. The server:

  1. fetches the receipt; none yet: 202 (retry);
  2. checks status == success; otherwise 400;
  3. checks head - receipt.block + 1 >= CONFIRMATIONS; otherwise 202;
  4. filters receipt.logs to address == PaymentProcessor;
  5. parses PaymentReceived and finds invoiceId == invoice.onchainId; none: 400;
  6. calls applyPaymentLog with arguments taken from the event.

The txHash only selects which receipt to read. The merchant and amount used for the decision come from a log signed by chain consensus, not from the request.

5.2 Certain route: the indexer

POST /api/indexer (guarded by a secret header) scans PaymentReceived from lastBlock + 1 to head - CONFIRMATIONS + 1 in chunks of 2,000 blocks, calls applyPaymentLog for each event, then stores lastBlock. With no initial state it starts 5,000 blocks behind the head.

The indexer catches payments the client never reported: closed tabs, dropped networks, payments from scripts or multisigs, or verifications that were pending on confirmations. Because applyPaymentLog is idempotent, it can run as often as desired and overlap the fast route without side effects.

5.3 Confirmations

CONFIRMATIONS (default 1) applies to both routes and is the only defence against reorgs. For real networks, 2 or 3 is recommended. There is no mechanism to revert PAID if a reorg happens after the threshold; we accept this risk as the price of an append-only database.

6. Buyer flow

  1. Open /pay/:id; the page reads the invoice from the API and shows merchant, description and amount.
  2. Connect a wallet; the page switches it to Robinhood Chain if needed.
  3. If allowance(buyer, PaymentProcessor) < amount, send USDC.approve(PaymentProcessor, amount).
  4. Send PaymentProcessor.pay(onchainId, merchant.walletAddress, amount); arguments are filled from the database, not from user input.
  5. After confirmation, post the txHash to /verify; repeat while 202.
  6. Show PAID and an explorer link.

Two transactions (one if the allowance already covers it). No protocol fee; the merchant receives exactly amount.

7. Network

Payrail runs on Robinhood Chain, an Arbitrum Orbit L2 settling to Ethereum, with ETH as the gas token. The contract in section 4 is deployed there at 0xD591A0d397179dE0692d50f43AC450C6cDF9C66D, pointing at the chain's dollar stablecoin, USDG (0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168, 6 decimals). The testnet (chain 46630) and a local hardhat node run the same contract for rehearsals and development.

Three properties of the choice matter for the design:

  1. Gas is cheap and paid in ETH. A payment is one approve and one pay, each a few cents at most. The buyer needs a little ETH on Robinhood Chain on top of the stablecoin.
  2. An invoice is pinned to its network at creation (Invoice.chainId), and the key in section 3 does not include it: invoiceKey(salt, merchant, amount) is the same bytes32 on every deployment. applyPaymentLog therefore receives the network the event was seen on and refuses to mark PAID unless it equals the invoice's chainId. A payment of the right terms on the testnet, or on any other deployment, lands under the same key there and leaves the real invoice untouched. The verifier and the indexer each keep one cursor per network, so a slow RPC on one never stalls another.
  3. RPC access is a first-class concern. Robinhood Chain's public RPC is content-filtered by some ISPs, which would make the payment page unusable on those connections. The browser and the wallet therefore talk to a same-origin relay (/api/rpc/4663) that forwards an allow-list of read and broadcast methods from the server. The relay is a transport choice, not a trust boundary: the decision to mark PAID still comes from a log signed by chain consensus.

8. Failure mode analysis

ScenarioWhat happensMitigation
Client sends someone else's txHashFails the invoiceId checkBuilt in
Client pays the wrong merchant via the UIImpossible; arguments come from the DBBuilt in
Attacker pays 1 unit to themselves with a known saltLands on a different key; real invoice untouchedClosed in v2. Regression tests in contracts/test and web/scripts/e2e-local.mjs.
Someone calls pay() by hand with the wrong merchant or amountUSDC goes where they pointed it, recorded under a different key; real invoice untouchedNo fund recovery for the mistaken payer; invoice remains payable
Under- or overpaymentDifferent key; the invoice stays PENDING; USDC reaches the merchantMerchant settles by hand. No partial payments yet.
Invoice CANCELLED but paidapplyPaymentLog does not check status, so PAIDKnown; documented. Merchant refunds by hand.
Reorg after CONFIRMATIONSPAID stays; tx may vanishRaise CONFIRMATIONS
RPC hides a txInvoice looks PENDING for longerIndexer on a second RPC; getPayment() readable by anyone
Database lostAll status lostIndexer rebuilds Payment from events; Invoice needs backups
Another contract emits PaymentReceivedIgnored by the address filterBuilt in
Invoice creation without authenticationSpam invoices; fake links in a merchant's nameFunds still go to the merchant's wallet. SIWE required before production.
USDC freezes the merchant wallettransferFrom reverts; invoices unpayableOut of our control
Buyer pays the right terms on another network (e.g. the testnet)Same key, other deployment; invoice stays PENDINGBackend rejects by chainId; wallet is switched to Robinhood Chain before paying
Public RPC is down or filteredInvoices verify lateSame-origin relay for the browser; RPC_URL_4663 can point the server at a second provider

9. What does not exist yet

Merchant authentication. Automatic expiry (dueAt and EXPIRED are in the schema; nothing sets them). Partial payments. Webhooks. Multi-token and multi-chain: one dollar token on Robinhood Chain (section 7). Escrow, which is deliberately not an evolution of this contract, because it would break the first principle in section 2. An independent audit; the contract has had two internal review passes and a live end-to-end attack test, which is not the same thing.

10. Conclusion

Payrail shows that stablecoin payment reconciliation on Robinhood Chain does not require a custodian. One contract with no balance, one hash linking an offchain invoice to an onchain event, and one idempotent function reached from two directions are enough to turn "I sent it, please check" into a status that changes on its own. The rest (authentication, expiry, notifications) is ordinary application work that can be added without touching the part that cannot be changed.


Code: `contracts/contracts/PaymentProcessor.sol`, `web/src/lib/verify.ts`. Documentation: [/docs](/docs). Risks: [/docs/risks-and-limits](/docs/risks-and-limits).