Skip to content

release: v1.4.0 - #64

Open
pviti wants to merge 25 commits into
mainfrom
v1.4.0
Open

release: v1.4.0#64
pviti wants to merge 25 commits into
mainfrom
v1.4.0

Conversation

@pviti

@pviti pviti commented Aug 18, 2026

Copy link
Copy Markdown
Member

⛔ Do not merge before the contracts are deployed

This used to say "draft, and not to be merged until the deployment", which the draft flag was carrying. The flag is off now, so the constraint has to live here. Merging this while 1.3.0 is what runs on-chain does four things, none of them recoverable by a follow-up commit:

  1. Publishes the wrong instructions. Pages serves main:/docs, and this branch's site tells buyers to sign ReceiveWithAuthorization and shows the 4-argument refundNonce. The deployed 1.3.0 wants transferWithAuthorization and 3 arguments, so every integrator following rail0.xyz fails at the token. That is docs(site): the landing page teaches the signature v1.4.0 rejects — flip it with the release, not before #69's failure, fired in the opposite direction.
  2. Closes two sev-high security issues that are still live on-chain. security(high): authorize/charge use transferWithAuthorization — front-runnable, buyer funds permanently lost #35 (front-running, buyer funds lost) and security(high): refund nonce derives from refundableAmount — nonce collision permanently bricks refunds #36 (refund nonce bricking) are fixed in source only; all six deployed addresses still have them. Closing them publicly misstates the security posture.
  3. Puts VERSION = "1.4.0" on main while every Live address runs 1.3.0 — so anyone verifying a deployed contract from HEAD compiles the wrong version, which is the reproducibility problem maintenance(low): foundry.toml chain config drifted from the deployment set; floating pragma on a deployed contract #43 was about.
  4. Advertises a 1.4.0 badge whose git tag does not exist yet (see the tagging item below).

Nothing in the running stack reads this repo — the indexer builds its config from the gateway's seeds.yml, and addresses live in the seeds — so the damage is entirely to what is published and claimed, not to the live system.

This is the review surface for the release: one diff for everything that has accumulated since 1.3.0.

Why 1.4.0 and not 1.5.0

1.3.0 is what is deployed on all five active chains (0x13a4…Ba1F, 27–28 July), and VERSION had never moved past it. Meanwhile a v1.4.0 branch already existed carrying #53 (the minimum refund window), merged there rather than to main — so the release the in-flight work belongs to is 1.4.0. The v1.5.0 branch has been deleted (it had no commits of its own) and its milestone closed.

What is in it

#53 minimum refund window between the expiries (was already on this branch)
#56 refund nonce from both balances — closes #36, a residual that became permanently non-refundable
#58 authorize/charge pull via receiveWithAuthorizationcloses #35, the mempool front-running hazard
#55 transient reentrancy lock, one fewer configHash SLOAD, solc 0.8.31 + Cancun
main the unreleased docs/CI/test work: the IEIP3009 split, the accounting fuzz, the two accepted-risk write-ups, forge fmt, the first CI workflow
#65 foundry.toml pointed at the deployment set, pragma pinned to =0.8.31closes #43
#74 ReceiveWithAuthorization in the three nonce views and on the landing page — closes #68, #69
#76 the site's remaining 1.4.0 facts: the solc/Cancun requirement, the version badge on both pages
#59 optimizer_runs 200 → 10_000 — previously excluded from this release; see the note below
main the unreleased docs/CI/test work: the IEIP3009 split, the accounting fuzz, the two accepted-risk write-ups, forge fmt, the first CI workflow
VERSION = "1.4.0"

#59 is now IN this release, reversing what this PR used to say

It was merged to this branch on 25 Aug (afce2f4), after the paragraph that said it would stay on v2.0.0. That paragraph is deleted rather than corrected in place, because the decision has to be restated deliberately — it is the bytecode that goes on-chain, and it should not be inherited from a batch merge.

For keeping it: this release redeploys every chain, which was the one condition under which the change was ever acceptable — the old note itself said "if it is ever taken it belongs to a release that redeploys anyway". Measured on this branch: ~1,400 gas off a void (135,823 → 134,430), matching the ~1,300/operation #59 claimed, with forge test green at 129/129.

Against, unchanged: the saving is fractions of a cent, the choice is irreversible on code that cannot be patched, and the real exposure is not the runs count but more aggressive IR inlining on a contract that custodies funds — a risk that buys nothing measurable here.

So: decide before deploying. Either keep it and drop this section, or revert afce2f4 on this branch. What must not happen is shipping it by default.

The conflicts, and how they were resolved

All by keeping both sides rather than picking one — worth checking, because each pair was two correct changes to the same place:

Breaking for off-chain integrators

VERSION is in the EIP-712 domain, so every prior signature is invalidated and a fresh deployment is required on every active chain. Two changes need the gateway before the new addresses go live:

  • the signing typehashauthorize/charge now sign ReceiveWithAuthorization. lib/rail0/eip3009.rb holds one TRANSFER_TYPEHASH; typed_data already takes primary_type, so the change is small. Both SDKs already switch on primaryType and need nothing.
  • refundNonce arity — 3 → 4 arguments. Eip3009.refund_nonce needs the capturable balance added; the refund path already reads payment_state, so the data is there.

Plus rail0-test fixtures.

Before deploying

Verification

forge build clean, forge fmt clean, forge test: 129 passed, 0 failed — re-run on the current branch tip (afce2f4), so the figure holds with #59's optimizer_runs = 10_000 in place, not only at 200.

Issues this release closes

In keyword form, and in this PR specifically, because a PR merging into v1.4.0 cannot close anything: GitHub only creates closing links for a PR targeting the default branch. #58 and #56 wrote "closes #35 / #36" and #74 writes "closes #68 / #69" — none of those registered (closingIssuesReferences on #74 is empty). This PR is the only one that targets main, so it is the only place the keywords do anything. Without them, six fixed issues stay open after the release ships.

The last two depend on #74 landing on v1.4.0 first — it is the one outstanding branch this release still expects.

🤖 Generated with Claude Code

pviti and others added 16 commits July 29, 2026 22:26
authorizationExpiry == refundExpiry was accepted — the ordering check uses `>`, not
`>=` — which collapses the refund/dispute window to zero: both become unreachable the
instant the authorization ends, so a payment could be created that is refundable in
name only.

_validatePayment now requires refundExpiry - authorizationExpiry >=
MIN_REFUND_WINDOW, a new public constant set to 1 days.

Days, not hours, as the metric: the window has to survive a buyer noticing a problem,
a merchant responding and a transaction confirming, and a window measured in hours is
one the buyer can lose to a weekend. There is a test asserting a 6-hour window is
refused even though it is non-zero.

One day is a FLOOR against a collapsed window, not a recommendation — the README's
guidance stays 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. The whole existing test
corpus passes unchanged, which is the evidence that the floor is not disruptive.

The subtraction cannot underflow: the ordering check immediately above already
guarantees refundExpiry >= authorizationExpiry.

Gas: +328 on authorize/charge (151,848 → 152,176 measured on
test_Authorize_Success), and nothing on any other entrypoint — the check lives only
on the creation path.

BREAKING for any caller that relied on equal expiries. Two things this PR does NOT
do, deliberately, because they are release decisions across six live deployments:
  - VERSION is left at 1.3.0. It must be bumped before deploying, since it is part
    of the EIP-712 domain and every configHash depends on it.
  - the gateway's Payment#validate is not mirrored, so a hand-built payment with too
    tight a window would fail on-chain rather than as a 422. Worth a follow-up.

Replaces test_Validation_AcceptsExpiriesEqual, which existed to lock in the old
behaviour. New tests cover: equal expiries rejected, the boundary on both sides
(exactly MIN_REFUND_WINDOW accepted, one second under refused), an hours-long window
refused, and charge rejecting the same shape — a validation added to only one
entrypoint being the classic miss.

Suite: 117 tests pass. forge fmt --check clean; forge lint reports the same 9
pre-existing block-timestamp warnings.

Closes #41

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The contract deploys once per chain and is then called for the life of
the deployment, so runtime gas is worth far more than deploy gas.
Measured on the suite's gas report: ~360-640 gas saved per operation
(capture 69,846 -> 69,484 median, refund 86,954 -> 86,315, authorize
141,589 -> 140,967) for +550k one-time deploy gas and bytecode growth
from 8,051 to 10,661 bytes — ample headroom under the 24,576 limit.

Bytecode now differs from the v1.x deployments built at 200 runs, so
explorer verification metadata must carry this value from v2.0.0 on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat(validation): require a minimum refund window between the expiries
transferWithAuthorization has no submitter restriction, so a mempool
observer could lift the buyer's (v, r, s) from the merchant's pending
authorize/charge and submit it straight to the token: funds land at the
contract with no PaymentState ever created and no payout path — the
buyer loses the full amount (#35).

receiveWithAuthorization closes the route: the token enforces
msg.sender == to, and every signature names this contract as to, so the
signature is only spendable through the guarded entrypoints.

Breaking for signers: the buyer now signs the ReceiveWithAuthorization
typehash. The mock, the signing helper, and the README's signing walk-
through wrongly used the TransferWithAuthorization typehash for refund's
receiveWithAuthorization path too — corrected everywhere, and two
regression tests pin both direct-to-token front-run routes.

Closes #35.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The refund nonce encoded `refundableAmount` alone, which is safe only while that
balance falls monotonically. `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 `capturableAmount` 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 by a refund.

Reachable through ordinary merchant flows, not just direct contract calls: the
gateway's CAPTURABLE_FROM includes partially_captured and a partial refund leaves the
status unchanged, so capture-refund-capture is a normal two-shipment order.

The nonce now encodes BOTH balances. The pair determines
`amount - capturable - refundable` — how much of the payment has left the two live
buckets — and that quantity never falls: a capture moves value between the buckets
and leaves it flat, every refund raises it by the refunded amount. So no two refunds
of a payment can share a pre-refund pair.

Chosen over the suggested per-payment counter, which needs storage PaymentState does
not have: it is exactly one 256-bit slot (bool + two uint120 + bool), so a counter
means a second slot (~20,000 gas on every payment's first refund) or narrowing the
balances and rewriting the EIP-712 Payment typehash.

Passing both balances also beat computing the difference on-chain. Measured on an
isolated refund call (gasleft brackets, harness excluded):

  main (buggy)                63,244
  both balances, no arith     63,296   +52
  difference, unchecked       63,371   +127
  difference, checked         63,523   +279

So it is ~380x cheaper than a storage counter, and it removes the checked/unchecked
question entirely — with no arithmetic there is nothing to check and no invariant to
bet on. `capturable + refundable <= amount` does hold by construction (the sum starts
at amount on both creation paths and no write raises it), but not relying on it is
better than relying on it.

BREAKING: `refundNonce` takes a fourth parameter, so its selector changes and every
off-chain deriver must move with it — gateway, CLI, SDKs. No extra call is needed:
both balances come from the single getPaymentState those callers already make.

Tests: test_Refund_SurvivesARevisitedRefundableBalance walks the exact sequence, and
testFuzz_RefundNoncesNeverRepeat fuzzes arbitrary capture/refund interleavings over
256 runs asserting no nonce repeats and that the quantity above stays flat on a
capture and strictly rises on a refund. Both were run against the OLD derivation and
the fuzz test fails there with "REPEATED nonce across refunds" — verified, after a
first sabotage attempt failed for the wrong reason (a signature mismatch) and proved
nothing.

README's nonce section is corrected, including a note for anyone integrated against
the old derivation.

Suite: 122 tests pass. forge fmt --check clean; forge lint reports the same 9
pre-existing block-timestamp warnings.

Closes #36

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The signature change was documented in the prose that explains it and missed in the
three places an integrator actually copies from: the helper list in the README, the
refund walkthrough, and the same walkthrough on the docs site. All three published
`refundNonce(bytes32,bytes32,uint120)`, which now recovers the wrong nonce — the
failure lands at broadcast, as a token revert the caller pays gas for.

The walkthroughs also now say where the two values come from (getPaymentState), since
the second one is new and there is no reason for a reader to guess.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two of #45's three items. Every guarded entrypoint gets cheaper:

  authorize  151,870 → 149,973   -1,897
  charge     153,077 → 151,180   -1,897
  capture    178,347 → 176,692   -1,655
  void       137,247 → 135,455   -1,792
  release    135,647 → 133,811   -1,836
  refund     204,193 → 202,229   -1,964

Measured against a clean `main` worktree, not a hand-reverted tree — partial reverts
move this contract's optimizer output enough to produce a false baseline.

1) The reentrancy lock moves from storage to EIP-1153 transient storage. The issue
estimated ~2k per call and that is what it delivers; an earlier estimate of ~4.8k in
discussion was wrong because it ignored the EIP-3529 refund for restoring a slot to
its original value.

Solidity 0.8.27 has no `transient` keyword (0.8.28+), so this is two one-line
assembly helpers with the custom error kept in Solidity.

Chain safety is not a new requirement: the deployed bytecode ALREADY contains MCOPY,
a Cancun opcode, so all six live deployments are on Cancun chains today. TLOAD was
also probed directly on all five active chains earlier. evm_version is now pinned to
"cancun" rather than inherited from forge's default (currently "prague"), so the
dependency is stated and a future forge default cannot silently raise it.

Transient storage clears at the end of the TRANSACTION, not the call, so the guard
must still release the lock explicitly — otherwise the first guarded call in a
transaction poisons every later one, breaking a multicall or smart-account batch.
test_Reentrancy_TwoGuardedCallsInOneTransaction pins it; removing the release makes
it fail with Reentrancy(), verified.

2) _loadAndVerify now returns the configHash it already loads to compare, so refund
stops re-reading the same slot to derive its EIP-3009 nonce.

3) The third item — collapsing capture's two packed-field writes into one struct
write — is DELIBERATELY NOT INCLUDED. Measured, it costs +2,352 gas in capture
(179,044 vs 176,692), turning a saving into a regression. The IR optimizer was
already coalescing the two field writes; writing the struct instead forces the whole
256-bit word to be assembled from four values. The issue's premise that this "removes
reliance on the optimizer" was right about the mechanism and wrong about the price.

test_Capture_PreservesAnOpenDispute was written while evaluating that item and is
kept: a successful capture must not clear an open dispute, nothing else covered it,
and sabotaging the flag makes it fail with the right message.

Suite: 122 tests pass. forge fmt --check clean; forge lint reports the same 9
pre-existing block-timestamp warnings as main.

Refs #45

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the inline assembly from the previous commit with a `transient` state
variable, which needs Solidity 0.8.28+. The pragma on RAIL0.sol moves to ^0.8.28
accordingly; foundry.toml pins the exact build at 0.8.31.

This is not only tidier — it is cheaper, measured:

                    asm      transient    Δ
  authorize      149,973 →    149,884    -89
  charge         151,180 →    151,091    -89
  capture        176,692 →    176,336   -356
  void           135,455 →    135,277   -178
  release        133,811 →    133,633   -178
  refund         202,229 →    202,051   -178

Which brings the totals against main to roughly -2,000 on every guarded entrypoint:

  authorize  151,870 → 149,884   -1,986
  charge     153,077 → 151,091   -1,986
  capture    178,347 → 176,336   -2,011
  void       137,247 → 135,277   -1,970
  release    135,647 → 133,633   -2,014
  refund     204,193 → 202,051   -2,142

`uint256 transient` rather than `bool transient`, also measured: the bool costs
140–557 gas MORE because every read and write carries its 0/1 normalisation. Nothing
here needs a bool.

Dropping the assembly matters beyond gas. foundry.toml already excludes the
asm-keccak256 lint with the note that "inline assembly hurts readability — not worth
the trade", so a hand-rolled tload/tstore block sat against the project's own stated
preference. Now there is none: zero assembly blocks in the contract.

0.8.29, 0.8.30 and 0.8.31 were each built and tested — identical gas, 122 tests
passing on all. 0.8.31 is pinned as the latest patch release, since staying lower
carries any compiler fix since for no measurable benefit; evm_version is pinned
separately, so the compiler version does not drag the EVM target with it.

Only RAIL0.sol's pragma moves. interfaces/IERC20.sol and the test do not use
`transient`, so ^0.8.27 remains an accurate statement of what they require — the
pragma says what a file needs, not what the project happens to build with.

README's two version claims now also state the Cancun requirement explicitly, which
was previously implicit (the deployed bytecode has always contained MCOPY).

Suite: 122 tests pass. forge fmt --check clean; forge lint reports the same 9
pre-existing block-timestamp warnings as main.

Refs #45
Follow-up on review: all three pragmas now name 0.8.31, matching the pinned
compiler, instead of RAIL0.sol at ^0.8.28 and the other two left at ^0.8.27.

The earlier split was defended as "a pragma states what a file requires". That is
true in the abstract and wrong here, for a reason worth recording: NOTHING below the
pin is ever compiled. CI runs `forge build` and `forge test` with the foundry.toml
pin and nothing else, so ^0.8.27 advertised compatibility with 0.8.27 through 0.8.30
that no test exercises — on a contract that holds funds.

Nor does anything need the lower bound. No repo imports these sources: the gateway,
indexer and CLI consume the compiled ABI, and every other reference to RAIL0.sol
across the project is a comment citing its behaviour.

So the range was a claim, not a requirement, and narrowing it costs nothing.

README's two version lines follow.

122 tests pass; gas unchanged (capture 176,336); forge fmt --check clean.

Refs #45
script/Deploy.s.sol was left at ^0.8.27 by the previous commit: the sweep that
updated the pragmas covered src/ and test/ but not script/, and the earlier survey
that did include it collapsed identical lines through `sort -u`, so the omission was
invisible in both.

It is the one file where a stale pragma would matter most — the deploy script is what
produces the bytecode that goes on chain.

All four .sol files under src/, test/ and script/ now read ^0.8.31; verified by
enumerating every .sol in the repo rather than the directories I expected to matter.

122 tests pass, forge fmt --check clean, build succeeds.

Refs #45
This branch was cut before #57 moved IEIP3009 into its own file, so the pragma bump
reached every source file except that one — leaving it alone at ^0.8.27 while the
contract, its test suite, the deploy script and IERC20 all read ^0.8.31.

It compiled either way (0.8.31 satisfies ^0.8.27), which is exactly why it would have
gone unnoticed: nothing fails, the repo just stops agreeing with itself about which
compiler it wants. Pinning the pragma outright is a separate decision — see #43.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…-writes' into v1.4.0

# Conflicts:
#	contracts/src/RAIL0.sol
#	contracts/test/RAIL0.t.sol
…ng' into v1.4.0

# Conflicts:
#	README.md
#	contracts/test/RAIL0.t.sol
The number the release is, and the one line that makes it one: VERSION goes into the
EIP-712 domain, so bumping it invalidates every prior signature and obliges a fresh
deployment on every active chain. 1.3.0 is what is live on all five; main has been ahead of
it since #53 (the minimum refund window) without the constant moving, and this branch adds
three more behavioural changes on top.

NOT touched, deliberately:

- the README's Live table still says 1.3.0 against the deployed addresses, because that is
  what is deployed. It moves when the deployment does, not when the source does.
- docs/index.html and docs/manifesto.html still link to the v1.3.0 release tag. Worth
  knowing that tag does not exist — the repo's tags stop at v1.2.1, so 1.3.0 shipped
  untagged. Fixing that belongs to the release, not to this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Merging the existing v1.4.0 branch (which carries #53, the minimum refund window) into
this one produced no textual conflict and a broken build: #53's tests call `_sign3009`,
which #58 renamed to `_signReceive3009` when authorize/charge moved to the receive variant.
Two changes that never touched the same line and could not both be right.

A rename is the whole fix — the parameters are identical — and it is also the correct
SEMANTICS, not just the compiling one: under #58 an authorize payload is signed against the
ReceiveWithAuthorization typehash, so the receive helper is the one those tests should have
been using.

The kind of collision only a build catches, which is the argument for assembling the
release branch now rather than at merge time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pviti pviti added this to the v1.4.0 milestone Aug 18, 2026
@pviti pviti added the target: main Merges into main — not part of a version release label Aug 18, 2026
@pviti

pviti commented Aug 18, 2026

Copy link
Copy Markdown
Member Author

Cancun verified on all five active chains — probed, not read off docs

The blocker I flagged is cleared. Method, so it can be re-run: eth_call with a state override injecting bytecode at an unused address, executing the opcodes and returning the value. No deployment, no gas.

chain TSTORE/TLOAD (EIP-1153) MCOPY (EIP-5656) negative control
Arc Testnet (5042002) 0x2a 0x2a OpcodeNotFound
Base Sepolia (84532) 0x2a 0x2a OpcodeNotFound
Optimism Sepolia (11155420) 0x2a 0x2a OpcodeNotFound
Arbitrum Sepolia (421614) 0x2a 0x2a invalid opcode
Polygon Amoy (80002) 0x2a 0x2a invalid opcode: 0x0c

The probes:

TSTORE/TLOAD  0x602a60005d60005c60005260206000f3
              PUSH1 2a, PUSH1 00, TSTORE, PUSH1 00, TLOAD, MSTORE, RETURN 32 bytes
MCOPY         0x602a6020526020602060005e60206000f3
              mem[0x20] = 0x2a, MCOPY 32 bytes 0x20 -> 0x00, RETURN mem[0..32]
negative      0x0c60005260206000f3   (0x0c is not an opcode on any fork)

The negative control is the part that makes the result mean something. Without it, an "OK" could equally be a node ignoring the state override, or one that accepts unknown opcodes silently. Every chain rejects 0x0c with an invalid-opcode error and returns 0x2a for the two real ones — so the code genuinely executed, and the opcodes genuinely exist.

Corroborating, on Arc specifically: its latest block header carries the Cancun EIP-4844 fields (blobGasUsed, excessBlobGas, parentBeaconBlockRoot), and web3_clientVersion reports arc/v1. eth_chainId returns 0x4cef52 = 5042002, matching the gateway's seeds.

So evm_version = "cancun" and solc 0.8.31 are deployable on the whole active set: no chain has to go active: false for this release.

pviti and others added 4 commits August 19, 2026 12:05
The sweep that moved refundNonce to four arguments missed two spots, and the
grep that would have caught them didn't, because neither writes `refundNonce(`:

  - README.md:158 — the refund SIGNING WALKTHROUGH, which tells an integrator
    to build `nonce = keccak256(_REFUND_NONCE_PREFIX, paymentId, configHash,
    refundableAmount)`. Three fields. Two lines further down the same section
    already explains that the nonce commits to BOTH balances, so the document
    contradicted itself within a paragraph — and the half a reader copies from
    is the half with the code in it.
  - RAIL0.sol:475 — refund's NatSpec, same three-field form.

Following either produces a nonce the contract never derives: the token
recovers a mismatched signer and reverts with a bare "invalid signature", which
the payee discovers by paying gas. This is the exact failure #35's fix had to
correct across the mock, the signing helper and the README, where the wrong
typehash had been sitting in the reference material nobody re-reads.

No behaviour change: both are comments.

Refs #36.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Closes #43.

`[rpc_endpoints]`/`[etherscan]` had drifted into describing a different project
than the one that ships. Four of the six chains the README lists as Live —
Arbitrum Sepolia, Base Sepolia, Optimism Sepolia, Polygon Amoy — had no entry at
all, so a redeploy or a verification meant supplying URLs out of band. Three
chains that do NOT run RAIL0 did have entries, which is worse than the omission:
Tempo cannot run it at all (TIP-20 has no EIP-3009, as the README itself says),
Plasma's testnet is still Planned, and Moderato appears nowhere in the README.
The config advertised support that does not exist.

Now both tables carry exactly the six Live chains. Nothing here is guessed:

  - every chain id was checked against its endpoint's own eth_chainId;
  - five of the six explorers are Blockscout and were confirmed to answer, so
    they verify with no API key;
  - Polygon Amoy is the exception, and the reason the rows are not uniform:
    Polygonscan's V1 API is retired ("You are using a deprecated V1 endpoint"),
    so Amoy goes through Etherscan's V2 endpoint — the one entry needing a key.

Also pins `pragma solidity =0.8.31` on all five files. foundry.toml already
pinned `solc`, so the bytecode this repo builds was never ambiguous; the
floating caret was ambiguous for a THIRD PARTY recompiling to verify a contract
that has no upgrade path, which is the case the issue is about.

CI gate run in full: fmt, build --sizes, lint, test — 129 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pinning the pragma to =0.8.31 in the previous commit has a cost this commit
pays: an editor extension compiles with the solc it ships with, so one carrying
0.8.36 now refuses these files outright —

  Source file requires different compiler version (current compiler is
  0.8.36+commit.8a079791.Emscripten.clang)

That is the pin working rather than a fault. While the pragma was ^0.8.31 the
same extension compiled the contract with whatever newer 0.8.x it happened to
have, produced different bytecode than the deployed artifact, and said nothing
— which is the silent divergence #43 asked us to close. The fix is to point the
editor AT 0.8.31, not to install something newer; newer is the problem.

Both extensions' keys are set, since the two common ones read different names
and each ignores the other's. The version string is the build forge actually
used, read back from the compiled artifact's metadata rather than typed from
memory — the hash is not guessable.

Also turns off format-on-save for Solidity: this repo is formatted by `forge
fmt`, and an editor applying its own rules fights `forge fmt --check` in CI.

Gate re-run: fmt, build, test — 129 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ild-config

docs+chore: finish the refundNonce arity sweep, and point the build config at the deployment set
pviti and others added 3 commits August 25, 2026 08:42
Fallout from #35's fix that the fix itself did not carry. Every call site now
uses `receiveWithAuthorization`, but two surfaces still told integrators to sign
the transfer variant — and those are the two surfaces integrators read FIRST.

The three nonce views (#68). `authorizeNonce`, `chargeNonce` and `refundNonce`
exist so a caller can derive the nonce it must sign over, and their NatSpec named
`TransferWithAuthorization`. Anyone wiring a signer from them signs a typehash
the token cannot match: the recovered signer differs and every authorize, charge
and refund reverts at the token. No test can catch this — NatSpec has no runtime
behaviour, so the text is its own only guard. The `refundNonce` line predates
#35, since refund used the receive variant all along.

The landing page (#69). Two mentions, and the timing is why they ride with the
release rather than being fixed on main: Pages serves main:/docs, so flipping
them before v1.4.0 is activated would make rail0.xyz describe a signature the
LIVE deployment rejects — the same failure pointed the other way. On this branch
they go live exactly when the release does.

Left alone deliberately: the four remaining mentions are contrast passages
explaining why the transfer variant is NOT used (RAIL0.sol:290,
IEIP3009.sol:28,32, README.md:351). Naming it there is the point.

Closes #68
Closes #69

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…and-site

docs: name the variant the code actually calls — Receive, not Transfer
perf(build): raise optimizer_runs to 10_000
Three claims on rail0.xyz that are true for the DEPLOYED 1.3.0 and false the
moment 1.4.0 is activated. On this branch they go live exactly when the release
does, which is the same reason #74's typehash flip lives here (Pages serves
main:/docs).

- The chain requirement said Solidity 0.8.27. #55 raised the floor to 0.8.31 and
  the Cancun fork — TSTORE/TLOAD for the transient reentrancy lock, MCOPY — and
  the README carries that already; the site did not. It is the one claim on the
  page an integrator uses to decide whether a chain can run RAIL0 at all.
- The version badge, on both pages, read v1.3.0 and linked a tag that does not
  exist (the repo's tags stop at v1.2.1). Now v1.4.0 — which makes tagging this
  release a prerequisite of the merge rather than a loose end: the link 404s
  otherwise, exactly as the v1.3.0 one does today.
- "entrypoints"/"signatures" went plural when the page named both EIP-3009
  functions. #74 left one, so the grammar follows it.

Refs #64

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…body

docs(site): carry the 1.4.0 facts the release changes
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment