Skip to content

feat: propose Safe transactions at the next free nonce - #59

Merged
MalteHerrmann merged 9 commits into
mainfrom
bb/check-this-handoff-document-and-implement-the-ch-thr_mm3mdzctsw
Oct 7, 2026
Merged

MalteHerrmann merged 9 commits into
mainfrom
bb/check-this-handoff-document-and-implement-the-ch-thr_mm3mdzctsw

Conversation

@MalteHerrmann

@MalteHerrmann MalteHerrmann commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

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.

The fork adds the tx-service URLs for chains that upstream does not
support (1329, 1868, 2288, 3946, 4114, 4153, 5888, 25363).
The Safe on-chain nonce advances only on execution. Two proposals at
one nonce compete, and only one can execute.

Add `SafeNonce.next`, which reads the pending proposals from the Safe
tx service and returns one above the highest pending nonce. Use it in
`MultiSigBatchBase._proposeBatch(safe, sender)` and in the
`SafeTimelockBatchBase` propose helpers.
- Use one name for each value in `SafeNonce.next` and `nextFrom`.
- Parse the highest pending nonce only when proposals are pending.
- Document the error parameters, and that `next` initializes the client.
- Put the local import first in `MultiSigBatchBase`.
- Track the fork's `main` branch in `.gitmodules`, as `foundry.lock` does.
@CLAassistant

CLAassistant commented Oct 2, 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 2, 2026 •

Copy link
Copy Markdown

Changes to gas cost

Generated at commit: cdaed1845d15431fb03158449cbf7cf26b451cd3, compared to commit: 81ec16e7199d01a988d573a04e7b3b2991aa7c31

🧾 Summary (20% most significant diffs)

Contract Method Avg (+/-) %
Bytes32StringHarness toString +85 ❌ +1.13%
ERC20ExtendedHarness mint
transfer
transferFrom
+214 ❌
+49 ❌
+50 ❌
+0.42%
+0.15%
+0.14%
TimelockBatchBaseHarness getOperationBatchId +22 ❌ +0.12%

Full diff report 👇
Contract Deployment Cost (+/-) Method Min (+/-) % Avg (+/-) % Median (+/-) % Max (+/-) % # Calls (+/-)
Bytes32StringHarness 238,480 (0) toString 698 (0) 0.00% 7,578 (+85) +1.13% 8,661 (+330) +3.96% 11,261 (0) 0.00% 293 (0)
ERC20ExtendedHandler 766,606 (0) approve
burn
mint
transfer
transferFrom
31,355 (0)
41,828 (0)
381 (0)
477 (0)
488 (0)
0.00%
0.00%
0.00%
0.00%
0.00%
45,760 (+2)
46,321 (+18)
51,255 (+274)
57,722 (+72)
54,235 (+77)
+0.00%
+0.04%
+0.54%
+0.12%
+0.14%
51,303 (0)
44,691 (0)
62,063 (0)
60,907 (+11)
60,925 (0)
0.00%
0.00%
0.00%
+0.02%
0.00%
51,879 (0)
53,627 (0)
96,839 (0)
131,397 (-72)
133,779 (0)
0.00%
0.00%
0.00%
-0.05%
0.00%
12,854 (0)
12,951 (0)
12,964 (0)
12,670 (0)
12,812 (0)
ERC20ExtendedHarness 1,677,854 (-2,052) 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)
61,058 (0)
60,203 (0)
29,183 (0)
24,109 (+12)
24,594 (+12)
60,906 (0)
60,183 (0)
29,086 (0)
0.00%
0.00%
0.00%
-0.10%
0.00%
0.00%
0.00%
+0.05%
+0.05%
0.00%
0.00%
0.00%
41,149 (+5)
29,726 (+13)
50,826 (+214)
60,277 (0)
83,486 (+10)
82,631 (+9)
80,112 (+9)
31,667 (+49)
35,461 (+50)
83,327 (+9)
82,604 (+9)
80,166 (+8)
+0.01%
+0.04%
+0.42%
0.00%
+0.01%
+0.01%
+0.01%
+0.15%
+0.14%
+0.01%
+0.01%
+0.01%
45,973 (0)
28,711 (0)
51,293 (+24)
74,972 (+3)
83,838 (+8)
82,983 (+8)
83,196 (+4)
28,871 (0)
31,873 (0)
83,678 (+8)
82,955 (+8)
83,058 (0)
0.00%
0.00%
+0.05%
+0.00%
+0.01%
+0.01%
+0.00%
0.00%
0.00%
+0.01%
+0.01%
0.00%
46,537 (0)
34,695 (0)
68,873 (0)
75,893 (0)
84,222 (+12)
83,367 (+12)
83,588 (+12)
51,955 (0)
57,769 (0)
84,050 (0)
83,327 (0)
83,438 (0)
0.00%
0.00%
0.00%
0.00%
+0.01%
+0.01%
+0.01%
0.00%
0.00%
0.00%
0.00%
0.00%
14,681 (+9)
13,466 (0)
15,340 (-2)
1,548 (0)
258 (0)
258 (0)
272 (0)
11,830 (+2)
11,136 (+3)
258 (0)
258 (0)
271 (0)
TimelockBatchBaseHarness 4,080,759 (+681,413) 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)
ERC20ExtendedUpgradeableHarness 1,964,420 (0) 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)
2,936 (0)
41,087 (0)
40,770 (0)
9,689 (0)
0.00%
0.00%
0.00%
0.00%
15,166 (-4)
63,445 (+1)
63,128 (+1)
60,529 (+1)
-0.03%
+0.00%
+0.00%
+0.00%
8,262 (0)
63,787 (0)
63,470 (0)
63,419 (0)
0.00%
0.00%
0.00%
0.00%
35,516 (0)
63,807 (0)
63,490 (0)
63,439 (0)
0.00%
0.00%
0.00%
0.00%
775 (0)
258 (0)
258 (0)
271 (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,375 (-1)
0.00%
-0.02%
4,450 (-16)
4,373 (-8)
-0.36%
-0.18%
4,486 (0)
4,409 (0)
0.00%
0.00%
263 (0)
520 (0)
TransferHelperHarness 472,638 (0) safeApprove
safeTransferExact
safeTransferExactFrom
safeTransferFrom
25,909 (0)
32,918 (0)
36,230 (0)
34,039 (0)
0.00%
0.00%
0.00%
0.00%
37,132 (-1)
68,106 (-1)
74,192 (-2)
47,033 (+1)
-0.00%
-0.00%
-0.00%
+0.00%
27,300 (0)
68,274 (0)
74,383 (0)
34,839 (0)
0.00%
0.00%
0.00%
0.00%
48,875 (0)
80,919 (0)
87,024 (0)
59,931 (0)
0.00%
0.00%
0.00%
0.00%
1,028 (0)
514 (0)
514 (0)
1,028 (0)
Proxy 0 (0) fallback 5,070 (0) 0.00% 37,832 (+1) +0.00% 16,681 (0) 0.00% 165,559 (0) 0.00% 19,346 (0)

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

LCOV of commit 2536ba6 during Forge Coverage #198

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The submodule gitlink must be updated to the intended fork revision, and the external-service behavior needs coverage.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds Safe transaction-service nonce discovery to prevent proposals from colliding with pending transactions.

Changes:

  • Adds SafeNonce and unit tests.
  • Uses the next available nonce for batch and timelock proposals.
  • Switches safe-utils to the m0-platform fork.
File Description
script/​SafeNonce.sol Queries pending transactions and calculates the proposal nonce.
script/​MultiSigBatchBase.sol Uses the service-derived nonce for batches.
script/​SafeTimelockBatchBase.sol Signs timelock proposals with the service-derived nonce.
test/​SafeNonce.t.sol Tests nonce-selection logic.
.gitmodules Points safe-utils at the fork.
foundry.lock Records the fork revision.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .gitmodules
Comment thread script/SafeNonce.sol
…lating (#60)

* fix(script): read the Safe nonce from the real chain state after simulating

`_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.

* fix(script): check the state restore and restore after the timelock simulation

* `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.

---------

Co-authored-by: MalteHerrmann <malte.herrmann@m0.xyz>
`fromResponse` trusted the `nonce__gte` filter of the Safe transaction
service. A service that ignores the filter returns a stale proposal below
the on-chain nonce, e.g. the loser of a past collision, and the proposal
would land at a nonce the Safe has already passed.

Clamp the result to the on-chain nonce. The guard never triggers when the
filter is honored.
`fromResponse` logs the pending count and the highest pending nonce, in
the format the `propose-timelock-migration` skill reads. The summary line
in `next` is redundant with `proposing at nonce` and goes away.
@MalteHerrmann
MalteHerrmann merged commit b1bcdce into main Oct 7, 2026
2 checks passed
@MalteHerrmann
MalteHerrmann deleted the bb/check-this-handoff-document-and-implement-the-ch-thr_mm3mdzctsw branch October 7, 2026 08:31
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.

4 participants