diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 322561b..e9adf82 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: Install Foundry uses: foundry-rs/foundry-toolchain@v1 - # solc is pinned in foundry.toml (0.8.27), so this compiles with the same + # solc is pinned in foundry.toml (0.8.31), so this compiles with the same # compiler the deployments used rather than whatever ships with the toolchain. - name: Format run: forge fmt --check diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..c551a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,35 @@ +{ + // Pin the editor to the SAME compiler the project builds with. + // + // contracts/foundry.toml pins `solc = "0.8.31"` and forge fetches it, so the + // command line has always been correct. An editor extension, though, uses the + // compiler it ships with — and while the pragma was `^0.8.31` it happily + // compiled these files with whatever newer 0.8.x that happened to be, producing + // different bytecode than the deployed artifact and saying nothing about it. + // That silent divergence is what issue #43 was about. + // + // With the pragma now `=0.8.31`, a mismatched extension reports: + // + // Source file requires different compiler version (current compiler is + // 0.8.36+commit.8a079791.Emscripten.clang) + // + // which is the pin working, not a fault. The fix is to point the editor at + // 0.8.31 rather than to install something newer — newer is the problem. + // + // Both keys are here because the two common extensions read different ones; + // whichever you don't have simply ignores its key. The exact build is the one + // forge used, read back from the compiled artifact's metadata rather than + // typed from memory. + + // Juan Blanco's `solidity` extension. + "solidity.compileUsingRemoteVersion": "v0.8.31+commit.fd3a2265", + + // Nomic Foundation's `hardhat-solidity` / Solidity extension. + "solidity.compilerVersion": "0.8.31", + + // Foundry formats this repo (`forge fmt`), so the editor must not reformat + // Solidity on save with its own rules — that fights `forge fmt --check` in CI. + "[solidity]": { + "editor.formatOnSave": false + } +} diff --git a/README.md b/README.md index c4fdcab..9bb0eed 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ lifecycle for stablecoin payments — no owner, no admin, no fees, no privileged --- -_rail0_ is a peer-to-peer protocol: buyer (`payer`) and merchant (`payee`) transact directly, with no processor, gateway, or operator in between. The contract custodies nothing outside the active escrow window, takes no fee, and routes every captured token to the merchant in full. It is immutable and permissionless — anyone can deploy it, anyone can use it. Buyer-funded operations need a single off-chain signature (an **EIP-3009 `TransferWithAuthorization`**); the merchant submits the transaction and pays gas. No allowance, no separate intent typehash, no smart-account wallet, no bundler. +_rail0_ is a peer-to-peer protocol: buyer (`payer`) and merchant (`payee`) transact directly, with no processor, gateway, or operator in between. The contract custodies nothing outside the active escrow window, takes no fee, and routes every captured token to the merchant in full. It is immutable and permissionless — anyone can deploy it, anyone can use it. Buyer-funded operations need a single off-chain signature (an **EIP-3009 `ReceiveWithAuthorization`**); the merchant submits the transaction and pays gas. No allowance, no separate intent typehash, no smart-account wallet, no bundler.

@@ -23,17 +23,20 @@ _rail0_ is a peer-to-peer protocol: buyer (`payer`) and merchant (`payee`) trans - **One immutable contract.** No owner, admin, pauser, or upgrade path. Code and token allowlist are fixed at deploy time. - **No protocol fee.** Every captured token reaches the merchant in full; the contract routes nothing to anyone else. -- **One signature, no allowance.** Buyers sign an EIP-3009 `TransferWithAuthorization` off-chain; merchants submit and pay gas. No `approve`, no standing allowance for either party. +- **One signature, no allowance.** Buyers sign an EIP-3009 `ReceiveWithAuthorization` off-chain; merchants submit and pay gas. No `approve`, no standing allowance for either party. - **Card-network primitives on-chain.** `authorize → capture → refund`, plus `charge`, `void`, `release`, and a signal-only `dispute`. - **Time-based recourse only.** `release` (after `authorizationExpiry`) and the merchant's discretionary `refund` are the only paths that return funds to the buyer; there is no arbitration layer. -- **Portable.** Runs on any EVM that compiles Solidity 0.8.27 with EIP-3009-capable tokens. +- **Portable.** Runs on any EVM that compiles Solidity 0.8.31 with EIP-3009-capable tokens. ## Supported chains _rail0_ has two hard requirements — any chain and token that meet them can run it: -- **EVM-compatible.** Solidity 0.8.27 must compile and execute on the chain. -- **EIP-3009-capable tokens.** Each accepted token must expose `transferWithAuthorization` and `receiveWithAuthorization` (used by `authorize`/`charge` and `refund` respectively). +- **EVM-compatible.** Solidity 0.8.31 must compile and execute on the chain, targeting + the **Cancun** fork — the contract uses `MCOPY` and, for its reentrancy lock, EIP-1153 + transient storage (`TLOAD`/`TSTORE`). Both are pinned in `foundry.toml` + (`evm_version = "cancun"`), so a chain that predates Cancun is not a supported target. +- **EIP-3009-capable tokens.** Each accepted token must expose `receiveWithAuthorization` (used by `authorize`, `charge`, and `refund` alike). Beyond those, _rail0_ is **best on stablecoin-native chains with sub-second finality** — there the merchant pays gas in the asset being settled and checkout confirms instantly — but neither is required; on any other EVM chain gas is simply paid in that chain's native token. @@ -102,7 +105,7 @@ function authorize(bytes32 paymentId, Payment calldata p, uint8 v, bytes32 r, by Buyer escrows `p.amount` of the stablecoin in the contract, holding it for the merchant to capture later. -The buyer signs an **EIP-3009 `TransferWithAuthorization`** over the token's domain with `from = p.payer`, `to = address(rail0)`, `value = p.amount`, `validAfter = 0`, `validBefore = p.authorizationExpiry`, and `nonce = keccak256(_AUTHORIZE_NONCE_PREFIX, paymentId, configHash)`. The merchant submits. The contract validates the config (expiries in order and not in the past, addresses non-zero, payer and payee distinct, token allowlisted), records the payment state, then calls `token.transferWithAuthorization(...)` with the deterministic nonce and pinned validity window. The token's own EIP-712 check verifies the signature; if any Payment term was tampered, the recovered signer won't match `p.payer` and the token reverts. The deterministic nonce is what binds the buyer's signature to the exact terms — no separate intent typehash needed. Once authorized, the merchant may `capture` (one or more times, up to `p.amount`) before `authorizationExpiry`, or `void` the hold — but only while nothing has been captured yet; otherwise `release` opens after `authorizationExpiry`. +The buyer signs an **EIP-3009 `ReceiveWithAuthorization`** over the token's domain with `from = p.payer`, `to = address(rail0)`, `value = p.amount`, `validAfter = 0`, `validBefore = p.authorizationExpiry`, and `nonce = keccak256(_AUTHORIZE_NONCE_PREFIX, paymentId, configHash)`. The merchant submits. The contract validates the config (expiries in order and not in the past, addresses non-zero, payer and payee distinct, token allowlisted), records the payment state, then calls `token.receiveWithAuthorization(...)` with the deterministic nonce and pinned validity window. The receive variant matters: the token enforces `msg.sender == to`, so the buyer's signature is spendable only through _rail0_ — lifted from the mempool, it is worthless to anyone else. The token's own EIP-712 check verifies the signature; if any Payment term was tampered, the recovered signer won't match `p.payer` and the token reverts. The deterministic nonce is what binds the buyer's signature to the exact terms — no separate intent typehash needed. Once authorized, the merchant may `capture` (one or more times, up to `p.amount`) before `authorizationExpiry`, or `void` the hold — but only while nothing has been captured yet; otherwise `release` opens after `authorizationExpiry`. #### Charge @@ -152,9 +155,11 @@ function refund(bytes32 paymentId, Payment calldata p, uint256 amount, uint8 v, Merchant reverses a prior capture, sending `amount` of the stablecoin back to the buyer. -Captured funds live in the merchant's wallet (not the contract), so `refund` pulls them back using the **same EIP-3009 pattern as `authorize`/`charge`** — symmetric, no allowance. The merchant (`p.payee`) signs an EIP-3009 `TransferWithAuthorization` with `from = p.payee`, `to = address(rail0)`, `value = amount`, `validAfter = 0`, `validBefore = p.refundExpiry`, and `nonce = keccak256(_REFUND_NONCE_PREFIX, paymentId, configHash, refundableAmount)`. **Only `p.payee`** may submit. The contract checks the caller, that the payment exists, that `block.timestamp < p.refundExpiry` and `0 < amount ≤ refundableAmount`, decrements `refundableAmount -= amount`, then calls `token.receiveWithAuthorization(...)` to pull funds from the merchant into _rail0_ and immediately forwards them to `p.payer`. +Captured funds live in the merchant's wallet (not the contract), so `refund` pulls them back using the **same EIP-3009 pattern as `authorize`/`charge`** — symmetric, no allowance. The merchant (`p.payee`) signs an EIP-3009 `ReceiveWithAuthorization` with `from = p.payee`, `to = address(rail0)`, `value = amount`, `validAfter = 0`, `validBefore = p.refundExpiry`, and `nonce = keccak256(_REFUND_NONCE_PREFIX, paymentId, configHash, capturableAmount, refundableAmount)`. **Only `p.payee`** may submit. The contract checks the caller, that the payment exists, that `block.timestamp < p.refundExpiry` and `0 < amount ≤ refundableAmount`, decrements `refundableAmount -= amount`, then calls `token.receiveWithAuthorization(...)` to pull funds from the merchant into _rail0_ and immediately forwards them to `p.payer`. -The refund nonce encodes the **current** `refundableAmount`, so each partial refund has a unique, deterministic nonce. A stale signature (made against an earlier `refundableAmount`) produces a different nonce than the one the merchant signed — the token recovers a mismatched signer and reverts. Use `refundNonce(paymentId, configHash, refundableAmount)` to compute it off-chain. +The refund nonce encodes **both current balances** — `capturableAmount` and `refundableAmount` — so each partial refund has a unique, deterministic nonce. The pair determines how much of the payment has left the two live buckets, and that quantity never falls: a capture moves value *between* them and leaves it unchanged, while every refund raises it. So no two refunds of a payment can derive the same nonce. A stale signature (made against earlier balances) produces a different nonce than the one the merchant signed — the token recovers a mismatched signer and reverts. Use `refundNonce(paymentId, configHash, capturableAmount, refundableAmount)` to compute it off-chain. + +Encoding `refundableAmount` alone was **not** sufficient, and this is worth knowing if you have integrated against an earlier version: that balance is not monotonic — a capture raises it — so a capture between two refunds could put it back to a value already used. The nonce then repeated, the token rejected it as spent, and with the escrow exhausted the payee had no way to move the balance to a fresh value. The residual became permanently non-refundable. #### Dispute / Close dispute @@ -204,7 +209,7 @@ A `TokenAccepted(address indexed token)` event is emitted from the constructor f ### Config commitment (EIP-712) -The `Payment` struct is hashed with EIP-712 typed-data encoding using the domain `EIP712Domain(name="RAIL0", version="1.3.0", chainId, verifyingContract)`. The digest is stored at `_configHash[paymentId]` on first call (`authorize`/`charge`) and re-checked on every subsequent call via `_loadAndVerify`. Tampering with any field causes a `PaymentMismatch` revert. +The `Payment` struct is hashed with EIP-712 typed-data encoding using the domain `EIP712Domain(name="RAIL0", version="1.4.0", chainId, verifyingContract)`. The digest is stored at `_configHash[paymentId]` on first call (`authorize`/`charge`) and re-checked on every subsequent call via `_loadAndVerify`. Tampering with any field causes a `PaymentMismatch` revert. Buyer-initiated operations don't introduce a separate _rail0_-domain signing typehash. Instead, _rail0_ derives a deterministic EIP-3009 nonce from the operation context: @@ -213,20 +218,20 @@ authorizeNonce = keccak256(keccak256("RAIL0.AUTHORIZE"), paymentId, configHash) chargeNonce = keccak256(keccak256("RAIL0.CHARGE"), paymentId, configHash) ``` -The buyer signs the token's standard `TransferWithAuthorization` digest with this nonce; _rail0_ recomputes the nonce from the supplied Payment and calls the token. Tamper with any field and the recomputed nonce differs, the recovered signer differs from `p.payer`, and the token reverts. The `configHash` inside the nonce provides the same term-binding an EIP-712 intent typehash would, without needing one. Distinct prefixes ensure an authorize-signature can't be reused for charge. +The buyer signs the token's standard `ReceiveWithAuthorization` digest with this nonce; _rail0_ recomputes the nonce from the supplied Payment and calls the token. Tamper with any field and the recomputed nonce differs, the recovered signer differs from `p.payer`, and the token reverts. The `configHash` inside the nonce provides the same term-binding an EIP-712 intent typehash would, without needing one. Distinct prefixes ensure an authorize-signature can't be reused for charge. The domain separator is cached at construction and rebuilt automatically if `block.chainid` changes (chain-fork safety). Helpers exposed to off-chain signers: - `DOMAIN_SEPARATOR()` — current EIP-712 domain separator (for `Payment` hashing; the buyer signs over the _token's_ domain). - `hashPayment(p)` — Payment digest (also stored on-chain as configHash). - `authorizeNonce(paymentId, configHash)` / `chargeNonce(paymentId, configHash)` — EIP-3009 nonces for `authorize` / `charge`. -- `refundNonce(paymentId, configHash, refundableAmount)` — EIP-3009 nonce for `refund`; encodes the current `refundableAmount`, so each partial refund has a distinct, replay-proof nonce. +- `refundNonce(paymentId, configHash, capturableAmount, refundableAmount)` — EIP-3009 nonce for `refund`; encodes BOTH current balances, so each partial refund has a distinct, replay-proof nonce. Both are needed: `refundableAmount` alone is not monotonic (a capture raises it), so it could revisit a value already spent. ### Allowance requirements _rail0_ does not custody anything outside the active escrow window, and **no allowance grant ever happens for either party** — every wallet-to-contract movement uses an EIP-3009 signature: -- **Buyer.** `transferWithAuthorization` moves funds from the buyer's wallet to _rail0_ atomically based on the signature alone (for `authorize` / `charge`). The buyer never calls `approve` and broadcasts no transaction to fund a payment — they only sign. (They may optionally broadcast `release` after `authorizationExpiry`.) +- **Buyer.** `receiveWithAuthorization` moves funds from the buyer's wallet to _rail0_ atomically based on the signature alone (for `authorize` / `charge`). The buyer never calls `approve` and broadcasts no transaction to fund a payment — they only sign. (They may optionally broadcast `release` after `authorizationExpiry`.) - **Merchant.** Captured funds live in the merchant's wallet; `refund` pulls them back via `receiveWithAuthorization` against a signature the merchant produces off-chain. Nothing is required for `capture` / `void` / `release` (those distribute funds _rail0_ already holds). ### Events @@ -293,6 +298,7 @@ event DisputeClosed (bytes32 indexed paymentId, address indexed payer, addres - **Reentrancy guard.** Every entrypoint that makes an external token call (`authorize`, `charge`, `capture`, `void`, `release`, `refund`) is `nonReentrant`. `dispute`/`closeDispute` make no external calls and hold no guard by design — the only fund-moving dispute path is the full-refund auto-close, which executes inside `refund`'s effects phase, already under its guard and ahead of any transfer. - **Checks-Effects-Interactions.** All state mutations occur before external transfers; even if the reentrancy guard were bypassed (it can't be), CEI ordering already prevents same-payment double-spending. - **SafeERC20-style transfers.** `_safeTransfer` accepts both bool-returning and non-returning ERC-20s and reverts with `TransferFailed` on any failure (compatible with USDT-mainnet-style tokens). Inbound pulls use EIP-3009, which revert token-side on an invalid signature. +- **Mempool front-running.** Every signature-funded operation uses `receiveWithAuthorization`, never `transferWithAuthorization`. The receive variant requires `msg.sender == to`, and every signature names _rail0_ as `to` — so a signature lifted from a pending transaction cannot be submitted straight to the token. With the transfer variant an attacker could do exactly that, landing the buyer's funds at the contract with no payment state and no payout path (see #35). - **Frozen-merchant escape hatch.** If the merchant is frozen by the token issuer (e.g. USDC blacklist) after authorization, `capture` reverts, but the buyer's escrow is not stuck: `void` (by the merchant, only while nothing has been captured) and `release` (after `authorizationExpiry`, by payer or payee) send funds directly to the buyer — `release` covers the partly-captured case where `void` is no longer available. - **Frozen-buyer escrow lock — accepted risk.** The mirror of the case above has **no** escape hatch. Every buyer-bound payout is a transfer to `payer` with no alternate recipient, so if the token issuer freezes the *buyer* after authorization, `void` and `release` both revert inside the transfer. While `authorizationExpiry` has not passed the funds can still move — `capture` settles to the (unfrozen) merchant — but once it passes, `capture` is closed too and the escrow has no on-chain path out at all. There is no admin, sweep or reassignment function by design, so this is permanent. The only mitigation is timing: capture within the authorization window. Pinned by `test_FrozenBuyer_EscrowIsStuckAfterExpiry`, so the behaviour cannot change silently. - **Partial-capture escrow freeze — accepted trade-off.** After _any_ partial capture the uncaptured remainder cannot be returned before `authorizationExpiry`, even when both parties want it returned. The three paths are each closed for their own reason: `void` requires the escrow fully intact (`AlreadyCaptured`), `release` requires expiry (`AuthorizationNotExpired`), and `refund` can only draw on the refundable bucket the capture created — never on the escrow. So a 100-unit authorization with 40 captured leaves the buyer's 60 held until the window ends, with no on-chain way to hand it back early. Note the lever this gives the merchant: capturing **one base unit** is enough to disable `void` and pin the escrow for the whole window, at the cost of settling that one unit. Accepted rather than fixed because the freeze is time-bound, not permanent — `release` opens at expiry and returns the remainder to the buyer, so no funds are lost, only immobilised — and because the alternatives (a partial `void`, or a consensual early-release path needing both signatures) each add a fund-moving entrypoint to a contract whose surface is deliberately small. The mitigation is choosing `authorizationExpiry` to match the fulfilment window rather than the maximum the protocol allows. Pinned by `test_PartialCapture_FreezesTheRemainderUntilExpiry`, which asserts both guards together and the exit at expiry, so the behaviour cannot change silently. @@ -341,15 +347,17 @@ export PAYMENT_ID=$(cast keccak "order-12345") 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. Build the EIP-3009 TransferWithAuthorization digest (over the TOKEN's domain). +# 2. Build the EIP-3009 ReceiveWithAuthorization digest (over the TOKEN's domain). +# NOT TransferWithAuthorization: the typehashes are distinct even though the field +# list is identical, and rail0 spends the signature via receiveWithAuthorization. # _rail0_ pins validAfter = 0 and validBefore = p.authorizationExpiry, so the 5th and # 6th args below must be exactly 0 and $AUTH_EXPIRY. value = $AMOUNT (= p.amount). TOKEN_DOMAIN=$(cast call $TOKEN "DOMAIN_SEPARATOR()(bytes32)" --rpc-url $RPC) -TWA_TYPEHASH=$(cast keccak \ - "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)") +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)" \ - $TWA_TYPEHASH $PAYER $RAIL0 $AMOUNT 0 $AUTH_EXPIRY $NONCE)) + $RWA_TYPEHASH $PAYER $RAIL0 $AMOUNT 0 $AUTH_EXPIRY $NONCE)) DIGEST=$(cast keccak 0x1901${TOKEN_DOMAIN:2}${STRUCT_HASH:2}) # 3. Buyer signs the raw digest; split into v, r, s @@ -362,7 +370,7 @@ cast send $RAIL0 "authorize(bytes32,$PAYMENT_TYPE,uint8,bytes32,bytes32)" \ --rpc-url $RPC --private-key $PAYEE_KEY ``` -In production, the buyer's wallet renders this as a standard EIP-3009 `TransferWithAuthorization` prompt and signs via `eth_signTypedData_v4`. +In production, the buyer's wallet renders this as a standard EIP-3009 `ReceiveWithAuthorization` prompt and signs via `eth_signTypedData_v4`. ### Charge (one-shot pay-through) @@ -396,9 +404,10 @@ cast send $RAIL0 "capture(bytes32,$PAYMENT_TYPE,uint256)" \ Pulls from the merchant's own wallet back to the buyer — no allowance, no `approve`. ```sh -# 1. Refund nonce for the CURRENT refundable balance (here: a full 50000000 refund) -NONCE=$(cast call $RAIL0 "refundNonce(bytes32,bytes32,uint120)(bytes32)" \ - $PAYMENT_ID $CONFIG_HASH 50000000 --rpc-url $RPC) +# 1. Refund nonce for the CURRENT balances (here: nothing left in escrow, a full +# 50000000 refundable). Read them from getPaymentState — both go into the nonce. +NONCE=$(cast call $RAIL0 "refundNonce(bytes32,bytes32,uint120,uint120)(bytes32)" \ + $PAYMENT_ID $CONFIG_HASH 0 50000000 --rpc-url $RPC) # 2. EIP-3009 digest over the TOKEN's domain. value = 50000000, validAfter = 0, # validBefore = $REFUND_EXPIRY. @@ -490,7 +499,7 @@ contracts/ ├── src/ │ ├── RAIL0.sol # the protocol contract │ └── interfaces/ -│ └── IERC20.sol # IERC20 + IEIP3009 (TransferWithAuthorization) +│ └── IERC20.sol # IERC20 + IEIP3009 (ReceiveWithAuthorization) ├── script/ │ └── Deploy.s.sol # deploy script (reads RAIL0_ACCEPTED_TOKENS) └── test/ diff --git a/contracts/foundry.toml b/contracts/foundry.toml index 97704c2..ef363a6 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -3,10 +3,21 @@ src = "src" out = "out" libs = ["lib"] test = "test" -solc = "0.8.27" +solc = "0.8.31" optimizer = true -optimizer_runs = 200 +# 10_000, not the 200 default: this contract deploys once per chain and is then +# called for the life of the deployment, so the trade is one-time deploy gas +# (~+550k) and bytecode size (8.0k -> 10.7k, ample headroom under the 24,576 +# limit) for a measured ~360-640 gas saved on EVERY operation (capture, refund, +# authorize, ...). Bytecode differs from the v1.x deployments (200 runs) -- +# verification metadata must use this value from v2.0.0 onward. +optimizer_runs = 10_000 via_ir = true +# Pinned rather than inherited from forge's default (today "prague"): the contract +# depends on Cancun for MCOPY and, since #45, for TSTORE/TLOAD. Naming the oldest +# fork that satisfies it keeps the bytecode deployable on every Cancun chain and stops +# a future forge default from silently raising the requirement. +evm_version = "cancun" verbosity = 2 fs_permissions = [{ access = "read", path = "./" }] @@ -21,14 +32,29 @@ bracket_spacing = true # inline assembly hurts readability — not worth the trade. exclude_lints = ["asm-keccak256"] +# The DEPLOYMENT SET, and only it. Every chain the README lists as Live gets an RPC +# and a verifier here, so a redeploy or a verification runs from this file instead of +# out-of-band URLs. Chains that do not run RAIL0 are deliberately absent — listing +# them advertised support that does not exist: Tempo cannot run it at all (TIP-20 has +# no EIP-3009), Plasma's testnet is still Planned, and Moderato appeared nowhere in +# the README. Every chain id below was checked against its endpoint's eth_chainId. [rpc_endpoints] -moderato = "https://rpc.moderato.tempo.xyz" -tempo = "https://rpc.tempo.xyz" -arc-testnet = "https://rpc.testnet.arc.network" -plasma-testnet = "https://testnet-rpc.plasma.to" -celo-sepolia = "https://forno.celo-sepolia.celo-testnet.org" +arbitrum-sepolia = "https://sepolia-rollup.arbitrum.io/rpc" +arc-testnet = "https://rpc.testnet.arc.network" +base-sepolia = "https://sepolia.base.org" +celo-sepolia = "https://forno.celo-sepolia.celo-testnet.org" +optimism-sepolia = "https://sepolia.optimism.io" +polygon-amoy = "https://polygon-amoy.drpc.org" +# Five of the six explorers are Blockscout, which verifies with no API key — the +# `/api/` suffix is the verifier endpoint, not the browse URL, and each was confirmed +# to answer. Polygon Amoy is the exception and the reason `key` is not simply "" for +# every row: Polygonscan's V1 API is retired, so Amoy goes through Etherscan's V2 +# endpoint, the only entry here that needs a key. [etherscan] -tempo = { url = "https://contracts.tempo.xyz", chain = 4217, key = "" } -moderato = { url = "https://contracts.tempo.xyz", chain = 42431, key = "" } -celo-sepolia = { url = "https://celo-sepolia.blockscout.com/api/", chain = 11142220, key = "" } +arbitrum-sepolia = { url = "https://arbitrum-sepolia.blockscout.com/api/", chain = 421614, key = "" } +arc-testnet = { url = "https://testnet.arcscan.app/api/", chain = 5042002, key = "" } +base-sepolia = { url = "https://base-sepolia.blockscout.com/api/", chain = 84532, key = "" } +celo-sepolia = { url = "https://celo-sepolia.blockscout.com/api/", chain = 11142220, key = "" } +optimism-sepolia = { url = "https://testnet-explorer.optimism.io/api/", chain = 11155420, key = "" } +polygon-amoy = { url = "https://api.etherscan.io/v2/api", chain = 80002, key = "${ETHERSCAN_API_KEY}" } diff --git a/contracts/script/Deploy.s.sol b/contracts/script/Deploy.s.sol index 392bcc8..055da71 100644 --- a/contracts/script/Deploy.s.sol +++ b/contracts/script/Deploy.s.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; +pragma solidity =0.8.31; import { Script, console2 } from "forge-std/Script.sol"; import { RAIL0 } from "../src/RAIL0.sol"; diff --git a/contracts/src/RAIL0.sol b/contracts/src/RAIL0.sol index 73bf316..ca05b64 100644 --- a/contracts/src/RAIL0.sol +++ b/contracts/src/RAIL0.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; +pragma solidity =0.8.31; import { IEIP3009 } from "./interfaces/IEIP3009.sol"; import { IERC20 } from "./interfaces/IERC20.sol"; @@ -7,7 +7,7 @@ import { IERC20 } from "./interfaces/IERC20.sol"; /// @title RAIL0 — Peer-to-peer stablecoin payments for commerce /// @notice Authorize, capture, void, release, and refund stablecoin payments on any /// EVM-compatible chain whose accepted tokens implement EIP-3009 -/// (`transferWithAuthorization`). Buyer-funded operations use a single +/// (`receiveWithAuthorization`). Buyer-funded operations use a single /// EIP-3009 signature: the buyer signs off-chain and the merchant submits the /// transaction and pays gas natively, so no token allowance state is touched. /// Every operation is merchant-submitted, except `release`, which the payer or @@ -19,13 +19,27 @@ contract RAIL0 { // Constants // ================================================================ - string public constant VERSION = "1.3.0"; + string public constant VERSION = "1.4.0"; /// @dev Reason emitted on the `DisputeClosed` event when a dispute is closed /// automatically by a full refund (one that brings `refundableAmount` to 0). /// Lets indexers distinguish a refund-driven close from a buyer withdrawal. bytes32 public constant REASON_FULL_REFUND = keccak256("rail0.dispute.full_refund"); + /// Minimum gap the contract requires between `authorizationExpiry` and + /// `refundExpiry`, so every payment carries a refund/dispute window that is + /// actually usable. + /// + /// Days, not hours: the window has to survive a buyer noticing a problem, a + /// merchant responding and a transaction confirming — a window measured in hours + /// is one the buyer can lose to a weekend. One day is a FLOOR against a collapsed + /// window, not a recommendation; the README's guidance remains 14–30 days aligned + /// with consumer-protection practice. + /// + /// Chosen so it cannot reject what integrators already produce: the gateway's + /// defaults are a 7-day authorization and a 30-day refund window, a 23-day gap. + uint48 public constant MIN_REFUND_WINDOW = 1 days; + /// @dev EIP-712 typehash for the EIP712Domain struct. bytes32 internal constant _DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); @@ -48,8 +62,6 @@ contract RAIL0 { bytes32 internal constant _VERSION_HASH = keccak256(bytes(VERSION)); /// @dev Reentrancy lock states. - uint256 private constant _NOT_ENTERED = 1; - uint256 private constant _ENTERED = 2; // ================================================================ // Domain separator (EIP-712) @@ -76,7 +88,17 @@ contract RAIL0 { // Reentrancy lock // ================================================================ - uint256 private _reentrancyStatus = _NOT_ENTERED; + /// Reentrancy lock in EIP-1153 transient storage. + /// + /// A `transient` state variable (Solidity 0.8.28+) rather than inline assembly around + /// tload/tstore. Not merely tidier: it measured CHEAPER than the assembly version by + /// 89–356 gas per entrypoint, because the compiler tracks the slot itself instead of + /// working around an opaque asm block. + /// + /// `uint256` rather than `bool` on purpose — also measured. A `bool transient` costs + /// 140–557 gas MORE, since every read and write carries the bool's 0/1 normalisation. + /// Nothing here needs the type to be a bool; 0 and 1 say the same thing for free. + uint256 private transient _entered; modifier nonReentrant() { _nonReentrantBefore(); @@ -85,12 +107,17 @@ contract RAIL0 { } function _nonReentrantBefore() private { - if (_reentrancyStatus == _ENTERED) revert Reentrancy(); - _reentrancyStatus = _ENTERED; + if (_entered != 0) revert Reentrancy(); + _entered = 1; } + /// Releases the lock EXPLICITLY, which transient storage does not make optional. + /// A transient slot is cleared when the TRANSACTION ends, not when the call does, so + /// without this a second guarded call in the same transaction — two operations + /// batched by a multicall or a smart account — would find the lock still held and + /// revert Reentrancy. Pinned by test_Reentrancy_TwoGuardedCallsInOneTransaction. (#45) function _nonReentrantAfter() private { - _reentrancyStatus = _NOT_ENTERED; + _entered = 0; } // ================================================================ @@ -99,7 +126,7 @@ contract RAIL0 { /// @param acceptedTokens Token addresses this deployment will accept on `Payment.token`. /// Each entry must be non-zero and unique. The list is fixed forever. - /// Tokens MUST implement EIP-3009 (`transferWithAuthorization`). + /// Tokens MUST implement EIP-3009 (`receiveWithAuthorization`). constructor(address[] memory acceptedTokens) { _CACHED_CHAIN_ID = block.chainid; _CACHED_DOMAIN_SEPARATOR = _buildDomainSeparator(); @@ -248,7 +275,7 @@ contract RAIL0 { // ================================================================ /// @notice Authorize funds: pull `p.amount` from buyer into escrow. - /// @dev The buyer signs an EIP-3009 `TransferWithAuthorization` over the token's + /// @dev The buyer signs an EIP-3009 `ReceiveWithAuthorization` over the token's /// domain with `from = p.payer`, `to = address(this)`, `value = p.amount`, /// `validAfter = 0`, `validBefore = p.authorizationExpiry`, and /// `nonce = keccak256(_AUTHORIZE_NONCE_PREFIX, paymentId, configHash)`. @@ -257,6 +284,13 @@ contract RAIL0 { /// be opened. The nonce derivation binds the signature to specific Payment /// terms — a merchant cannot substitute different terms and reuse the signature. /// Only `p.payee` (the merchant) may submit; the submitter pays gas. + /// + /// The RECEIVE variant, not the transfer one, on purpose: the token enforces + /// `msg.sender == to` on `receiveWithAuthorization`, so the signature is only + /// spendable through this contract. A `TransferWithAuthorization` signature + /// could be lifted from the mempool and submitted straight to the token by + /// anyone — funds would land here with no `PaymentState` ever created, and no + /// payout path can reach them (#35). function authorize(bytes32 paymentId, Payment calldata p, uint8 v, bytes32 r, bytes32 s) external nonReentrant { if (msg.sender != p.payee) revert NotPayee(); if (_state[paymentId].exists) revert PaymentAlreadyExists(); @@ -271,7 +305,7 @@ contract RAIL0 { // any Payment field changes the configHash, which changes the nonce, which // makes the recovered signer differ from `p.payer`, causing the token to revert. IEIP3009(p.token) - .transferWithAuthorization( + .receiveWithAuthorization( p.payer, address(this), p.amount, @@ -287,10 +321,10 @@ contract RAIL0 { } /// @notice One-shot: authorize and immediately capture (no hold). - /// @dev Same EIP-3009 pattern as `authorize` (including `validAfter = 0` and - /// `validBefore = p.authorizationExpiry` baked into the buyer's signed - /// payload), but the nonce uses `_CHARGE_NONCE_PREFIX` so an authorize- - /// signature can't be repurposed for charge (and vice versa). Here + /// @dev Same EIP-3009 `ReceiveWithAuthorization` pattern as `authorize` (including + /// `validAfter = 0` and `validBefore = p.authorizationExpiry` baked into the + /// buyer's signed payload), but the nonce uses `_CHARGE_NONCE_PREFIX` so an + /// authorize-signature can't be repurposed for charge (and vice versa). Here /// `authorizationExpiry` is the submission deadline only — there is no /// escrow window because the contract immediately forwards the buyer's /// funds to `payee`. Only `p.payee` (the merchant) may submit. @@ -305,7 +339,7 @@ contract RAIL0 { PaymentState({ exists: true, capturableAmount: 0, refundableAmount: p.amount, disputed: false }); IEIP3009(p.token) - .transferWithAuthorization( + .receiveWithAuthorization( p.payer, address(this), p.amount, 0, p.authorizationExpiry, _chargeNonce(paymentId, configHash), v, r, s ); @@ -321,7 +355,7 @@ contract RAIL0 { /// recover their escrowed funds; the merchant may also submit to settle. function release(bytes32 paymentId, Payment calldata p) external nonReentrant { if (msg.sender != p.payer && msg.sender != p.payee) revert NotPayerOrPayee(); - PaymentState memory s = _loadAndVerify(paymentId, p); + (PaymentState memory s,) = _loadAndVerify(paymentId, p); if (block.timestamp < p.authorizationExpiry) revert AuthorizationNotExpired(); if (s.capturableAmount == 0) revert NothingToRelease(); @@ -349,7 +383,7 @@ contract RAIL0 { /// @param reason Caller-supplied code recorded in the event; meaning lives off-chain. function dispute(bytes32 paymentId, Payment calldata p, bytes32 reason) external { if (msg.sender != p.payer) revert NotPayer(); - PaymentState memory s = _loadAndVerify(paymentId, p); + (PaymentState memory s,) = _loadAndVerify(paymentId, p); if (block.timestamp >= p.refundExpiry) revert RefundExpired(); if (s.refundableAmount == 0) revert NothingToDispute(); if (s.disputed) revert AlreadyDisputed(); @@ -367,7 +401,7 @@ contract RAIL0 { /// @param reason Caller-supplied code recorded in the event; meaning lives off-chain. function closeDispute(bytes32 paymentId, Payment calldata p, bytes32 reason) external { if (msg.sender != p.payer) revert NotPayer(); - PaymentState memory s = _loadAndVerify(paymentId, p); + (PaymentState memory s,) = _loadAndVerify(paymentId, p); if (!s.disputed) revert NotDisputed(); _state[paymentId].disputed = false; @@ -382,7 +416,7 @@ contract RAIL0 { /// @notice Capture authorized funds: pay the merchant. function capture(bytes32 paymentId, Payment calldata p, uint256 amount) external nonReentrant { if (msg.sender != p.payee) revert NotPayee(); - PaymentState memory s = _loadAndVerify(paymentId, p); + (PaymentState memory s,) = _loadAndVerify(paymentId, p); if (block.timestamp >= p.authorizationExpiry) revert AuthorizationExpired(); if (amount == 0 || amount > s.capturableAmount) revert InvalidCaptureAmount(); @@ -411,7 +445,7 @@ contract RAIL0 { /// never restoring `capturableAmount`. function void(bytes32 paymentId, Payment calldata p) external nonReentrant { if (msg.sender != p.payee) revert NotPayee(); - PaymentState memory s = _loadAndVerify(paymentId, p); + (PaymentState memory s,) = _loadAndVerify(paymentId, p); if (s.capturableAmount == 0) revert NothingToVoid(); if (s.capturableAmount != p.amount) revert AlreadyCaptured(); @@ -427,7 +461,7 @@ contract RAIL0 { /// @notice Refund a previously captured amount from the merchant's wallet. /// @dev Uses EIP-3009 `receiveWithAuthorization` — the payee signs a - /// `TransferWithAuthorization` digest off-chain; RAIL0 calls + /// `ReceiveWithAuthorization` digest off-chain; RAIL0 calls /// `receiveWithAuthorization` to pull funds from the payee directly into /// this contract, then immediately forwards them to the payer. No ERC-20 /// allowance (`approve`) is needed. @@ -438,7 +472,7 @@ contract RAIL0 { /// value = amount /// validAfter = 0 /// validBefore = p.refundExpiry - /// nonce = refundNonce(paymentId, configHash, refundableAmount) + /// nonce = refundNonce(paymentId, configHash, capturableAmount, refundableAmount) /// /// The nonce encodes the current `refundableAmount` so each partial refund /// has a unique, deterministic nonce — preventing replay and double-spending @@ -449,7 +483,7 @@ contract RAIL0 { nonReentrant { if (msg.sender != p.payee) revert NotPayee(); - PaymentState memory st = _loadAndVerify(paymentId, p); + (PaymentState memory st, bytes32 configHash) = _loadAndVerify(paymentId, p); if (block.timestamp >= p.refundExpiry) revert RefundExpired(); if (amount == 0 || amount > st.refundableAmount) revert InvalidRefundAmount(); @@ -477,7 +511,7 @@ contract RAIL0 { amount, 0, // validAfter: available immediately p.refundExpiry, // validBefore: same as on-chain refund deadline - _refundNonce(paymentId, _configHash[paymentId], st.refundableAmount), + _refundNonce(paymentId, configHash, st.capturableAmount, st.refundableAmount), v, r, s @@ -522,30 +556,41 @@ contract RAIL0 { } /// @notice Computes the EIP-3009 nonce the buyer must use when signing a - /// `TransferWithAuthorization` for an `authorize` call. + /// `ReceiveWithAuthorization` for an `authorize` call. function authorizeNonce(bytes32 paymentId, bytes32 configHash) external pure returns (bytes32) { return _authorizeNonce(paymentId, configHash); } /// @notice Computes the EIP-3009 nonce the buyer must use when signing a - /// `TransferWithAuthorization` for a `charge` call. + /// `ReceiveWithAuthorization` for a `charge` call. function chargeNonce(bytes32 paymentId, bytes32 configHash) external pure returns (bytes32) { return _chargeNonce(paymentId, configHash); } /// @notice Computes the EIP-3009 nonce the payee must use when signing a - /// `TransferWithAuthorization` for a `refund` call. + /// `ReceiveWithAuthorization` for a `refund` call. /// @param paymentId The payment identifier. /// @param configHash Stored configuration hash (from `getConfigHash`). + /// @param capturableAmount Current escrow balance (from `getPaymentState`). /// @param refundableAmount Current refundable balance (from `getPaymentState`). - /// Including this value makes each partial-refund nonce unique and ties - /// the signature to a specific payment state, preventing replay. - function refundNonce(bytes32 paymentId, bytes32 configHash, uint120 refundableAmount) + /// + /// @dev BOTH balances go into the nonce, and that is what makes it unique. + /// + /// The pair determines `amount - capturable - refundable` — how much of the + /// payment has left the two live buckets. That quantity never falls: a + /// capture moves value BETWEEN the buckets and leaves it flat, while every + /// refund raises it by the refunded amount. So no two refunds of a payment + /// can share a pre-refund pair, and no nonce repeats. + /// + /// Deriving from `refundableAmount` alone was unsafe: a capture puts that + /// balance back to a value already used, so the nonce repeats and the token + /// refuses every later refund — permanently. See #36. + function refundNonce(bytes32 paymentId, bytes32 configHash, uint120 capturableAmount, uint120 refundableAmount) external pure returns (bytes32) { - return _refundNonce(paymentId, configHash, refundableAmount); + return _refundNonce(paymentId, configHash, capturableAmount, refundableAmount); } /// @notice Returns the EIP-712 domain separator for this contract on the current chain. @@ -565,18 +610,27 @@ contract RAIL0 { return keccak256(abi.encode(_CHARGE_NONCE_PREFIX, paymentId, configHash)); } - function _refundNonce(bytes32 paymentId, bytes32 configHash, uint120 refundableAmount) + function _refundNonce(bytes32 paymentId, bytes32 configHash, uint120 capturableAmount, uint120 refundableAmount) internal pure returns (bytes32) { - return keccak256(abi.encode(_REFUND_NONCE_PREFIX, paymentId, configHash, refundableAmount)); + return keccak256(abi.encode(_REFUND_NONCE_PREFIX, paymentId, configHash, capturableAmount, refundableAmount)); } function _validatePayment(Payment calldata p) internal view { if (p.amount == 0) revert InvalidAmount(); if (p.authorizationExpiry == 0) revert InvalidExpiries(); if (p.authorizationExpiry > p.refundExpiry) revert InvalidExpiries(); + // A refund/dispute window of at least MIN_REFUND_WINDOW. The ordering check + // above is what makes this subtraction safe — refundExpiry >= authorizationExpiry + // is already guaranteed, so it cannot underflow. + // + // Without it, equal expiries were accepted (the ordering check uses `>`, not + // `>=`), which collapses the window to zero: refund and dispute both become + // unreachable the instant the authorization ends, so a payment could be created + // that is refundable in name only. + if (p.refundExpiry - p.authorizationExpiry < MIN_REFUND_WINDOW) revert InvalidExpiries(); if (block.timestamp >= p.authorizationExpiry) revert AuthorizationExpired(); if (p.payer == address(0) || p.payee == address(0) || p.token == address(0)) { revert ZeroAddress(); @@ -589,10 +643,18 @@ contract RAIL0 { if (!_accepted[p.token]) revert TokenNotAccepted(); } - function _loadAndVerify(bytes32 paymentId, Payment calldata p) internal view returns (PaymentState memory s) { + /// Returns the verified config hash alongside the state. The hash is loaded here + /// anyway to compare against `_hash(p)`, so handing it back saves `refund` a second + /// SLOAD of the same slot — it needs the hash to derive the EIP-3009 nonce. (#45) + function _loadAndVerify(bytes32 paymentId, Payment calldata p) + internal + view + returns (PaymentState memory s, bytes32 configHash) + { s = _state[paymentId]; if (!s.exists) revert PaymentNotFound(); - if (_configHash[paymentId] != _hash(p)) revert PaymentMismatch(); + configHash = _configHash[paymentId]; + if (configHash != _hash(p)) revert PaymentMismatch(); } function _hash(Payment calldata p) internal view returns (bytes32) { diff --git a/contracts/src/interfaces/IEIP3009.sol b/contracts/src/interfaces/IEIP3009.sol index fef42ee..48f42ce 100644 --- a/contracts/src/interfaces/IEIP3009.sol +++ b/contracts/src/interfaces/IEIP3009.sol @@ -1,43 +1,41 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; +pragma solidity =0.8.31; /// @title IEIP3009 — Subset of EIP-3009 used by RAIL0. -/// @dev RAIL0 uses two EIP-3009 functions. In BOTH cases the caller is RAIL0 itself: -/// it is this contract that calls the token, so the token sees -/// `msg.sender == address(RAIL0)`. The payer/payee only ever sign off-chain. +/// @dev RAIL0 uses ONE EIP-3009 function, `receiveWithAuthorization`, for every +/// signature-funded operation: authorize and charge (the payer signs, to pull the +/// payer's funds) and refund (the payee signs, to pull the payee's). In all three +/// the caller is RAIL0 itself — it is this contract that calls the token, so the +/// token sees `msg.sender == address(RAIL0)`. The payer/payee only ever sign +/// off-chain. /// -/// • `transferWithAuthorization` — used by authorize/charge to pull the PAYER's -/// funds. Signed by the payer. The EIP places NO restriction on who submits it. -/// • `receiveWithAuthorization` — used by refund to pull the PAYEE's funds. -/// Signed by the payee. The token additionally requires `to == msg.sender`, so -/// it verifies RAIL0 is the intended recipient before moving funds. +/// The RECEIVE variant, in all three, is the security property. The token requires +/// `to == msg.sender`, and every RAIL0 signature names this contract as `to`, so a +/// signature is only ever spendable THROUGH this contract: lifted from a pending +/// transaction it is worthless to anyone else. /// -/// The two require DIFFERENT typed data, despite the shared field list. Each has -/// its own EIP-712 typehash — verified against deployed USDC: +/// `transferWithAuthorization` is deliberately ABSENT from this interface — not +/// merely unused. The EIP places no restriction on who submits it, so a signature +/// made for it can be taken from the mempool and sent straight to the token by +/// anyone: the funds land here with no `PaymentState` ever created, and no payout +/// path can reach them. That is issue #35, and authorize/charge used to be written +/// that way. Keeping the declaration out means no call site can reach for it again +/// without re-adding it here, where this comment is. +/// +/// The two functions require DIFFERENT typed data, despite the shared field list. +/// Each has its own EIP-712 typehash — verified against deployed USDC: /// /// TransferWithAuthorization 0x7c7c6cdb…1c1a2267 /// ReceiveWithAuthorization 0xd099cc98…aae413de8 /// -/// This comment previously claimed the caller was the payer and that both -/// functions took the same signature. Both were wrong, and the second is very -/// likely why authorize/charge use the submitter-unrestricted variant — see -/// issue #35, where that is a front-running hazard. Anyone changing which -/// function a call site uses must also change what the signer signs. +/// So switching which function a call site uses REQUIRES changing what the signer +/// signs — a `TransferWithAuthorization` signature presented to +/// `receiveWithAuthorization` recovers a different signer and the token reverts. +/// Off-chain integrators sign `ReceiveWithAuthorization` for all three operations. /// -/// USDC (FiatTokenV2_2) exposes both on every chain it deploys to. +/// USDC (FiatTokenV2_2) exposes `receiveWithAuthorization` on every chain it +/// deploys to. interface IEIP3009 { - function transferWithAuthorization( - address from, - address to, - uint256 value, - uint256 validAfter, - uint256 validBefore, - bytes32 nonce, - uint8 v, - bytes32 r, - bytes32 s - ) external; - function receiveWithAuthorization( address from, address to, diff --git a/contracts/src/interfaces/IERC20.sol b/contracts/src/interfaces/IERC20.sol index 2ecc4f0..6f4791f 100644 --- a/contracts/src/interfaces/IERC20.sol +++ b/contracts/src/interfaces/IERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; +pragma solidity =0.8.31; /// @title IERC20 — Minimal ERC-20 interface used by RAIL0. /// @dev Some tokens (e.g. USDT on Ethereum mainnet) do not return a bool from diff --git a/contracts/test/RAIL0.t.sol b/contracts/test/RAIL0.t.sol index 6f1a8be..51b27ce 100644 --- a/contracts/test/RAIL0.t.sol +++ b/contracts/test/RAIL0.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.27; +pragma solidity =0.8.31; import { Test } from "forge-std/Test.sol"; import { Vm } from "forge-std/Vm.sol"; @@ -10,7 +10,9 @@ import { IERC20 } from "../src/interfaces/IERC20.sol"; // Mock tokens // ================================================================ -/// Standard ERC-20 with EIP-3009 `transferWithAuthorization`. +/// Standard ERC-20 with EIP-3009 `transferWithAuthorization` and +/// `receiveWithAuthorization`, each verified against its OWN typehash — mirroring +/// FiatTokenV2 (USDC), where the two are distinct despite the identical field list. contract MockERC20 { // Lowercase to match the ERC-20 standard's `name()` / `version()` getters. // forge-lint: disable-next-line(screaming-snake-case-const) @@ -26,6 +28,10 @@ contract MockERC20 { "TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" ); + bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH = keccak256( + "ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)" + ); + bytes32 public immutable DOMAIN_SEPARATOR; event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce); @@ -113,7 +119,7 @@ contract MockERC20 { require(!authorizationState[from][nonce], "EIP3009: nonce used"); bytes32 structHash = keccak256( - abi.encode(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) + abi.encode(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce) ); bytes32 digest = keccak256(abi.encodePacked(hex"1901", DOMAIN_SEPARATOR, structHash)); address signer = ecrecover(digest, v, r, s); @@ -155,7 +161,7 @@ contract MockTransferFails is MockERC20 { } } -/// Token whose transferWithAuthorization calls back into RAIL0 (reentrancy attempt). +/// Token whose receiveWithAuthorization calls back into RAIL0 (reentrancy attempt). contract MockReentrant { bool public reenterAttempted; bool public reenterSucceeded; @@ -168,7 +174,7 @@ contract MockReentrant { payload = _payload; } - function transferWithAuthorization(address, address, uint256, uint256, uint256, bytes32, uint8, bytes32, bytes32) + function receiveWithAuthorization(address, address, uint256, uint256, uint256, bytes32, uint8, bytes32, bytes32) external { if (rail0 != address(0) && payload.length > 0) { @@ -240,9 +246,11 @@ contract RAIL0Test is Test { }); } - /// Sign an EIP-3009 TransferWithAuthorization for the given token, with the - /// nonce derived as RAIL0 would expect for either an authorize or charge call. - function _sign3009( + /// Sign an EIP-3009 ReceiveWithAuthorization for the given token, with the + /// nonce derived as RAIL0 would expect. Every RAIL0 operation that spends a + /// signature (authorize, charge, refund) goes through `receiveWithAuthorization`, + /// so this is the only digest the suite ever signs against a well-behaved token. + function _signReceive3009( uint256 ownerKey, MockERC20 t, address from, @@ -253,7 +261,7 @@ contract RAIL0Test is Test { bytes32 nonce ) internal view returns (uint8 v, bytes32 r, bytes32 s) { bytes32 structHash = keccak256( - abi.encode(t.TRANSFER_WITH_AUTHORIZATION_TYPEHASH(), from, to, value, validAfter, validBefore, nonce) + abi.encode(t.RECEIVE_WITH_AUTHORIZATION_TYPEHASH(), from, to, value, validAfter, validBefore, nonce) ); bytes32 digest = keccak256(abi.encodePacked(hex"1901", t.DOMAIN_SEPARATOR(), structHash)); (v, r, s) = vm.sign(ownerKey, digest); @@ -264,7 +272,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(paymentId, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.prank(payee); rail0.authorize(paymentId, p, v, r, s); } @@ -273,7 +281,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(paymentId, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.prank(payee); rail0.charge(paymentId, p, v, r, s); } @@ -282,12 +290,12 @@ contract RAIL0Test is Test { /// Mints only the difference if payee's balance is insufficient. function _refund(bytes32 paymentId, RAIL0.Payment memory p, uint256 amount) internal { bytes32 configHash = rail0.getConfigHash(paymentId); - uint120 refundable = rail0.getPaymentState(paymentId).refundableAmount; - bytes32 nonce = rail0.refundNonce(paymentId, configHash, refundable); + RAIL0.PaymentState memory st0 = rail0.getPaymentState(paymentId); + bytes32 nonce = rail0.refundNonce(paymentId, configHash, st0.capturableAmount, st0.refundableAmount); uint256 bal = token.balanceOf(payee); if (bal < amount) token.mint(payee, amount - bal); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), amount, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), amount, 0, p.refundExpiry, nonce); vm.prank(payee); rail0.refund(paymentId, p, amount, v, r, s); } @@ -315,7 +323,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.prank(makeAddr("random-relayer")); vm.expectRevert(RAIL0.NotPayee.selector); @@ -328,9 +336,9 @@ contract RAIL0Test is Test { bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); // Sign with payee key instead of payer (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payeeKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); - // Token reverts inside transferWithAuthorization on bad sig — bubbles through RAIL0. + // Token reverts inside receiveWithAuthorization on bad sig — bubbles through RAIL0. vm.expectRevert(); vm.prank(payee); rail0.authorize(PAYMENT_ID, p, v, r, s); @@ -342,7 +350,7 @@ contract RAIL0Test is Test { bytes32 signedHash = rail0.hashPayment(signed); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, signedHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), signed.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), signed.amount, 0, authorizationExpiry, nonce); // Submit with tampered Payment — the contract will derive a different nonce RAIL0.Payment memory tampered = _payment(); @@ -362,7 +370,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.warp(authorizationExpiry); vm.expectRevert(RAIL0.AuthorizationExpired.selector); @@ -395,7 +403,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 chargeNonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, chargeNonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, chargeNonce); vm.expectRevert(); vm.prank(payee); @@ -409,7 +417,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.PaymentAlreadyExists.selector); vm.prank(payee); @@ -422,7 +430,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), 0, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), 0, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.InvalidAmount.selector); vm.prank(payee); @@ -434,7 +442,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectEmit(true, true, true, true); emit RAIL0.PaymentAuthorized(PAYMENT_ID, payer, payee, p); @@ -462,7 +470,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 authNonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, authNonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, authNonce); vm.expectRevert(); vm.prank(payee); @@ -475,7 +483,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.prank(makeAddr("random-relayer")); vm.expectRevert(RAIL0.NotPayee.selector); @@ -487,7 +495,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payeeKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectRevert(); vm.prank(payee); @@ -499,7 +507,7 @@ contract RAIL0Test is Test { bytes32 signedHash = rail0.hashPayment(signed); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, signedHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), signed.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), signed.amount, 0, authorizationExpiry, nonce); RAIL0.Payment memory tampered = _payment(); tampered.amount = 200e6; @@ -514,7 +522,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.warp(authorizationExpiry); vm.expectRevert(RAIL0.AuthorizationExpired.selector); @@ -529,7 +537,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.PaymentAlreadyExists.selector); vm.prank(payee); @@ -542,7 +550,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), 0, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), 0, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.InvalidAmount.selector); vm.prank(payee); @@ -554,7 +562,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectEmit(true, true, true, true); emit RAIL0.PaymentCharged(PAYMENT_ID, payer, payee, p); @@ -750,10 +758,11 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); token.mint(payee, 50e6); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); vm.prank(makeAddr("relayer")); vm.expectRevert(RAIL0.NotPayee.selector); @@ -777,10 +786,11 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); token.mint(payee, 50e6); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); vm.warp(refundExpiry); vm.expectRevert(RAIL0.RefundExpired.selector); @@ -794,11 +804,12 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); token.mint(payee, 50e6); // Sign with payer key instead of payee key → bad sig. (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payerKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); vm.expectRevert(); vm.prank(payee); @@ -812,10 +823,11 @@ contract RAIL0Test is Test { // First refund succeeds. bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); token.mint(payee, 100e6); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); vm.prank(payee); rail0.refund(PAYMENT_ID, p, 50e6, v, r, s); @@ -1022,8 +1034,10 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); - (uint8 v, bytes32 r, bytes32 s) = _sign3009(payeeKey, token, payee, address(rail0), 0, 0, p.refundExpiry, nonce); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); + (uint8 v, bytes32 r, bytes32 s) = + _signReceive3009(payeeKey, token, payee, address(rail0), 0, 0, p.refundExpiry, nonce); vm.expectRevert(RAIL0.InvalidRefundAmount.selector); vm.prank(payee); rail0.refund(PAYMENT_ID, p, 0, v, r, s); @@ -1035,10 +1049,11 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); token.mint(payee, 101e6); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 101e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 101e6, 0, p.refundExpiry, nonce); vm.expectRevert(RAIL0.InvalidRefundAmount.selector); vm.prank(payee); rail0.refund(PAYMENT_ID, p, 101e6, v, r, s); @@ -1048,7 +1063,7 @@ contract RAIL0Test is Test { RAIL0.Payment memory p = _payment(); // No payment created — _loadAndVerify reverts with PaymentNotFound. (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, bytes32(0)); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, bytes32(0)); vm.expectRevert(RAIL0.PaymentNotFound.selector); vm.prank(payee); rail0.refund(PAYMENT_ID, p, 50e6, v, r, s); @@ -1062,9 +1077,10 @@ contract RAIL0Test is Test { bad.amount = 9999e6; bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); vm.expectRevert(RAIL0.PaymentMismatch.selector); vm.prank(payee); rail0.refund(PAYMENT_ID, bad, 50e6, v, r, s); @@ -1409,7 +1425,7 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, other, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, other, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.TokenNotAccepted.selector); vm.prank(payee); @@ -1423,7 +1439,7 @@ contract RAIL0Test is Test { function _signForAuthorize(RAIL0.Payment memory p) internal view returns (uint8 v, bytes32 r, bytes32 s) { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); - return _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, p.authorizationExpiry, nonce); + return _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, p.authorizationExpiry, nonce); } function test_Validation_RejectsBadExpiriesOrder() public { @@ -1477,25 +1493,78 @@ contract RAIL0Test is Test { // Build the digest manually with the original token, but submit with token=0. bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, rail0.hashPayment(p)); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.ZeroAddress.selector); vm.prank(payee); rail0.authorize(PAYMENT_ID, p, v, r, s); } - function test_Validation_AcceptsExpiriesEqual() public { - // authorizationExpiry == refundExpiry should be allowed - // (the contract uses `>` not `>=` in the ordering check). + /// Equal expiries are now REJECTED. They used to be accepted — the ordering check + /// uses `>`, not `>=` — which collapsed the refund/dispute window to zero: both + /// become unreachable the instant the authorization ends, so the payment was + /// refundable in name only. (#41) + function test_Validation_RejectsExpiriesEqual() public { RAIL0.Payment memory p = _payment(); - uint48 t = uint48(block.timestamp + 1 hours); + uint48 t = uint48(block.timestamp + 10 days); p.authorizationExpiry = t; p.refundExpiry = t; (uint8 v, bytes32 r, bytes32 s) = _signForAuthorize(p); vm.prank(payee); + vm.expectRevert(RAIL0.InvalidExpiries.selector); rail0.authorize(PAYMENT_ID, p, v, r, s); + } + + /// The boundary, both sides. Exactly MIN_REFUND_WINDOW is accepted; one second + /// under is not — so the constant is the real gate, not an approximation of one. + function test_Validation_RefundWindowBoundary() public { + uint48 authExpiry = uint48(block.timestamp + 10 days); + + RAIL0.Payment memory ok = _payment(); + ok.authorizationExpiry = authExpiry; + ok.refundExpiry = authExpiry + rail0.MIN_REFUND_WINDOW(); + (uint8 v, bytes32 r, bytes32 s) = _signForAuthorize(ok); + vm.prank(payee); + rail0.authorize(PAYMENT_ID, ok, v, r, s); assertEq(rail0.getPaymentState(PAYMENT_ID).capturableAmount, 100e6); + + RAIL0.Payment memory tooTight = _payment(); + tooTight.authorizationExpiry = authExpiry; + tooTight.refundExpiry = authExpiry + rail0.MIN_REFUND_WINDOW() - 1; + (v, r, s) = _signForAuthorize(tooTight); + vm.prank(payee); + vm.expectRevert(RAIL0.InvalidExpiries.selector); + rail0.authorize(keccak256("too-tight"), tooTight, v, r, s); + } + + /// charge shares _validatePayment, so it must reject the same shapes — pinned + /// because a validation added to only one entrypoint is the classic miss. + function test_Validation_ChargeRejectsACollapsedRefundWindow() public { + RAIL0.Payment memory p = _payment(); + uint48 t = uint48(block.timestamp + 10 days); + p.authorizationExpiry = t; + p.refundExpiry = t; + bytes32 configHash = rail0.hashPayment(p); + bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); + (uint8 v, bytes32 r, bytes32 s) = + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, p.authorizationExpiry, nonce); + + vm.prank(payee); + vm.expectRevert(RAIL0.InvalidExpiries.selector); + rail0.charge(PAYMENT_ID, p, v, r, s); + } + + /// The metric is DAYS: an hours-long window is refused even though it is non-zero. + function test_Validation_RejectsAnHoursLongRefundWindow() public { + RAIL0.Payment memory p = _payment(); + p.authorizationExpiry = uint48(block.timestamp + 10 days); + p.refundExpiry = p.authorizationExpiry + 6 hours; + (uint8 v, bytes32 r, bytes32 s) = _signForAuthorize(p); + + vm.prank(payee); + vm.expectRevert(RAIL0.InvalidExpiries.selector); + rail0.authorize(PAYMENT_ID, p, v, r, s); } // ============================================================ @@ -1565,13 +1634,72 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.hashPayment(p); bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); vm.expectRevert(RAIL0.PaymentAlreadyExists.selector); vm.prank(payee); rail0.authorize(PAYMENT_ID, p, v, r, s); } + // ============================================================ + // Front-running the merchant's submission (#35) + // ============================================================ + // + // authorize/charge spend the buyer's signature via `receiveWithAuthorization`, + // whose `msg.sender == to` check is the entire defense: a signature naming RAIL0 + // as `to` is spendable only through RAIL0. These tests pin both direct-to-token + // routes an attacker could try with a signature lifted from the mempool, then + // prove the nonce survives for the merchant's real submission. + + function test_FrontRun_AuthorizeSignature_UnusableDirectlyAtToken() public { + RAIL0.Payment memory p = _payment(); + bytes32 configHash = rail0.hashPayment(p); + bytes32 nonce = rail0.authorizeNonce(PAYMENT_ID, configHash); + (uint8 v, bytes32 r, bytes32 s) = + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + + address attacker = makeAddr("mempool-attacker"); + + // Route 1: submit the lifted signature to receiveWithAuthorization directly. + // The token requires msg.sender == to, and `to` is RAIL0 — not the attacker. + vm.prank(attacker); + vm.expectRevert(bytes("EIP3009: caller must be receiver")); + token.receiveWithAuthorization(payer, address(rail0), p.amount, 0, authorizationExpiry, nonce, v, r, s); + + // Route 2: submit it to transferWithAuthorization, which anyone may call. + // The signature was produced over the RECEIVE typehash, so the recovered + // signer differs from the payer and the token rejects it. + vm.prank(attacker); + vm.expectRevert(bytes("EIP3009: bad sig")); + token.transferWithAuthorization(payer, address(rail0), p.amount, 0, authorizationExpiry, nonce, v, r, s); + + // Neither attempt burned the nonce: the merchant's authorize still lands and + // the funds end up tracked by a live PaymentState. + vm.prank(payee); + rail0.authorize(PAYMENT_ID, p, v, r, s); + assertEq(rail0.getPaymentState(PAYMENT_ID).capturableAmount, p.amount); + assertEq(token.balanceOf(address(rail0)), p.amount); + } + + function test_FrontRun_ChargeSignature_UnusableDirectlyAtToken() public { + RAIL0.Payment memory p = _payment(); + bytes32 configHash = rail0.hashPayment(p); + bytes32 nonce = rail0.chargeNonce(PAYMENT_ID, configHash); + (uint8 v, bytes32 r, bytes32 s) = + _signReceive3009(payerKey, token, payer, address(rail0), p.amount, 0, authorizationExpiry, nonce); + + address attacker = makeAddr("mempool-attacker"); + + vm.prank(attacker); + vm.expectRevert(bytes("EIP3009: caller must be receiver")); + token.receiveWithAuthorization(payer, address(rail0), p.amount, 0, authorizationExpiry, nonce, v, r, s); + + vm.prank(payee); + rail0.charge(PAYMENT_ID, p, v, r, s); + assertEq(rail0.getPaymentState(PAYMENT_ID).refundableAmount, p.amount); + assertEq(token.balanceOf(payee), p.amount); + } + // ============================================================ // EIP-712 / hash commitment // ============================================================ @@ -1624,14 +1752,14 @@ contract RAIL0Test is Test { tokens[0] = address(badTransfer); RAIL0 r = new RAIL0(tokens); - // Set up state via authorize (uses transferWithAuthorization, which works). + // Set up state via authorize (uses receiveWithAuthorization, which works). badTransfer.mint(payer, 1000e6); RAIL0.Payment memory p = _payment(); p.token = address(badTransfer); bytes32 cfg = r.hashPayment(p); bytes32 nonce = r.authorizeNonce(PAYMENT_ID, cfg); (uint8 v, bytes32 rr, bytes32 ss) = - _sign3009(payerKey, badTransfer, payer, address(r), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, badTransfer, payer, address(r), p.amount, 0, authorizationExpiry, nonce); vm.prank(payee); r.authorize(PAYMENT_ID, p, v, rr, ss); @@ -1663,7 +1791,7 @@ contract RAIL0Test is Test { bytes32 cfg = r.hashPayment(p); bytes32 nonce = r.authorizeNonce(PAYMENT_ID, cfg); (uint8 v, bytes32 rr, bytes32 ss) = - _sign3009(payerKey, frozen, payer, address(r), p.amount, 0, authorizationExpiry, nonce); + _signReceive3009(payerKey, frozen, payer, address(r), p.amount, 0, authorizationExpiry, nonce); vm.prank(payee); r.authorize(PAYMENT_ID, p, v, rr, ss); @@ -1750,9 +1878,9 @@ contract RAIL0Test is Test { rail0.capture(PAYMENT_ID, p, captureAmount); RAIL0.PaymentState memory mid = rail0.getPaymentState(PAYMENT_ID); - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, rail0.hashPayment(p), mid.refundableAmount); + bytes32 nonce = rail0.refundNonce(PAYMENT_ID, rail0.hashPayment(p), mid.capturableAmount, mid.refundableAmount); (uint8 v, bytes32 r, bytes32 ss) = - _sign3009(payeeKey, token, payee, address(rail0), refundAmount, 0, refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), refundAmount, 0, refundExpiry, nonce); vm.prank(payee); rail0.refund(PAYMENT_ID, p, refundAmount, v, r, ss); @@ -1823,6 +1951,122 @@ contract RAIL0Test is Test { rail0.capture(PAYMENT_ID, p, 1); } + // ============================================================ + // Refund-nonce uniqueness (#36) + // ============================================================ + + /// The sequence that used to brick refunds permanently. + /// + /// Deriving the nonce from `refundableAmount` alone was safe only while that balance + /// fell monotonically. `capture` is the one operation that raises it, so it could put + /// the balance back to a value already used: the nonce repeated, the token refused it + /// as spent, and because `capturableAmount` was by then exhausted the payee had no way + /// to move the balance to a fresh value. The residual was permanently non-refundable, + /// and an open dispute on it could never be resolved. + function test_Refund_SurvivesARevisitedRefundableBalance() public { + RAIL0.Payment memory p = _payment(); + _authorize(PAYMENT_ID, p); + bytes32 cfg = rail0.getConfigHash(PAYMENT_ID); + + vm.prank(payee); + rail0.capture(PAYMENT_ID, p, 50e6); + bytes32 firstNonce = rail0.refundNonce(PAYMENT_ID, cfg, 50e6, 50e6); + _refund(PAYMENT_ID, p, 50e6); + assertTrue(token.authorizationState(payee, firstNonce), "precondition: first nonce spent"); + + // Capture the rest: refundable returns to 50e6 -- the old collision point. + vm.prank(payee); + rail0.capture(PAYMENT_ID, p, 50e6); + RAIL0.PaymentState memory st = rail0.getPaymentState(PAYMENT_ID); + assertEq(st.capturableAmount, 0, "escrow exhausted: the balance cannot be moved again"); + assertEq(st.refundableAmount, 50e6, "and it is back to a value already used"); + + bytes32 secondNonce = rail0.refundNonce(PAYMENT_ID, cfg, 0, 50e6); + assertTrue(secondNonce != firstNonce, "the pair differs, so the nonce differs"); + + // The refund that was impossible now settles. + _refund(PAYMENT_ID, p, 50e6); + assertEq(rail0.getPaymentState(PAYMENT_ID).refundableAmount, 0, "fully refunded"); + } + + /// No nonce may repeat across an ARBITRARY interleaving of captures and refunds. + /// + /// The property behind it: `amount - capturable - refundable` never falls -- a capture + /// moves value between the buckets and leaves it flat, every refund raises it. Since + /// the pair determines that quantity, two refunds cannot share a pre-refund pair. + /// + /// Run against the old single-balance derivation this fails with "REPEATED nonce", + /// which is what makes it a regression test rather than a restatement. + function testFuzz_RefundNoncesNeverRepeat(uint120 a, uint120 b, uint120 c, uint120 d) public { + RAIL0.Payment memory p = _payment(); + _authorize(PAYMENT_ID, p); + bytes32 cfg = rail0.getConfigHash(PAYMENT_ID); + + uint120[4] memory amounts = [ + uint120(bound(a, 1, 25e6)), + uint120(bound(b, 1, 25e6)), + uint120(bound(c, 1, 25e6)), + uint120(bound(d, 1, 25e6)) + ]; + + bytes32[4] memory seen; + uint256 n; + uint120 prevSettled; + + for (uint256 i = 0; i < amounts.length; i++) { + vm.prank(payee); + rail0.capture(PAYMENT_ID, p, amounts[i]); + + RAIL0.PaymentState memory st = rail0.getPaymentState(PAYMENT_ID); + assertEq(p.amount - st.capturableAmount - st.refundableAmount, prevSettled, "a capture leaves it flat"); + + bytes32 nonce = rail0.refundNonce(PAYMENT_ID, cfg, st.capturableAmount, st.refundableAmount); + for (uint256 j = 0; j < n; j++) { + assertTrue(nonce != seen[j], "REPEATED nonce across refunds"); + } + seen[n++] = nonce; + + _refund(PAYMENT_ID, p, amounts[i]); + + RAIL0.PaymentState memory settled = rail0.getPaymentState(PAYMENT_ID); + uint120 nowSettled = p.amount - settled.capturableAmount - settled.refundableAmount; + assertGt(nowSettled, prevSettled, "a refund strictly raises it"); + prevSettled = nowSettled; + } + } + + /// A SUCCESSFUL capture must leave an open dispute open. `capturableAmount` and + /// `refundableAmount` share a packed slot with `exists` and `disputed`, so any change + /// to how that slot is written risks carrying the neighbours with it. + /// + /// Written while evaluating a whole-struct write for #45 — which was measured and + /// REJECTED for costing gas — but kept, because the pin is valuable independently + /// and nothing else covered it. test_Capture_IsNotBlockedByAnOpenDispute does not: + /// it charges first, so there is nothing capturable and the capture reverts, meaning + /// a dropped `disputed` would survive it untouched. + function test_Capture_PreservesAnOpenDispute() public { + RAIL0.Payment memory p = _payment(); + _authorize(PAYMENT_ID, p); + + // A dispute needs a refundable balance, so capture part of the escrow first. + vm.prank(payee); + rail0.capture(PAYMENT_ID, p, 40e6); + + vm.prank(payer); + rail0.dispute(PAYMENT_ID, p, bytes32(uint256(1))); + assertTrue(rail0.getPaymentState(PAYMENT_ID).disputed, "precondition: dispute open"); + + // A second, successful capture — escrow remains, so this one goes through. + vm.prank(payee); + rail0.capture(PAYMENT_ID, p, 10e6); + + RAIL0.PaymentState memory st = rail0.getPaymentState(PAYMENT_ID); + assertTrue(st.disputed, "the whole-slot write must not clear an open dispute"); + assertTrue(st.exists, "nor the exists flag"); + assertEq(st.capturableAmount, 50e6, "escrow reduced by both captures"); + assertEq(st.refundableAmount, 50e6, "and moved to the refundable bucket"); + } + function test_SafeTransfer_AcceptsNonReturningToken() public { // USDT-mainnet style: `transfer` returns NO data. _safeTransfer must accept it // (the `data.length == 0` branch — success, no bool to decode), so an outbound @@ -1849,10 +2093,11 @@ contract RAIL0Test is Test { bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); uint120 refundable = rail0.getPaymentState(PAYMENT_ID).refundableAmount; - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, refundable); + bytes32 nonce = + rail0.refundNonce(PAYMENT_ID, configHash, rail0.getPaymentState(PAYMENT_ID).capturableAmount, refundable); token.mint(payee, 50e6); (uint8 v, bytes32 r, bytes32 s) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); // Mock token.transfer(payer, 50e6) to return false. vm.mockCall( @@ -1868,6 +2113,29 @@ contract RAIL0Test is Test { // Reentrancy // ============================================================ + /// Transient storage clears at the end of the TRANSACTION, not the call, so the + /// guard must release the lock explicitly (#45). Without that, the first guarded + /// call in a transaction would poison every later one. + /// + /// Two captures in a single transaction — the shape a multicall or a smart-account + /// batch produces. Both must go through. This is the regression the move from + /// storage to transient storage makes possible, and nothing else in the suite would + /// notice it: every other test makes one guarded call per transaction. + function test_Reentrancy_TwoGuardedCallsInOneTransaction() public { + RAIL0.Payment memory p = _payment(); + _authorize(PAYMENT_ID, p); + + // No vm.prank between them: one broadcast, two guarded entrypoints. + vm.startPrank(payee); + rail0.capture(PAYMENT_ID, p, 10e6); + rail0.capture(PAYMENT_ID, p, 15e6); + vm.stopPrank(); + + RAIL0.PaymentState memory st = rail0.getPaymentState(PAYMENT_ID); + assertEq(st.capturableAmount, 75e6, "both captures must have applied"); + assertEq(st.refundableAmount, 25e6); + } + function test_Reentrancy_GuardBlocksInnerCall() public { MockReentrant evil = new MockReentrant(); address[] memory tokens = new address[](1); @@ -2056,9 +2324,9 @@ contract RAIL0Test is Test { _charge(PAYMENT_ID, p); bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, 100e6); + bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, 0, 100e6); (uint8 v, bytes32 r, bytes32 sig) = - _sign3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 50e6, 0, p.refundExpiry, nonce); vm.expectEmit(true, true, true, true); emit PaymentRefunded(PAYMENT_ID, payer, payee, 50e6, 0, 50e6); @@ -2073,9 +2341,9 @@ contract RAIL0Test is Test { _charge(PAYMENT_ID, p); bytes32 configHash = rail0.getConfigHash(PAYMENT_ID); - bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, 100e6); + bytes32 nonce = rail0.refundNonce(PAYMENT_ID, configHash, 0, 100e6); (uint8 v, bytes32 r, bytes32 sig) = - _sign3009(payeeKey, token, payee, address(rail0), 100e6, 0, p.refundExpiry, nonce); + _signReceive3009(payeeKey, token, payee, address(rail0), 100e6, 0, p.refundExpiry, nonce); vm.expectEmit(true, true, true, true); emit PaymentRefunded(PAYMENT_ID, payer, payee, 100e6, 0, 0); diff --git a/docs/index.html b/docs/index.html index 69771c1..a6809c3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -50,7 +50,7 @@ rail0 rail0 - v1.3.0 + v1.4.0