Skip to content

fix(script): read the Safe nonce from the real chain state after simulating - #60

Merged
MalteHerrmann merged 2 commits into
bb/check-this-handoff-document-and-implement-the-ch-thr_mm3mdzctswfrom
fix/safe-nonce-after-simulation
Oct 5, 2026
Merged

MalteHerrmann merged 2 commits into
bb/check-this-handoff-document-and-implement-the-ch-thr_mm3mdzctswfrom
fix/safe-nonce-after-simulation

Conversation

@PierrickGT

Copy link
Copy Markdown
Member

Overview

Stacked on #59. The simulation in MultiSigBatchBase._simulateBatch runs execTransaction for real on the local fork. By the time SafeNonce.next reads the Safe's local nonce, it is one higher than on chain. Every script in evm-m-extensions simulates before it proposes, so the service was queried with nonce__gte one too high and the proposal landed one slot past the free nonce. With a pending proposal at the real nonce, the query also hid it, which is the collision #59 sets out to prevent. The simulation is now wrapped in vm.snapshotState and vm.revertToState. A new MultiSigBatchBase test with a mock Safe fails without the restore and passes with it. The service parsing is split into a pure fromResponse and covered by fixture pages for the no-pending, pending and non-2xx cases. All 8 suites pass locally on Foundry 1.8.3. The snapshot path has not been exercised in a live forge script dry run yet.

Left for a follow-up: moving next into the safe-utils fork so the nonce-less safe-utils entry points get the same behaviour, and pinning the fork to a tag instead of main.

What changed

  • _simulateBatch restores the chain state after the simulation, so the Safe nonce, the owners' dealt balances and the batch's effects do not leak into the proposal.
  • SafeNonce.next takes an initialized client and no longer initializes it; each propose helper initializes the client itself.
  • SafeNonce.fromResponse holds the 2xx check and the JSON parsing, and is tested through a harness. nextFrom and its unreachable below-on-chain branch are gone.
  • _proposeScheduleBatch and _proposeCancel gain explicit-nonce overloads, and the nonce NatSpec points to them as the bypass when the service is down.
  • Every propose path logs [nonce] proposing at nonce once.

@CLAassistant

CLAassistant commented Oct 5, 2026 •

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
0 out of 2 committers have signed the CLA.

❌ PierrickGT
❌ MalteHerrmann
You have signed the CLA already but the status is still pending? Let us recheck it.

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Changes to gas cost

Generated at commit: f9e9cf70a276f712ed1c9ed7c7de65ef94a2b210, compared to commit: 934149f7619cdc7b4d2491a52cab184474aebcae

🧾 Summary (20% most significant diffs)

Contract Method Avg (+/-) %
Bytes32StringHarness toString +587 ❌ +7.83%
ERC20ExtendedHarness mint -178 ✅ -0.35%

Full diff report 👇
Contract Deployment Cost (+/-) Method Min (+/-) % Avg (+/-) % Median (+/-) % Max (+/-) % # Calls (+/-)
Bytes32StringHarness 238,480 (0) toBytes32
toString
822 (0)
698 (0)
0.00%
0.00%
846 (-1)
8,080 (+587)
-0.12%
+7.83%
856 (0)
9,651 (+1,320)
0.00%
+15.84%
856 (-2)
11,261 (0)
-0.23%
0.00%
293 (0)
293 (0)
ERC20ExtendedHandler 766,606 (0) approve
burn
mint
transfer
transferFrom
31,355 (0)
39,861 (-1,967)
381 (0)
477 (0)
488 (0)
0.00%
-4.70%
0.00%
0.00%
0.00%
45,731 (-25)
46,137 (-162)
50,336 (-644)
58,111 (+455)
54,520 (+357)
-0.05%
-0.35%
-1.26%
+0.79%
+0.66%
51,303 (0)
44,691 (0)
62,051 (-12)
60,896 (0)
60,925 (0)
0.00%
0.00%
-0.02%
0.00%
0.00%
51,879 (0)
53,627 (0)
96,839 (0)
131,421 (-48)
133,671 (-108)
0.00%
0.00%
0.00%
-0.04%
-0.08%
12,907 (+53)
12,948 (-3)
12,759 (-205)
12,928 (+258)
12,709 (-103)
ERC20ExtendedUpgradeableHarness 1,964,420 (0) approve
burn
mint
permit
receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)
receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes32,bytes32)
receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32)
transferFrom
transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)
transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes32,bytes32)
transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32)
4,725 (0)
2,789 (0)
7,154 (0)
862 (0)
41,138 (0)
40,798 (0)
9,766 (0)
2,936 (0)
41,087 (0)
40,770 (0)
9,689 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
24,528 (+115)
7,716 (+11)
46,480 (+11)
40,575 (+167)
63,408 (-87)
63,068 (-87)
60,394 (-82)
15,257 (+87)
63,356 (-88)
63,039 (-88)
60,444 (-84)
+0.47%
+0.14%
+0.02%
+0.41%
-0.14%
-0.14%
-0.14%
+0.57%
-0.14%
-0.14%
-0.14%
24,625 (0)
7,176 (0)
46,954 (0)
55,898 (0)
63,858 (+20)
63,518 (+20)
63,557 (0)
8,262 (0)
63,787 (0)
63,470 (0)
63,419 (0)
0.00%
0.00%
0.00%
0.00%
+0.03%
+0.03%
0.00%
0.00%
0.00%
0.00%
0.00%
24,625 (0)
12,776 (0)
46,954 (0)
56,167 (0)
63,858 (0)
63,518 (0)
63,577 (0)
35,516 (0)
63,807 (0)
63,490 (0)
63,439 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
0.00%
1,033 (0)
515 (0)
3,612 (0)
1,548 (0)
258 (0)
258 (0)
272 (0)
775 (0)
258 (0)
258 (0)
271 (0)
ContractHelperHarness 221,136 (0) getContractFrom 697 (0) 0.00% 750 (-3) -0.40% 768 (0) 0.00% 781 (0) 0.00% 270 (0)
ERC20ExtendedHarness 1,677,854 (0) approve
burn
mint
permit
receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)
receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes32,bytes32)
receiveWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32)
transfer
transferFrom
transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes)
transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,bytes32,bytes32)
transferWithAuthorization(address,address,uint256,uint256,uint256,bytes32,uint8,bytes32,bytes32)
26,013 (0)
24,108 (0)
28,461 (0)
23,832 (-24)
60,910 (-148)
60,055 (-148)
29,183 (0)
24,109 (+12)
24,594 (+12)
60,730 (-176)
60,007 (-176)
29,086 (0)
0.00%
0.00%
0.00%
-0.10%
-0.24%
-0.25%
0.00%
+0.05%
+0.05%
-0.29%
-0.29%
0.00%
41,123 (-17)
29,601 (-109)
50,429 (-178)
60,439 (+162)
83,396 (-80)
82,541 (-81)
80,027 (-76)
31,669 (+42)
35,419 (+4)
83,237 (-81)
82,514 (-81)
80,081 (-77)
-0.04%
-0.37%
-0.35%
+0.27%
-0.10%
-0.10%
-0.09%
+0.13%
+0.01%
-0.10%
-0.10%
-0.10%
45,973 (0)
28,711 (0)
51,257 (-12)
74,981 (+12)
83,838 (+8)
82,983 (+8)
83,200 (+8)
28,871 (0)
31,873 (0)
83,678 (+8)
82,955 (+8)
83,058 (0)
0.00%
0.00%
-0.02%
+0.02%
+0.01%
+0.01%
+0.01%
0.00%
0.00%
+0.01%
+0.01%
0.00%
46,537 (0)
34,695 (0)
68,873 (0)
75,881 (-12)
84,222 (+12)
83,367 (+12)
83,588 (+12)
51,955 (0)
57,769 (0)
84,062 (+12)
83,339 (+12)
83,450 (+12)
0.00%
0.00%
0.00%
-0.02%
+0.01%
+0.01%
+0.01%
0.00%
0.00%
+0.01%
+0.01%
+0.01%
14,719 (+51)
13,463 (-3)
15,090 (-253)
1,548 (0)
258 (0)
258 (0)
272 (0)
12,154 (+325)
11,114 (-21)
258 (0)
258 (0)
271 (0)
TransferHelperHarness 472,638 (0) safeApprove
safeTransfer
safeTransferExact
safeTransferExactFrom
safeTransferFrom
25,909 (0)
28,310 (0)
32,918 (0)
36,230 (0)
34,039 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
37,147 (+14)
40,847 (-51)
68,056 (-51)
74,137 (-57)
46,976 (-56)
+0.04%
-0.12%
-0.07%
-0.08%
-0.12%
27,300 (0)
29,679 (0)
68,274 (0)
74,383 (0)
34,839 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
48,875 (0)
53,780 (0)
80,919 (0)
87,024 (0)
59,931 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
1,028 (0)
1,028 (0)
514 (0)
514 (0)
1,028 (0)
TimelockBatchBaseHarness 4,054,819 (+177,650) addToBatch
executeBatch
getOperationBatchId
proposeCancel
180,437 (+22)
48,810 (+22)
12,190 (+22)
28,897 (+34)
+0.01%
+0.05%
+0.18%
+0.12%
180,437 (+22)
59,908 (+22)
18,119 (+22)
28,897 (+34)
+0.01%
+0.04%
+0.12%
+0.12%
180,437 (+22)
59,908 (+22)
18,119 (+22)
28,897 (+34)
+0.01%
+0.04%
+0.12%
+0.12%
180,437 (+22)
71,006 (+22)
24,048 (+22)
28,897 (+34)
+0.01%
+0.03%
+0.09%
+0.12%
3 (+1)
2 (0)
2 (0)
1 (0)
Proxy 0 (0) fallback 5,070 (0) 0.00% 37,847 (+16) +0.04% 16,681 (0) 0.00% 165,559 (0) 0.00% 19,346 (0)
SignatureCheckerHarness 618,072 (0) isValidECDSASignature(address,bytes32,bytes32,bytes32)
validateECDSASignature(address,bytes32,bytes32,bytes32)
938 (0)
861 (0)
0.00%
0.00%
4,446 (0)
4,376 (0)
0.00%
0.00%
4,450 (-16)
4,373 (-8)
-0.36%
-0.18%
4,486 (0)
4,409 (0)
0.00%
0.00%
263 (0)
520 (0)

@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

LCOV of commit a03807a during Forge Coverage #195

Summary coverage rate:
  lines......: 95.4% (476 of 499 lines)
  functions..: 95.6% (153 of 160 functions)
  branches...: no data found

Files changed coverage rate: n/a

…lating

`_simulateBatch` executes the batch for real on the local fork. That advances
the Safe nonce by one. `SafeNonce.next` then read the advanced nonce. Every
script that simulates before proposing queried the service one nonce too high
and proposed one slot past the free one. Wrap the simulation in a state
snapshot so the proposal sees the chain as it is.

Also:
- Split the response parsing out of the HTTP call as `SafeNonce.fromResponse`.
  Fixture pages now cover the status check and the JSON paths.
- Each propose helper initializes the Safe client itself. `SafeNonce.next` no
  longer does it as a side effect.
- Add explicit-nonce overloads to the timelock propose helpers. They mirror
  the batch helper and bypass the transaction service.
- Drop the unreachable "pending below on-chain" guard. The query already
  filters on `nonce__gte`.
- Log the chosen nonce the same way on every propose path.
@PierrickGT
PierrickGT force-pushed the fix/safe-nonce-after-simulation branch from 3a86c47 to 9183901 Compare October 5, 2026 17:11
…imulation

* `MultiSigBatchBase._simulateBatch` uses `vm.revertToStateAndDelete` and requires its result. A failed restore now reverts, and the snapshot is deleted.
* `TimelockBatchBase._simulateBatch` restores the chain state after the simulation. The batch's effects, e.g. a new minimum delay, do not leak into the proposal.

@MalteHerrmann MalteHerrmann left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏

@MalteHerrmann
MalteHerrmann merged commit ec34fe3 into bb/check-this-handoff-document-and-implement-the-ch-thr_mm3mdzctsw Oct 5, 2026
2 checks passed
@MalteHerrmann
MalteHerrmann deleted the fix/safe-nonce-after-simulation branch October 5, 2026 20:40
MalteHerrmann added a commit that referenced this pull request Oct 7, 2026
## What
- Add the `SafeNonce` library (`script/SafeNonce.sol`). `next` reads the pending proposals from the Safe tx service and returns one above the highest pending nonce, or the on-chain nonce if none is pending. It reverts if the service does not answer with a 2xx status. The parsing is a pure `fromResponse`, covered by fixture pages and a fuzz test.
- `MultiSigBatchBase._proposeBatch(safe, sender)` and the `SafeTimelockBatchBase` propose helpers now propose at this nonce. Each has an explicit-nonce overload, for when the service cannot be queried.
- `_simulateBatch` in `MultiSigBatchBase` and `TimelockBatchBase` now restores the chain state after the simulation. The simulation executes the batch for real on the local fork, so the Safe nonce read afterwards was one too high: with no pending proposals, the proposal landed one slot past the free nonce. A mock-Safe test fails without the restore.
- Move `lib/safe-utils` from upstream `Recon-Fuzz/safe-utils` to the `m0-platform/safe-utils` fork (`a2cc7c2`). The fork adds tx-service URLs for more chains.

## Why
The Safe on-chain nonce advances only on execution. Thus, a new proposal can use the same nonce as a pending proposal, and only one of the two can execute. This moves the fix from `evm-m-suite-deployment` to `common`, so that all repos that propose Safe transactions get it (INT-437). Includes #60.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants