From 912634889561010f4b8fa0cc255e6e924bd20f31 Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Fri, 2 Oct 2026 16:42:50 +0200 Subject: [PATCH 1/9] chore: move safe-utils to the m0-platform fork The fork adds the tx-service URLs for chains that upstream does not support (1329, 1868, 2288, 3946, 4114, 4153, 5888, 25363). --- .gitmodules | 2 +- foundry.lock | 6 +++--- lib/safe-utils | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.gitmodules b/.gitmodules index f767300..97cd37f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -8,7 +8,7 @@ branch = release-v5.3 [submodule "lib/safe-utils"] path = lib/safe-utils - url = https://github.com/Recon-Fuzz/safe-utils + url = https://github.com/m0-platform/safe-utils [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/Openzeppelin/openzeppelin-contracts diff --git a/foundry.lock b/foundry.lock index 45fc813..94eadfa 100644 --- a/foundry.lock +++ b/foundry.lock @@ -18,9 +18,9 @@ } }, "lib/safe-utils": { - "tag": { - "name": "v0.0.22", - "rev": "273945a35ade03a78648a350140aace72707d5a7" + "branch": { + "name": "main", + "rev": "a2cc7c22bfce024cd3c7f856305c9e0fc48135f5" } } } \ No newline at end of file diff --git a/lib/safe-utils b/lib/safe-utils index 273945a..a2cc7c2 160000 --- a/lib/safe-utils +++ b/lib/safe-utils @@ -1 +1 @@ -Subproject commit 273945a35ade03a78648a350140aace72707d5a7 +Subproject commit a2cc7c22bfce024cd3c7f856305c9e0fc48135f5 From 5da1d61b3c74d00f06d497a82c123c4192d15ade Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Fri, 2 Oct 2026 16:42:50 +0200 Subject: [PATCH 2/9] feat: propose Safe transactions at the next free nonce 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. --- script/MultiSigBatchBase.sol | 7 ++-- script/SafeNonce.sol | 66 ++++++++++++++++++++++++++++++++ script/SafeTimelockBatchBase.sol | 16 ++++++-- test/SafeNonce.t.sol | 22 +++++++++++ 4 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 script/SafeNonce.sol create mode 100644 test/SafeNonce.t.sol diff --git a/script/MultiSigBatchBase.sol b/script/MultiSigBatchBase.sol index 0ebdb35..aef8d2c 100644 --- a/script/MultiSigBatchBase.sol +++ b/script/MultiSigBatchBase.sol @@ -6,6 +6,8 @@ import { Enum } from "../lib/safe-utils/lib/safe-smart-account/contracts/common/ import { OwnerManager } from "../lib/safe-utils/lib/safe-smart-account/contracts/base/OwnerManager.sol"; import { Safe } from "../lib/safe-utils/src/Safe.sol"; +import { SafeNonce } from "./SafeNonce.sol"; + import { console } from "../lib/forge-std/src/console.sol"; import { Script } from "../lib/forge-std/src/Script.sol"; @@ -21,10 +23,9 @@ abstract contract MultiSigBatchBase is Script { _data.push(data_); } - /// @dev Proposes the batch at the Safe's current on-chain nonce. + /// @dev Proposes the batch at the next free Safe nonce. See {SafeNonce-next}. function _proposeBatch(address safe_, address sender_) internal { - _safeMultiSig.initialize(safe_); - _propose(sender_, _safeMultiSig.getNonce()); + _propose(sender_, SafeNonce.next(_safeMultiSig, safe_)); } /// @dev Proposes the batch at an explicit nonce. The Safe's on-chain nonce only advances on execution, so diff --git a/script/SafeNonce.sol b/script/SafeNonce.sol new file mode 100644 index 0000000..beeda0a --- /dev/null +++ b/script/SafeNonce.sol @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity >=0.8.20 <0.9.0; + +import { HTTP } from "../lib/safe-utils/lib/solidity-http/src/HTTP.sol"; +import { Safe } from "../lib/safe-utils/src/Safe.sol"; + +import { console } from "../lib/forge-std/src/console.sol"; +import { Vm } from "../lib/forge-std/src/Vm.sol"; + +/// @notice Gets the next free Safe nonce from the Safe transaction service. +/// @dev The Safe on-chain nonce advances only on execution. Two proposals at one nonce compete, +/// and only one of them can execute. +library SafeNonce { + using HTTP for *; + using Safe for *; + + Vm private constant _vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + /// @notice Thrown if the Safe transaction service does not answer the pending proposals query. + error PendingProposalsQueryFailed(uint256 statusCode, string response); + + /// @notice Returns the next free nonce of `safe_`: the on-chain nonce, or one above the highest pending proposal. + function next(Safe.Client storage client_, address safe_) internal returns (uint256 nonce_) { + client_.initialize(safe_); + uint256 onChain_ = client_.getNonce(); + + HTTP.Response memory response_ = client_.instance().http.instance() + .GET( + string.concat( + client_.getApiKitUrl(block.chainid), + "/v1/safes/", + _vm.toString(safe_), + "/multisig-transactions/?executed=false&nonce__gte=", + _vm.toString(onChain_), + "&ordering=-nonce&limit=1" + ) + ).request(); + + if (response_.status < 200 || response_.status >= 300) { + revert PendingProposalsQueryFailed(response_.status, response_.data); + } + + uint256 pending_ = _vm.parseJsonUint(response_.data, ".count"); + uint256 highest_ = pending_ == 0 ? 0 : _vm.parseJsonUint(response_.data, ".results[0].nonce"); + + if (pending_ == 0) { + console.log("[nonce] Safe nonce %d, no pending proposals", onChain_); + } else { + console.log("[nonce] Safe nonce %d, %d pending proposal(s) up to nonce %d", onChain_, pending_, highest_); + } + + nonce_ = nextFrom(onChain_, pending_, highest_); + + console.log("[nonce] proposing at nonce", nonce_); + } + + /// @notice Returns `onChain_` if no proposal is pending at or above it, else the highest pending nonce plus one. + function nextFrom( + uint256 onChain_, + uint256 pendingCount_, + uint256 highestPending_ + ) internal pure returns (uint256) { + return pendingCount_ == 0 || highestPending_ < onChain_ ? onChain_ : highestPending_ + 1; + } +} diff --git a/script/SafeTimelockBatchBase.sol b/script/SafeTimelockBatchBase.sol index 974465e..477c9e2 100644 --- a/script/SafeTimelockBatchBase.sol +++ b/script/SafeTimelockBatchBase.sol @@ -1,8 +1,10 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity >=0.8.20 <0.9.0; +import { SafeNonce } from "./SafeNonce.sol"; import { TimelockBatchBase } from "./TimelockBatchBase.sol"; +import { Enum } from "../lib/safe-utils/lib/safe-smart-account/contracts/common/Enum.sol"; import { Safe } from "../lib/safe-utils/src/Safe.sol"; import { TimelockController @@ -33,8 +35,7 @@ abstract contract SafeTimelockBatchBase is TimelockBatchBase { uint256 delay = TimelockController(payable(timelock_)).getMinDelay(); bytes memory batchData = _getScheduleBatchCallData(predecessor_, salt_, delay); - _safeMultiSig.initialize(safe_); - _safeMultiSig.proposeTransaction(timelock_, batchData, sender_); + _propose(safe_, timelock_, batchData, sender_); } /// @notice Proposes to cancel the execution of a pending message that was originally scheduled through a timelock. @@ -48,7 +49,14 @@ abstract contract SafeTimelockBatchBase is TimelockBatchBase { revert OperationNotPending(id_); } - _safeMultiSig.initialize(safe_); - _safeMultiSig.proposeTransaction(timelock_, abi.encodeCall(TimelockController.cancel, id_), sender_); + _propose(safe_, timelock_, abi.encodeCall(TimelockController.cancel, id_), sender_); + } + + /// @dev Proposes a call at the next free Safe nonce. See {SafeNonce-next}. + function _propose(address safe_, address to_, bytes memory data_, address sender_) private { + uint256 nonce_ = SafeNonce.next(_safeMultiSig, safe_); + bytes memory signature_ = _safeMultiSig.sign(to_, data_, Enum.Operation.Call, sender_, nonce_, ""); + + _safeMultiSig.proposeTransactionWithSignature(to_, data_, sender_, signature_, nonce_); } } diff --git a/test/SafeNonce.t.sol b/test/SafeNonce.t.sol new file mode 100644 index 0000000..5556aab --- /dev/null +++ b/test/SafeNonce.t.sol @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity >=0.8.20 <0.9.0; + +import { Test } from "../lib/forge-std/src/Test.sol"; + +import { SafeNonce } from "../script/SafeNonce.sol"; + +contract SafeNonceTests is Test { + function test_nextFrom_noPending() external pure { + assertEq(SafeNonce.nextFrom(3, 0, 0), 3); + } + + function test_nextFrom_pendingAtOrAboveOnChain() external pure { + assertEq(SafeNonce.nextFrom(3, 2, 4), 5); + assertEq(SafeNonce.nextFrom(3, 1, 3), 4); + } + + function test_nextFrom_pendingBelowOnChain() external pure { + assertEq(SafeNonce.nextFrom(7, 1, 2), 7); + } +} From f27b602df7bb5da4e064cb3596fcdfd9f50b80bc Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Fri, 2 Oct 2026 16:43:28 +0200 Subject: [PATCH 3/9] refactor: address review comments - 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. --- .gitmodules | 1 + script/MultiSigBatchBase.sol | 4 ++-- script/SafeNonce.sol | 21 +++++++++++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.gitmodules b/.gitmodules index 97cd37f..cddebcf 100644 --- a/.gitmodules +++ b/.gitmodules @@ -9,6 +9,7 @@ [submodule "lib/safe-utils"] path = lib/safe-utils url = https://github.com/m0-platform/safe-utils + branch = main [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/Openzeppelin/openzeppelin-contracts diff --git a/script/MultiSigBatchBase.sol b/script/MultiSigBatchBase.sol index aef8d2c..06ff0ff 100644 --- a/script/MultiSigBatchBase.sol +++ b/script/MultiSigBatchBase.sol @@ -2,12 +2,12 @@ pragma solidity >=0.8.20 <0.9.0; +import { SafeNonce } from "./SafeNonce.sol"; + import { Enum } from "../lib/safe-utils/lib/safe-smart-account/contracts/common/Enum.sol"; import { OwnerManager } from "../lib/safe-utils/lib/safe-smart-account/contracts/base/OwnerManager.sol"; import { Safe } from "../lib/safe-utils/src/Safe.sol"; -import { SafeNonce } from "./SafeNonce.sol"; - import { console } from "../lib/forge-std/src/console.sol"; import { Script } from "../lib/forge-std/src/Script.sol"; diff --git a/script/SafeNonce.sol b/script/SafeNonce.sol index beeda0a..eadb564 100644 --- a/script/SafeNonce.sol +++ b/script/SafeNonce.sol @@ -18,9 +18,12 @@ library SafeNonce { Vm private constant _vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); /// @notice Thrown if the Safe transaction service does not answer the pending proposals query. - error PendingProposalsQueryFailed(uint256 statusCode, string response); + /// @param statusCode_ The HTTP status code of the response. + /// @param response_ The body of the response. + error PendingProposalsQueryFailed(uint256 statusCode_, string response_); /// @notice Returns the next free nonce of `safe_`: the on-chain nonce, or one above the highest pending proposal. + /// @dev Initializes `client_` for `safe_`. Reverts if the Safe transaction service does not answer. function next(Safe.Client storage client_, address safe_) internal returns (uint256 nonce_) { client_.initialize(safe_); uint256 onChain_ = client_.getNonce(); @@ -41,16 +44,22 @@ library SafeNonce { revert PendingProposalsQueryFailed(response_.status, response_.data); } - uint256 pending_ = _vm.parseJsonUint(response_.data, ".count"); - uint256 highest_ = pending_ == 0 ? 0 : _vm.parseJsonUint(response_.data, ".results[0].nonce"); + uint256 pendingCount_ = _vm.parseJsonUint(response_.data, ".count"); + uint256 highestPending_; - if (pending_ == 0) { + if (pendingCount_ == 0) { console.log("[nonce] Safe nonce %d, no pending proposals", onChain_); } else { - console.log("[nonce] Safe nonce %d, %d pending proposal(s) up to nonce %d", onChain_, pending_, highest_); + highestPending_ = _vm.parseJsonUint(response_.data, ".results[0].nonce"); + console.log( + "[nonce] Safe nonce %d, %d pending proposal(s) up to nonce %d", + onChain_, + pendingCount_, + highestPending_ + ); } - nonce_ = nextFrom(onChain_, pending_, highest_); + nonce_ = nextFrom(onChain_, pendingCount_, highestPending_); console.log("[nonce] proposing at nonce", nonce_); } From 64de9b4294d9556bbcbf8e7c93469901853bdca3 Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Fri, 2 Oct 2026 16:44:18 +0200 Subject: [PATCH 4/9] chore: format --- script/SafeNonce.sol | 8 ++++++-- script/SafeTimelockBatchBase.sol | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/script/SafeNonce.sol b/script/SafeNonce.sol index eadb564..f1f033a 100644 --- a/script/SafeNonce.sol +++ b/script/SafeNonce.sol @@ -28,7 +28,10 @@ library SafeNonce { client_.initialize(safe_); uint256 onChain_ = client_.getNonce(); - HTTP.Response memory response_ = client_.instance().http.instance() + HTTP.Response memory response_ = client_ + .instance() + .http + .instance() .GET( string.concat( client_.getApiKitUrl(block.chainid), @@ -38,7 +41,8 @@ library SafeNonce { _vm.toString(onChain_), "&ordering=-nonce&limit=1" ) - ).request(); + ) + .request(); if (response_.status < 200 || response_.status >= 300) { revert PendingProposalsQueryFailed(response_.status, response_.data); diff --git a/script/SafeTimelockBatchBase.sol b/script/SafeTimelockBatchBase.sol index 477c9e2..200b72f 100644 --- a/script/SafeTimelockBatchBase.sol +++ b/script/SafeTimelockBatchBase.sol @@ -6,9 +6,7 @@ import { TimelockBatchBase } from "./TimelockBatchBase.sol"; import { Enum } from "../lib/safe-utils/lib/safe-smart-account/contracts/common/Enum.sol"; import { Safe } from "../lib/safe-utils/src/Safe.sol"; -import { - TimelockController -} from "../lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/governance/TimelockController.sol"; +import { TimelockController } from "../lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/governance/TimelockController.sol"; abstract contract SafeTimelockBatchBase is TimelockBatchBase { using Safe for *; From 97dbfc3bfa326593053c3e8e6d9c8f0a26d4b6ca Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Fri, 2 Oct 2026 17:00:22 +0200 Subject: [PATCH 5/9] refactor: log the Safe nonce once on each propose path --- script/MultiSigBatchBase.sol | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/MultiSigBatchBase.sol b/script/MultiSigBatchBase.sol index 06ff0ff..9f3f8ae 100644 --- a/script/MultiSigBatchBase.sol +++ b/script/MultiSigBatchBase.sol @@ -31,6 +31,8 @@ abstract contract MultiSigBatchBase is Script { /// @dev Proposes the batch at an explicit nonce. The Safe's on-chain nonce only advances on execution, so /// proposing at it can collide with already queued proposals instead of queueing behind them. function _proposeBatch(address safe_, address sender_, uint256 nonce_) internal { + console.log("Safe nonce:", nonce_); + _safeMultiSig.initialize(safe_); _propose(sender_, nonce_); } @@ -51,8 +53,6 @@ abstract contract MultiSigBatchBase is Script { } function _propose(address sender_, uint256 nonce_) private { - console.log("Safe nonce:", nonce_); - (address to_, bytes memory data_) = _safeMultiSig.getProposeTransactionsTargetAndData(_targets, _data); // NOTE: Batches are executed via DelegateCall to preserve `msg.sender` across the sub-calls, and the signed From 934149f7619cdc7b4d2491a52cab184474aebcae Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Fri, 2 Oct 2026 17:00:49 +0200 Subject: [PATCH 6/9] refactor: rename SafeTimelockBatchBase._propose to _proposeToTimelock --- script/SafeTimelockBatchBase.sol | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/script/SafeTimelockBatchBase.sol b/script/SafeTimelockBatchBase.sol index 200b72f..329cffe 100644 --- a/script/SafeTimelockBatchBase.sol +++ b/script/SafeTimelockBatchBase.sol @@ -33,7 +33,7 @@ abstract contract SafeTimelockBatchBase is TimelockBatchBase { uint256 delay = TimelockController(payable(timelock_)).getMinDelay(); bytes memory batchData = _getScheduleBatchCallData(predecessor_, salt_, delay); - _propose(safe_, timelock_, batchData, sender_); + _proposeToTimelock(safe_, timelock_, batchData, sender_); } /// @notice Proposes to cancel the execution of a pending message that was originally scheduled through a timelock. @@ -47,14 +47,14 @@ abstract contract SafeTimelockBatchBase is TimelockBatchBase { revert OperationNotPending(id_); } - _propose(safe_, timelock_, abi.encodeCall(TimelockController.cancel, id_), sender_); + _proposeToTimelock(safe_, timelock_, abi.encodeCall(TimelockController.cancel, id_), sender_); } - /// @dev Proposes a call at the next free Safe nonce. See {SafeNonce-next}. - function _propose(address safe_, address to_, bytes memory data_, address sender_) private { + /// @dev Proposes a call to the timelock at the next free Safe nonce. See {SafeNonce-next}. + function _proposeToTimelock(address safe_, address timelock_, bytes memory data_, address sender_) private { uint256 nonce_ = SafeNonce.next(_safeMultiSig, safe_); - bytes memory signature_ = _safeMultiSig.sign(to_, data_, Enum.Operation.Call, sender_, nonce_, ""); + bytes memory signature_ = _safeMultiSig.sign(timelock_, data_, Enum.Operation.Call, sender_, nonce_, ""); - _safeMultiSig.proposeTransactionWithSignature(to_, data_, sender_, signature_, nonce_); + _safeMultiSig.proposeTransactionWithSignature(timelock_, data_, sender_, signature_, nonce_); } } From ec34fe3d82647776cbcfeb3b23a95a21b3315242 Mon Sep 17 00:00:00 2001 From: Pierrick Turelier Date: Mon, 5 Oct 2026 15:40:06 -0500 Subject: [PATCH 7/9] fix(script): read the Safe nonce from the real chain state after simulating (#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 --- script/MultiSigBatchBase.sol | 37 +++++--- script/SafeNonce.sol | 87 +++++++++---------- script/SafeTimelockBatchBase.sol | 110 +++++++++++++++++++----- script/TimelockBatchBase.sol | 6 ++ test/MultiSigBatchBase.t.sol | 38 ++++++++ test/SafeNonce.t.sol | 64 ++++++++++++-- test/TimelockBatchBase.t.sol | 10 +++ test/utils/MockSafe.sol | 61 +++++++++++++ test/utils/MultiSigBatchBaseHarness.sol | 14 +++ test/utils/SafeNonceHarness.sol | 12 +++ test/utils/TimelockBatchBaseHarness.sol | 4 + 11 files changed, 360 insertions(+), 83 deletions(-) create mode 100644 test/MultiSigBatchBase.t.sol create mode 100644 test/utils/MockSafe.sol create mode 100644 test/utils/MultiSigBatchBaseHarness.sol create mode 100644 test/utils/SafeNonceHarness.sol diff --git a/script/MultiSigBatchBase.sol b/script/MultiSigBatchBase.sol index 9f3f8ae..2474f37 100644 --- a/script/MultiSigBatchBase.sol +++ b/script/MultiSigBatchBase.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: UNLICENSED - pragma solidity >=0.8.20 <0.9.0; import { SafeNonce } from "./SafeNonce.sol"; @@ -23,36 +22,54 @@ abstract contract MultiSigBatchBase is Script { _data.push(data_); } - /// @dev Proposes the batch at the next free Safe nonce. See {SafeNonce-next}. + /// @dev Proposes the batch at the next free Safe nonce. See {SafeNonce-next}. + /// @param safe_ The Safe to propose to. + /// @param sender_ The owner signing the proposal. function _proposeBatch(address safe_, address sender_) internal { - _propose(sender_, SafeNonce.next(_safeMultiSig, safe_)); + _safeMultiSig.initialize(safe_); + _propose(sender_, SafeNonce.next(_safeMultiSig)); } - /// @dev Proposes the batch at an explicit nonce. The Safe's on-chain nonce only advances on execution, so - /// proposing at it can collide with already queued proposals instead of queueing behind them. + /// @dev Proposes the batch at an explicit nonce, for when the Safe transaction service cannot be queried or + /// the batch must queue at a chosen position. + /// @param safe_ The Safe to propose to. + /// @param sender_ The owner signing the proposal. + /// @param nonce_ The Safe nonce to propose at. function _proposeBatch(address safe_, address sender_, uint256 nonce_) internal { - console.log("Safe nonce:", nonce_); - _safeMultiSig.initialize(safe_); _propose(sender_, nonce_); } - /// @dev Simulates the batch through the Safe itself, using synthetic owner approvals, so that the MultiSend - /// encoding, the threshold check and any guard or fallback handler are exercised too. + /// @dev Simulates the batch through the Safe itself, using synthetic owner approvals, so that the MultiSend + /// encoding, the threshold check and any guard or fallback handler are exercised too. + /// @param safe_ The Safe to simulate through. function _simulateBatch(address safe_) internal { _safeMultiSig.initialize(safe_); address[] memory owners_ = OwnerManager(safe_).getOwners(); + uint256 snapshot_ = vm.snapshotState(); + // NOTE: `isolate` mode runs each top-level call as its own transaction, requiring the signer to pay for gas. for (uint256 i = 0; i < owners_.length; i++) { vm.deal(owners_[i], owners_[i].balance + 1 ether); } - require(_safeMultiSig.simulateTransactionsMultiSigNoSign(_targets, _data, owners_), "Simulation failed"); + bool success_ = _safeMultiSig.simulateTransactionsMultiSigNoSign(_targets, _data, owners_); + + // NOTE: The simulation executes the batch for real on the local fork, which advances the Safe nonce and + // applies the batch. Restoring the state keeps the nonce the proposal is later signed at correct. + require(vm.revertToStateAndDelete(snapshot_), "State restore failed"); + + require(success_, "Simulation failed"); } + /// @dev Signs and proposes the batch at `nonce_` through the initialized Safe client. + /// @param sender_ The owner signing the proposal. + /// @param nonce_ The Safe nonce to propose at. function _propose(address sender_, uint256 nonce_) private { + console.log("[nonce] proposing at nonce", nonce_); + (address to_, bytes memory data_) = _safeMultiSig.getProposeTransactionsTargetAndData(_targets, _data); // NOTE: Batches are executed via DelegateCall to preserve `msg.sender` across the sub-calls, and the signed diff --git a/script/SafeNonce.sol b/script/SafeNonce.sol index f1f033a..215ed02 100644 --- a/script/SafeNonce.sol +++ b/script/SafeNonce.sol @@ -1,5 +1,4 @@ // SPDX-License-Identifier: UNLICENSED - pragma solidity >=0.8.20 <0.9.0; import { HTTP } from "../lib/safe-utils/lib/solidity-http/src/HTTP.sol"; @@ -8,9 +7,8 @@ import { Safe } from "../lib/safe-utils/src/Safe.sol"; import { console } from "../lib/forge-std/src/console.sol"; import { Vm } from "../lib/forge-std/src/Vm.sol"; -/// @notice Gets the next free Safe nonce from the Safe transaction service. -/// @dev The Safe on-chain nonce advances only on execution. Two proposals at one nonce compete, -/// and only one of them can execute. +/// @title Next free Safe nonce, read from the Safe transaction service. +/// @author M0 Labs library SafeNonce { using HTTP for *; using Safe for *; @@ -18,62 +16,63 @@ library SafeNonce { Vm private constant _vm = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); /// @notice Thrown if the Safe transaction service does not answer the pending proposals query. - /// @param statusCode_ The HTTP status code of the response. - /// @param response_ The body of the response. + /// @param statusCode_ The HTTP status code of the response. + /// @param response_ The body of the response. error PendingProposalsQueryFailed(uint256 statusCode_, string response_); - /// @notice Returns the next free nonce of `safe_`: the on-chain nonce, or one above the highest pending proposal. - /// @dev Initializes `client_` for `safe_`. Reverts if the Safe transaction service does not answer. - function next(Safe.Client storage client_, address safe_) internal returns (uint256 nonce_) { - client_.initialize(safe_); + /// @notice Returns the next free nonce of the Safe `client_` is initialized for. + /// @dev Reverts if the Safe transaction service does not answer. The propose helpers accept an explicit + /// nonce to bypass the service. + /// @param client_ The Safe client, initialized for the Safe. + /// @return The on-chain nonce, or one above the highest pending proposal. + function next(Safe.Client storage client_) internal returns (uint256) { uint256 onChain_ = client_.getNonce(); HTTP.Response memory response_ = client_ .instance() .http .instance() - .GET( - string.concat( - client_.getApiKitUrl(block.chainid), - "/v1/safes/", - _vm.toString(safe_), - "/multisig-transactions/?executed=false&nonce__gte=", - _vm.toString(onChain_), - "&ordering=-nonce&limit=1" - ) - ) + .GET(_getPendingProposalsUrl(client_, onChain_)) .request(); - if (response_.status < 200 || response_.status >= 300) { - revert PendingProposalsQueryFailed(response_.status, response_.data); - } + uint256 nonce_ = fromResponse(onChain_, response_); - uint256 pendingCount_ = _vm.parseJsonUint(response_.data, ".count"); - uint256 highestPending_; + console.log("[nonce] Safe on-chain nonce %d, next free nonce %d", onChain_, nonce_); - if (pendingCount_ == 0) { - console.log("[nonce] Safe nonce %d, no pending proposals", onChain_); - } else { - highestPending_ = _vm.parseJsonUint(response_.data, ".results[0].nonce"); - console.log( - "[nonce] Safe nonce %d, %d pending proposal(s) up to nonce %d", - onChain_, - pendingCount_, - highestPending_ - ); + return nonce_; + } + + /// @notice Returns the next free nonce from a page of pending proposals at or above `onChain_`. + /// @dev Reverts if `response_` is not a 2xx answer. + /// @param onChain_ The Safe's on-chain nonce. + /// @param response_ The transaction service page of pending proposals, highest nonce first. + /// @return `onChain_` if no proposal is pending, else the highest pending nonce plus one. + function fromResponse(uint256 onChain_, HTTP.Response memory response_) internal pure returns (uint256) { + if (response_.status < 200 || response_.status >= 300) { + revert PendingProposalsQueryFailed(response_.status, response_.data); } - nonce_ = nextFrom(onChain_, pendingCount_, highestPending_); + if (_vm.parseJsonUint(response_.data, ".count") == 0) return onChain_; - console.log("[nonce] proposing at nonce", nonce_); + return _vm.parseJsonUint(response_.data, ".results[0].nonce") + 1; } - /// @notice Returns `onChain_` if no proposal is pending at or above it, else the highest pending nonce plus one. - function nextFrom( - uint256 onChain_, - uint256 pendingCount_, - uint256 highestPending_ - ) internal pure returns (uint256) { - return pendingCount_ == 0 || highestPending_ < onChain_ ? onChain_ : highestPending_ + 1; + /// @dev Builds the transaction service query for the pending proposals at or above `onChain_`. + /// @param client_ The Safe client, initialized for the Safe. + /// @param onChain_ The Safe's on-chain nonce. + /// @return The URL, ordered by nonce descending and limited to the first result. + function _getPendingProposalsUrl( + Safe.Client storage client_, + uint256 onChain_ + ) private view returns (string memory) { + return + string.concat( + client_.getApiKitUrl(block.chainid), + "/v1/safes/", + _vm.toString(client_.instance().safe), + "/multisig-transactions/?executed=false&nonce__gte=", + _vm.toString(onChain_), + "&ordering=-nonce&limit=1" + ); } } diff --git a/script/SafeTimelockBatchBase.sol b/script/SafeTimelockBatchBase.sol index 329cffe..b1fc13f 100644 --- a/script/SafeTimelockBatchBase.sol +++ b/script/SafeTimelockBatchBase.sol @@ -8,21 +8,24 @@ import { Enum } from "../lib/safe-utils/lib/safe-smart-account/contracts/common/ import { Safe } from "../lib/safe-utils/src/Safe.sol"; import { TimelockController } from "../lib/openzeppelin-contracts-upgradeable/lib/openzeppelin-contracts/contracts/governance/TimelockController.sol"; +import { console } from "../lib/forge-std/src/console.sol"; + abstract contract SafeTimelockBatchBase is TimelockBatchBase { using Safe for *; Safe.Client internal _safeMultiSig; /// @notice Thrown in case a transaction that's supposed to be cancelled is not pending. - /// @param id_ The identifier of the transaction. + /// @param id_ The identifier of the transaction. error OperationNotPending(bytes32 id_); /// @notice Proposes to schedule a batch of transactions to a timelock contract. - /// @param safe_ The address of the Safe multisig to propose to. - /// @param timelock_ The address of the timelock. - /// @param sender_ The sender's address. - /// @param predecessor_ The predecessor transaction, if any. - /// @param salt_ The salt to build the transaction with, if any. + /// @dev Proposes at the next free Safe nonce. See {SafeNonce-next}. + /// @param safe_ The address of the Safe multisig to propose to. + /// @param timelock_ The address of the timelock. + /// @param sender_ The sender's address. + /// @param predecessor_ The predecessor transaction, if any. + /// @param salt_ The salt to build the transaction with, if any. function _proposeScheduleBatch( address safe_, address timelock_, @@ -30,31 +33,96 @@ abstract contract SafeTimelockBatchBase is TimelockBatchBase { bytes32 predecessor_, bytes32 salt_ ) internal { - uint256 delay = TimelockController(payable(timelock_)).getMinDelay(); - bytes memory batchData = _getScheduleBatchCallData(predecessor_, salt_, delay); + bytes memory data_ = _getScheduleBatchData(timelock_, predecessor_, salt_); + + _safeMultiSig.initialize(safe_); + _proposeToTimelock(timelock_, data_, sender_, SafeNonce.next(_safeMultiSig)); + } + + /// @notice Proposes to schedule a batch of transactions to a timelock contract at an explicit Safe nonce. + /// @dev For when the Safe transaction service cannot be queried or the proposal must queue at a chosen position. + /// @param safe_ The address of the Safe multisig to propose to. + /// @param timelock_ The address of the timelock. + /// @param sender_ The sender's address. + /// @param predecessor_ The predecessor transaction, if any. + /// @param salt_ The salt to build the transaction with, if any. + /// @param nonce_ The Safe nonce to propose at. + function _proposeScheduleBatch( + address safe_, + address timelock_, + address sender_, + bytes32 predecessor_, + bytes32 salt_, + uint256 nonce_ + ) internal { + bytes memory data_ = _getScheduleBatchData(timelock_, predecessor_, salt_); - _proposeToTimelock(safe_, timelock_, batchData, sender_); + _safeMultiSig.initialize(safe_); + _proposeToTimelock(timelock_, data_, sender_, nonce_); } /// @notice Proposes to cancel the execution of a pending message that was originally scheduled through a timelock. - /// @param safe_ The address of the Safe multisig to propose the transaction to. - /// @param timelock_ The address of the timelock. - /// @param sender_ The sender's address. - /// @param id_ The id of the scheduled transaction to cancel. + /// @dev Proposes at the next free Safe nonce. See {SafeNonce-next}. + /// @param safe_ The address of the Safe multisig to propose the transaction to. + /// @param timelock_ The address of the timelock. + /// @param sender_ The sender's address. + /// @param id_ The id of the scheduled transaction to cancel. function _proposeCancel(address safe_, address timelock_, address sender_, bytes32 id_) internal { - TimelockController timelock = TimelockController(payable(timelock_)); - if (!timelock.isOperationPending(id_)) { - revert OperationNotPending(id_); - } + bytes memory data_ = _getCancelData(timelock_, id_); - _proposeToTimelock(safe_, timelock_, abi.encodeCall(TimelockController.cancel, id_), sender_); + _safeMultiSig.initialize(safe_); + _proposeToTimelock(timelock_, data_, sender_, SafeNonce.next(_safeMultiSig)); } - /// @dev Proposes a call to the timelock at the next free Safe nonce. See {SafeNonce-next}. - function _proposeToTimelock(address safe_, address timelock_, bytes memory data_, address sender_) private { - uint256 nonce_ = SafeNonce.next(_safeMultiSig, safe_); + /// @notice Proposes to cancel a pending timelock operation at an explicit Safe nonce. + /// @dev For when the Safe transaction service cannot be queried or the proposal must queue at a chosen position. + /// @param safe_ The address of the Safe multisig to propose the transaction to. + /// @param timelock_ The address of the timelock. + /// @param sender_ The sender's address. + /// @param id_ The id of the scheduled transaction to cancel. + /// @param nonce_ The Safe nonce to propose at. + function _proposeCancel(address safe_, address timelock_, address sender_, bytes32 id_, uint256 nonce_) internal { + bytes memory data_ = _getCancelData(timelock_, id_); + + _safeMultiSig.initialize(safe_); + _proposeToTimelock(timelock_, data_, sender_, nonce_); + } + + /// @dev Signs and proposes a call to the timelock at `nonce_` through the initialized Safe client. + /// @param timelock_ The address of the timelock. + /// @param data_ The call data for the timelock. + /// @param sender_ The sender's address. + /// @param nonce_ The Safe nonce to propose at. + function _proposeToTimelock(address timelock_, bytes memory data_, address sender_, uint256 nonce_) private { + console.log("[nonce] proposing at nonce", nonce_); + bytes memory signature_ = _safeMultiSig.sign(timelock_, data_, Enum.Operation.Call, sender_, nonce_, ""); _safeMultiSig.proposeTransactionWithSignature(timelock_, data_, sender_, signature_, nonce_); } + + /// @dev Builds the `scheduleBatch` call for the batch, at the timelock's minimum delay. + /// @param timelock_ The address of the timelock. + /// @param predecessor_ The predecessor transaction, if any. + /// @param salt_ The salt to build the transaction with, if any. + /// @return The call data for the timelock. + function _getScheduleBatchData( + address timelock_, + bytes32 predecessor_, + bytes32 salt_ + ) private view returns (bytes memory) { + uint256 delay_ = TimelockController(payable(timelock_)).getMinDelay(); + + return _getScheduleBatchCallData(predecessor_, salt_, delay_); + } + + /// @dev Builds the `cancel` call for a pending operation. + /// @param timelock_ The address of the timelock. + /// @param id_ The id of the scheduled transaction to cancel. + /// @return The call data for the timelock. + function _getCancelData(address timelock_, bytes32 id_) private view returns (bytes memory) { + if (!TimelockController(payable(timelock_)).isOperationPending(id_)) revert OperationNotPending(id_); + + return abi.encodeCall(TimelockController.cancel, id_); + } } diff --git a/script/TimelockBatchBase.sol b/script/TimelockBatchBase.sol index 52707b9..079c79e 100644 --- a/script/TimelockBatchBase.sol +++ b/script/TimelockBatchBase.sol @@ -87,6 +87,8 @@ abstract contract TimelockBatchBase is Script { /// @notice Simulates the timelock execution based on the accumulated call stack. /// @param timelock_ The address of the timelock contract to execute from. function _simulateBatch(address timelock_) internal { + uint256 snapshot_ = vm.snapshotState(); + vm.startPrank(timelock_); for (uint256 i = 0; i < _timelockTargets.length; i++) { @@ -95,5 +97,9 @@ abstract contract TimelockBatchBase is Script { } vm.stopPrank(); + + // NOTE: The simulation executes the batch for real on the local fork. Restoring the state keeps the + // proposal built from the current chain state, e.g. the timelock's minimum delay. + require(vm.revertToStateAndDelete(snapshot_), "State restore failed"); } } diff --git a/test/MultiSigBatchBase.t.sol b/test/MultiSigBatchBase.t.sol new file mode 100644 index 0000000..469cf29 --- /dev/null +++ b/test/MultiSigBatchBase.t.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.20 <0.9.0; + +import { Test } from "../lib/forge-std/src/Test.sol"; + +import { MockSafe } from "./utils/MockSafe.sol"; +import { MultiSigBatchBaseHarness } from "./utils/MultiSigBatchBaseHarness.sol"; + +contract MultiSigBatchBaseTests is Test { + MultiSigBatchBaseHarness public harness; + MockSafe public safe; + + address public owner = makeAddr("owner"); + address public target = makeAddr("target"); + + function setUp() external { + // NOTE: The MultiSend address is resolved per chain, and the mock Safe never calls it. + vm.chainId(1); + + harness = new MultiSigBatchBaseHarness(); + + address[] memory owners_ = new address[](1); + owners_[0] = owner; + + safe = new MockSafe(owners_); + } + + /* ============ _simulateBatch ============ */ + + function test_simulateBatch_leavesStateUntouched() external { + harness.addToBatch(target, ""); + + harness.simulateBatch(address(safe)); + + assertEq(safe.nonce(), 0); + assertEq(owner.balance, 0); + } +} diff --git a/test/SafeNonce.t.sol b/test/SafeNonce.t.sol index 5556aab..97295cf 100644 --- a/test/SafeNonce.t.sol +++ b/test/SafeNonce.t.sol @@ -1,22 +1,70 @@ // SPDX-License-Identifier: UNLICENSED - pragma solidity >=0.8.20 <0.9.0; +import { HTTP } from "../lib/safe-utils/lib/solidity-http/src/HTTP.sol"; + import { Test } from "../lib/forge-std/src/Test.sol"; import { SafeNonce } from "../script/SafeNonce.sol"; +import { SafeNonceHarness } from "./utils/SafeNonceHarness.sol"; + contract SafeNonceTests is Test { - function test_nextFrom_noPending() external pure { - assertEq(SafeNonce.nextFrom(3, 0, 0), 3); + SafeNonceHarness public harness; + + function setUp() external { + harness = new SafeNonceHarness(); + } + + /* ============ fromResponse ============ */ + + function test_fromResponse_statusBelow2xx() external { + vm.expectRevert(abi.encodeWithSelector(SafeNonce.PendingProposalsQueryFailed.selector, 199, "early")); + harness.fromResponse(3, HTTP.Response({ status: 199, data: "early" })); + } + + function test_fromResponse_statusAbove2xx() external { + vm.expectRevert(abi.encodeWithSelector(SafeNonce.PendingProposalsQueryFailed.selector, 300, "moved")); + harness.fromResponse(3, HTTP.Response({ status: 300, data: "moved" })); } - function test_nextFrom_pendingAtOrAboveOnChain() external pure { - assertEq(SafeNonce.nextFrom(3, 2, 4), 5); - assertEq(SafeNonce.nextFrom(3, 1, 3), 4); + function test_fromResponse_noPending() external view { + assertEq(harness.fromResponse(3, HTTP.Response({ status: 200, data: _pending(0, 0) })), 3); } - function test_nextFrom_pendingBelowOnChain() external pure { - assertEq(SafeNonce.nextFrom(7, 1, 2), 7); + function test_fromResponse_pendingAboveOnChain() external view { + assertEq(harness.fromResponse(3, HTTP.Response({ status: 299, data: _pending(2, 4) })), 5); + } + + function test_fromResponse_pendingAtOnChain() external view { + assertEq(harness.fromResponse(3, HTTP.Response({ status: 200, data: _pending(1, 3) })), 4); + } + + function testFuzz_fromResponse_pending(uint256 onChain_, uint256 count_, uint256 highest_) external view { + onChain_ = bound(onChain_, 0, type(uint128).max); + count_ = bound(count_, 1, type(uint128).max); + highest_ = bound(highest_, onChain_, type(uint128).max); + + assertEq( + harness.fromResponse(onChain_, HTTP.Response({ status: 200, data: _pending(count_, highest_) })), + highest_ + 1 + ); + } + + /// @dev Builds a transaction service page like the real `multisig-transactions` listing, ordered by nonce descending. + /// @param count_ The total number of pending proposals. + /// @param highest_ The nonce of the first (highest) result, ignored when `count_` is zero. + /// @return The JSON body. + function _pending(uint256 count_, uint256 highest_) internal pure returns (string memory) { + string memory results_ = count_ == 0 + ? "[]" + : string.concat( + '[{"safe":"0x0000000000000000000000000000000000000001","nonce":', + vm.toString(highest_), + "}]" + ); + + return + string.concat('{"count":', vm.toString(count_), ',"next":null,"previous":null,"results":', results_, "}"); } } diff --git a/test/TimelockBatchBase.t.sol b/test/TimelockBatchBase.t.sol index 82e8fb2..a86410e 100644 --- a/test/TimelockBatchBase.t.sol +++ b/test/TimelockBatchBase.t.sol @@ -68,6 +68,16 @@ contract TimelockBatchBaseTests is Test { _harness.proposeCancel(makeAddr("safe"), address(_timelock), address(this), id); } + function test_simulateBatch_leavesStateUntouched() external { + (address[] memory targets, , bytes[] memory payloads) = _updateDelayBatch(2 days); + + _harness.addToBatch(targets[0], payloads[0]); + + _harness.simulateBatch(address(_timelock)); + + assertEq(_timelock.getMinDelay(), _MIN_DELAY); + } + function _updateDelayBatch( uint256 newDelay_ ) internal view returns (address[] memory targets_, uint256[] memory values_, bytes[] memory payloads_) { diff --git a/test/utils/MockSafe.sol b/test/utils/MockSafe.sol new file mode 100644 index 0000000..8e654e3 --- /dev/null +++ b/test/utils/MockSafe.sol @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.20 <0.9.0; + +import { Enum } from "../../lib/safe-utils/lib/safe-smart-account/contracts/common/Enum.sol"; + +contract MockSafe { + uint256 public nonce; + + address[] internal _owners; + + constructor(address[] memory owners_) { + _owners = owners_; + } + + function execTransaction( + address, + uint256, + bytes calldata, + Enum.Operation, + uint256, + uint256, + uint256, + address, + address payable, + bytes memory + ) external payable returns (bool) { + nonce++; + return true; + } + + function getOwners() external view returns (address[] memory) { + return _owners; + } + + function isOwner(address account_) external view returns (bool) { + for (uint256 i; i < _owners.length; ++i) { + if (_owners[i] == account_) return true; + } + + return false; + } + + function getThreshold() external pure returns (uint256) { + return 1; + } + + function getTransactionHash( + address to_, + uint256 value_, + bytes calldata data_, + Enum.Operation operation_, + uint256, + uint256, + uint256, + address, + address, + uint256 nonce_ + ) external pure returns (bytes32) { + return keccak256(abi.encode(to_, value_, data_, operation_, nonce_)); + } +} diff --git a/test/utils/MultiSigBatchBaseHarness.sol b/test/utils/MultiSigBatchBaseHarness.sol new file mode 100644 index 0000000..358aa4a --- /dev/null +++ b/test/utils/MultiSigBatchBaseHarness.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.20 <0.9.0; + +import { MultiSigBatchBase } from "../../script/MultiSigBatchBase.sol"; + +contract MultiSigBatchBaseHarness is MultiSigBatchBase { + function addToBatch(address target_, bytes memory data_) external { + _addToBatch(target_, data_); + } + + function simulateBatch(address safe_) external { + _simulateBatch(safe_); + } +} diff --git a/test/utils/SafeNonceHarness.sol b/test/utils/SafeNonceHarness.sol new file mode 100644 index 0000000..fcded29 --- /dev/null +++ b/test/utils/SafeNonceHarness.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.20 <0.9.0; + +import { HTTP } from "../../lib/safe-utils/lib/solidity-http/src/HTTP.sol"; + +import { SafeNonce } from "../../script/SafeNonce.sol"; + +contract SafeNonceHarness { + function fromResponse(uint256 onChain_, HTTP.Response memory response_) external pure returns (uint256) { + return SafeNonce.fromResponse(onChain_, response_); + } +} diff --git a/test/utils/TimelockBatchBaseHarness.sol b/test/utils/TimelockBatchBaseHarness.sol index 91ee22a..8ba46e4 100644 --- a/test/utils/TimelockBatchBaseHarness.sol +++ b/test/utils/TimelockBatchBaseHarness.sol @@ -16,6 +16,10 @@ contract TimelockBatchBaseHarness is SafeTimelockBatchBase { return _getOperationBatchId(target_, predecessor_, salt_); } + function simulateBatch(address timelock_) external { + _simulateBatch(timelock_); + } + function proposeCancel(address safe_, address timelock_, address sender_, bytes32 id_) external { _proposeCancel(safe_, timelock_, sender_, id_); } From 84f0188e4cb32db8dad2c2a2d44917a89b0e6322 Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Wed, 7 Oct 2026 10:14:07 +0200 Subject: [PATCH 8/9] fix: never propose below the on-chain nonce `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. --- script/SafeNonce.sol | 8 ++++++-- test/SafeNonce.t.sol | 11 +++++++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/script/SafeNonce.sol b/script/SafeNonce.sol index 215ed02..532416b 100644 --- a/script/SafeNonce.sol +++ b/script/SafeNonce.sol @@ -46,7 +46,7 @@ library SafeNonce { /// @dev Reverts if `response_` is not a 2xx answer. /// @param onChain_ The Safe's on-chain nonce. /// @param response_ The transaction service page of pending proposals, highest nonce first. - /// @return `onChain_` if no proposal is pending, else the highest pending nonce plus one. + /// @return `onChain_` if no proposal is pending at or above it, else the highest pending nonce plus one. function fromResponse(uint256 onChain_, HTTP.Response memory response_) internal pure returns (uint256) { if (response_.status < 200 || response_.status >= 300) { revert PendingProposalsQueryFailed(response_.status, response_.data); @@ -54,7 +54,11 @@ library SafeNonce { if (_vm.parseJsonUint(response_.data, ".count") == 0) return onChain_; - return _vm.parseJsonUint(response_.data, ".results[0].nonce") + 1; + uint256 next_ = _vm.parseJsonUint(response_.data, ".results[0].nonce") + 1; + + // NOTE: A service that ignores `nonce__gte` can return a stale proposal below the on-chain nonce, e.g. the + // loser of a past collision. Never propose below the on-chain nonce. + return next_ > onChain_ ? next_ : onChain_; } /// @dev Builds the transaction service query for the pending proposals at or above `onChain_`. diff --git a/test/SafeNonce.t.sol b/test/SafeNonce.t.sol index 97295cf..848c88e 100644 --- a/test/SafeNonce.t.sol +++ b/test/SafeNonce.t.sol @@ -40,14 +40,21 @@ contract SafeNonceTests is Test { assertEq(harness.fromResponse(3, HTTP.Response({ status: 200, data: _pending(1, 3) })), 4); } + /// @dev A stale proposal below the on-chain nonce (the loser of a past collision) must not lower the nonce. + function test_fromResponse_pendingBelowOnChain() external view { + assertEq(harness.fromResponse(7, HTTP.Response({ status: 200, data: _pending(1, 3) })), 7); + } + function testFuzz_fromResponse_pending(uint256 onChain_, uint256 count_, uint256 highest_) external view { onChain_ = bound(onChain_, 0, type(uint128).max); count_ = bound(count_, 1, type(uint128).max); - highest_ = bound(highest_, onChain_, type(uint128).max); + highest_ = bound(highest_, 0, type(uint128).max); + + uint256 expected_ = highest_ + 1 > onChain_ ? highest_ + 1 : onChain_; assertEq( harness.fromResponse(onChain_, HTTP.Response({ status: 200, data: _pending(count_, highest_) })), - highest_ + 1 + expected_ ); } From 2536ba61312c443c462b01e819b43393970593f3 Mon Sep 17 00:00:00 2001 From: MalteHerrmann Date: Wed, 7 Oct 2026 10:28:12 +0200 Subject: [PATCH 9/9] feat: log the pending proposals in the [nonce] lines `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. --- script/SafeNonce.sol | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/script/SafeNonce.sol b/script/SafeNonce.sol index 532416b..7a28b27 100644 --- a/script/SafeNonce.sol +++ b/script/SafeNonce.sol @@ -35,15 +35,11 @@ library SafeNonce { .GET(_getPendingProposalsUrl(client_, onChain_)) .request(); - uint256 nonce_ = fromResponse(onChain_, response_); - - console.log("[nonce] Safe on-chain nonce %d, next free nonce %d", onChain_, nonce_); - - return nonce_; + return fromResponse(onChain_, response_); } /// @notice Returns the next free nonce from a page of pending proposals at or above `onChain_`. - /// @dev Reverts if `response_` is not a 2xx answer. + /// @dev Reverts if `response_` is not a 2xx answer. Logs the pending proposals as `[nonce]` lines. /// @param onChain_ The Safe's on-chain nonce. /// @param response_ The transaction service page of pending proposals, highest nonce first. /// @return `onChain_` if no proposal is pending at or above it, else the highest pending nonce plus one. @@ -52,9 +48,23 @@ library SafeNonce { revert PendingProposalsQueryFailed(response_.status, response_.data); } - if (_vm.parseJsonUint(response_.data, ".count") == 0) return onChain_; + uint256 pendingCount_ = _vm.parseJsonUint(response_.data, ".count"); + + if (pendingCount_ == 0) { + console.log("[nonce] Safe nonce %d, no pending proposals", onChain_); + return onChain_; + } + + uint256 highestPending_ = _vm.parseJsonUint(response_.data, ".results[0].nonce"); + + console.log( + "[nonce] Safe nonce %d, %d pending proposal(s) up to nonce %d", + onChain_, + pendingCount_, + highestPending_ + ); - uint256 next_ = _vm.parseJsonUint(response_.data, ".results[0].nonce") + 1; + uint256 next_ = highestPending_ + 1; // NOTE: A service that ignores `nonce__gte` can return a stale proposal below the on-chain nonce, e.g. the // loser of a past collision. Never propose below the on-chain nonce.