Conversation
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>
Cancun verified on all five active chains — probed, not read off docsThe blocker I flagged is cleared. Method, so it can be re-run:
The probes: 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 Corroborating, on Arc specifically: its latest block header carries the Cancun EIP-4844 fields ( So |
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
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
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), andVERSIONhad never moved past it. Meanwhile av1.4.0branch already existed carrying #53 (the minimum refund window), merged there rather than tomain— so the release the in-flight work belongs to is 1.4.0. Thev1.5.0branch has been deleted (it had no commits of its own) and its milestone closed.What is in it
receiveWithAuthorization— closes #35, the mempool front-running hazardconfigHashSLOAD, solc 0.8.31 + Cancunforge fmt, the first CI workflowfoundry.tomlpointed at the deployment set, pragma pinned to=0.8.31— closes #43ReceiveWithAuthorizationin the three nonce views and on the landing page — closes #68, #69optimizer_runs200 → 10_000 — previously excluded from this release; see the note belowforge fmt, the first CI workflowVERSION = "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 onv2.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, withforge testgreen 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
afce2f4on 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:
_refundNoncecall site: fix(refund): derive the nonce from both balances so it can never repeat #56's two balances and perf(gas): transient reentrancy lock via solc 0.8.28+, and one fewer configHash SLOAD #55's localconfigHash(the saved SLOAD) →_refundNonce(paymentId, configHash, st.capturableAmount, st.refundableAmount).test_Capture_PreservesAnOpenDispute, both purely additive._sign3009, which fix(eip3009): authorize/charge pull funds via receiveWithAuthorization #58 renamed to_signReceive3009. No textual conflict, broken build. The rename is also the correct semantics — under fix(eip3009): authorize/charge pull funds via receiveWithAuthorization #58 an authorize payload is signed against theReceiveWithAuthorizationtypehash.Breaking for off-chain integrators
VERSIONis 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:authorize/chargenow signReceiveWithAuthorization.lib/rail0/eip3009.rbholds oneTRANSFER_TYPEHASH;typed_dataalready takesprimary_type, so the change is small. Both SDKs already switch onprimaryTypeand need nothing.refundNoncearity — 3 → 4 arguments.Eip3009.refund_nonceneeds the capturable balance added; the refund path already readspayment_state, so the data is there.Plus
rail0-testfixtures.Before deploying
TSTORE/TLOAD,MCOPY). Under the one-version-across-active-chains rule this is a deploy blocker, not a footnote — a chain that cannot take EIP-1153 has to goactive: false.afce2f4. First item on this list because it decides the bytecode, so it has to be answered before anything is deployed.v1.4.0. Now a hard prerequisite rather than a loose end: docs(site): carry the 1.4.0 facts the release changes #76 points the site's version badge atreleases/tag/v1.4.0on both pages. The repo's tags stop atv1.2.1(1.3.0 shipped untagged, which is why the current badge 404s), so without the tag the freshly-published site has a broken link in its header.mainbefore the deployment, since Pages servesmain:/docs. docs: name the variant the code actually calls — Receive, not Transfer #74 is in; docs(site): carry the 1.4.0 facts the release changes #76 is open.Verification
forge buildclean,forge fmtclean,forge test: 129 passed, 0 failed — re-run on the current branch tip (afce2f4), so the figure holds with #59'soptimizer_runs = 10_000in place, not only at 200.Issues this release closes
In keyword form, and in this PR specifically, because a PR merging into
v1.4.0cannot 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 (closingIssuesReferenceson #74 is empty). This PR is the only one that targetsmain, so it is the only place the keywords do anything. Without them, six fixed issues stay open after the release ships.MIN_REFUND_WINDOW)foundry.tomldrift and the floating pragma, via docs+chore: finish the refundNonce arity sweep, and point the build config at the deployment set #65The last two depend on #74 landing on
v1.4.0first — it is the one outstanding branch this release still expects.🤖 Generated with Claude Code