Repository navigation
[#3450] Bind multisig confirmation and execution to transaction content - #55
Conversation
…ion content confirmTransaction and executeTransaction each take a content digest (hashTransactionContent(to, value, data)) alongside the transaction index. The digest is computed once at submission time, stored on the transaction, and compared against the caller-supplied value; a mismatch reverts with TransactionContentMismatch and leaves the transaction and its confirmations untouched. A confirmation or execution therefore names the action it applies to, not merely the slot it happened to land at. getTransactionContentHash exposes the stored digest so a caller can look it up without recomputing it. GovActions.s.sol's three submitters print the digest of the action they submitted instead of a transaction index read before the submission landed. VoteForTxn and ExecuteTxn take the digest from GOV_TXN_CONTENT_HASH, and log the transaction actually stored at the given index next to it before broadcasting. submit_governance_action.sh reads the index its own submission was assigned from the SubmitTransaction event in that call's broadcast receipt, via the new read_submitted_tx_index.sh, rather than from a value read before the call landed. MultiSignatureWallet.t.sol gains coverage for a mismatched digest on confirm and on execute, for confirmations bound to the wrong index's content, and for the SubmitTransaction event carrying the assigned index. The genesis bytecode hash pinned for MultiSignatureWallet in generator.rs is regenerated to match. Issue: bluealloy#3450 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
isaacdoidge
left a comment
There was a problem hiding this comment.
Reviewed the full diff and ran forge test --match-path test/MultiSignatureWallet.t.sol locally — 114 passed, 0 failed.
The contract change is sound. The digest is computed once at submission; confirmTransaction checks it before notConfirmed, so a mismatch leaves the transaction's confirmations untouched; executeTransaction checks it before removeTransaction; abi.encode (not encodePacked) keeps the digest collision-free; and hashTransactionContent being pure lets a caller derive it from the action it intends, without reading wallet state. The new Foundry coverage is well targeted.
The blocking items are all in the shell driver, and none of them is reachable from the Foundry suite, which is why the test plan is green.
Blocking
1. submit_governance_action.sh:65 — MULTISIG_WALLET_ADDRESS is not set in the shell.
The script's header tells the operator to put MULTISIG_WALLET_ADDRESS in .env, which forge loads in-process; bash never sees it. This line is the first time the shell itself dereferences that variable, and the same commit adds set -u, so a run aborts with MULTISIG_WALLET_ADDRESS: unbound variable — after the submission has landed on chain and before any vote is cast. The sibling deploy_automation_registry.sh:4 does source .env; this script needs the same, or an explicit require-and-error.
2. read_submitted_tx_index.sh is committed non-executable.
git ls-tree shows mode 100644 (deploy_automation_registry.sh is 100755), and submit_governance_action.sh:65 invokes it by path, so it fails with "Permission denied". Either chmod +x it in the commit or invoke it as bash "${script_path}/read_submitted_tx_index.sh".
3. submit_governance_action.sh:65 — export VAR=$(...) hides the helper's exit status.
With a command name in front of the assignment, the status is export's, so set -e does not fire when read_submitted_tx_index.sh exits non-zero. GOV_TXN_INDEX then becomes empty and the loop proceeds, so each owner's forge run fails on vm.envUint with an opaque error rather than the helper's diagnostic. Line 55 gets this right for the content hash; this line needs the same treatment — split the assignment, or add the emptiness check.
4. MultiSignatureWallet.sol — contentMatches accepts a stored digest of zero.
This wallet sits behind MultisigBeacon, so the implementation can be replaced. Appending contentHash to Transaction is layout-safe for the existing fields, but an entry written by the previous implementation reads back as bytes32(0): it cannot be actioned with the digest hashTransactionContent computes, and it can be actioned by passing bytes32(0), which satisfies the check without binding to anything. Suggest rejecting a zero stored digest in contentMatches (expiry plus removeExpiredTransaction being the migration path for such entries), or stating in the upgrade procedure that the pending queue is drained first.
Non-blocking
5. read_submitted_tx_index.sh:44 — nothing ties the receipt to this run.
The helper validates the first transaction's function, target and status, and the caller picks run-latest.json by ls -t. If forge does not rewrite that file for the current submit, an earlier successful submitTransaction to the same wallet satisfies every check. The content-hash guard covers the general case but not a re-run of the same action, where the digests are equal. Comparing the receipt's transaction hash or timestamp against the run just performed would close it.
6. GovActions.s.sol:139 and :168 — the diagnostic block can revert before it prints.
getTransaction and getTransactionContentHash both run txExists and notExpired, so if the index is wrong or the transaction has expired, the script aborts inside the logging block with a raw revert and the operator loses exactly the output the block exists to produce. try/catch around the reads, printing "no live transaction at this index", preserves the intent.
7. MultiSignatureWallet.sol:268 — executeTransaction inlines the digest comparison instead of calling the contentMatches helper added for confirmTransaction. That is the drift this PR factors GovSubmitAction out to avoid; a later change to the check (item 4, say) lands on one path only. The inline form avoids a second SLOAD because transaction is already in memory — worth a one-line comment if that is the reason.
8. revokeConfirmation keeps the index-only signature while confirm and execute gained the digest parameter. If leaving it out is deliberate, worth recording the rationale on the issue.
Wording
MultiSignatureWallet.sol aside, the commit message, the PR description's Motivation paragraph, and the header comments on read_submitted_tx_index.sh, submit_governance_action.sh and GovSubmitAction each frame the change against the behaviour the code no longer has ("instead of …", "rather than …", "not predicted …"). This repository is public, so those land as a permanent description of the defect attached to its fix. CONTRIBUTING.md, "What Comments and Commit Messages May Say About a Vulnerability", asks that the tree state only the rule the code now enforces — here, that the index comes from the SubmitTransaction event and that confirm and execute name the action by digest — and that the analysis live on the issue. Please reword the commit message, these comments and the PR body; the forward-looking note in GovSubmitAction is fine once it drops the comparison.
…cript guards revokeConfirmation now takes a content digest, the same as confirmTransaction and executeTransaction, so every action that changes a confirmation's state names the action it applies to. submit_governance_action.sh sources .env so the shell sees the same variables forge reads from it. read_submitted_tx_index.sh is committed executable, matching how it is invoked. VoteForTxn and ExecuteTxn read the transaction at the given index through try/catch: on success they log it next to the expected digest as before; on failure (index does not exist, or expired) they abort with a clear message before ever calling confirmTransaction or executeTransaction. Header comments on read_submitted_tx_index.sh, submit_governance_action.sh, and GovSubmitAction are reworded to state the rule each enforces. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for the thorough review. Addressed in b6ceac9, point by point: 1. 2. 3. 4. 5. Nothing ties the receipt to this specific run. Discarding — this script is for local/manual testing only, not part of an automated or production path, so the cost of closing this gap isn't worth it for that use. 6. Diagnostic block can revert before it prints. Fixed, and tightened further than originally proposed: 7. 8. Wording. Reworded the commit message, this reply, and the header comments on
|
isaacdoidge
left a comment
There was a problem hiding this comment.
Re-reviewed at b6ceac9. Ran both suites locally: forge test — 478 passed, 0 failed; cargo test -p revm-supra-extension genesis_contract_bytecode_matches_expected_hashes --release — ok, so the regenerated pin is correct.
The responses on items 3 and 7 both hold. The helper writes to stdout only on its success path, so the emptiness check does catch every failure regardless of set -e, and keeping the comparison inlined in executeTransaction is the right call now that the reason is recorded next to it. The try/catch in item 6 is tighter than what I proposed — aborting before any broadcast rather than letting the call revert on its own is the better shape. revokeConfirmation taking the digest closes the last state-changing path.
Approving. Three things left, none of them blocking:
1. submit_governance_action.sh:31 — source .env resolves against the caller's working directory.
It sits above script_path=$(dirname $(realpath ${0})), while every other path in the script is anchored to the script's own location. Invoked from anywhere other than solidity/supra_contracts, it aborts on a missing .env that is sitting next to it, exactly where the header tells the operator to put it. source "$(dirname "$(realpath "$0")")/.env", or moving the script_path assignment above the source, anchors it. The comment on the line above also ends on "the following variables to be set in the .env file:" and then lists nothing — worth finishing or dropping.
2. Item 4: the deferral is reasonable, but nothing in the tree records it.
Agreed that with no deployment there is no prior-layout entry to protect, so the guard can wait. What I would not leave only in this thread is the assumption it rests on: a line on contentMatches saying a zero stored digest is currently unreachable, plus a tracking issue in Entropy-Foundation/smr-moonshot, puts the deferral where the next person reads the code rather than in PR history.
3. Item 5: "local and manual" does not cover the mirrored copy.
Agreed for this script. The description says foundation-multisig-tools' forked driver mirrors this change for the v12.0 release tooling, though — if that copy shares the ls -t receipt selection, the gap sits in a release path rather than a manual one, and the equal-digest case is the one the content check cannot catch. Worth confirming either way before that fork lands.
…to script_path contentMatches, and executeTransaction's inlined comparison, now reject a stored content digest of bytes32(0) outright, regardless of what the caller supplies. hashTransactionContent never produces bytes32(0) for a real to/value/data, so a transaction only reads back that way from a slot written before this field existed, and it names no actual action either digest could legitimately be checked against. Covered by two new tests that write bytes32(0) directly into a transaction's storage slot and confirm both confirmTransaction and executeTransaction reject it even when called with bytes32(0) as the supplied digest. submit_governance_action.sh sources .env from script_path rather than the caller's working directory, matching every other path the script resolves. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
In f85feeb: Item 4 — implemented the guard directly rather than only recording the assumption. Covered by two new tests ( Since the guard is in place now rather than deferred, no tracking issue is needed for it. Also included in this push: your item 1 from the second round — Still open from the second round, not addressed in this push: item 3 (whether
|
|
Item 3 (v12.0 mirrored copy). Confirmed: it shares the gap. That said, it's its own copy in a separate repository — a fork, not a reference to the file here — so nothing in this PR's diff touches it, and this repo has no way to propagate a fix there automatically. Closing the gap for that copy, if it's wanted, is a decision and a change for |
Summary
Fixes Entropy-Foundation/smr-moonshot#3450.
confirmTransactionandexecuteTransactiononMultiSignatureWalletnow take acontent digest (
hashTransactionContent(to, value, data)) alongside thetransaction index. The digest is computed once at submission time and stored
on the transaction; a call whose supplied digest does not match what is
actually stored at that index reverts with
TransactionContentMismatchandleaves the transaction and its confirmations untouched. A confirmation or
execution therefore names the action it applies to, not merely the slot it
happened to land at. A new
getTransactionContentHashgetter exposes thestored digest so a caller can look it up without recomputing it.
script/GovActions.s.sol's three submitters now print the digest of theaction they submitted, instead of a transaction index read before the
submission landed.
VoteForTxnandExecuteTxntake the digest fromGOV_TXN_CONTENT_HASH, and log the transaction actually stored at the givenindex next to it before broadcasting.
submit_governance_action.shreads theindex its own submission was assigned from the
SubmitTransactionevent inthat call's broadcast receipt, via a new shared helper
(
read_submitted_tx_index.sh), rather than from a value read before the calllanded.
The two consumers of this wallet API outside this repo were updated
alongside this change:
Entropy-Foundation/supra-qa(temp/evm_automation_v11,a66734fb):
multisig_actions.pyreads the assigned index from the event and threadsthe digest through; the checked-in ABI is refreshed, which also fixes a
pre-existing drift on
ContractCreationFailed.Entropy-Foundation/foundation-multisig-tools(
task/evm-genesis-gen, v12.0 release tooling): the interface copy, theforked
GovActions.s.sol, and the forked shell driver mirror the same fix,preserving that fork's own env-var naming and gas-cap pre-check. Left
uncommitted for review.
Motivation
The governance script drove every foundation key's confirmation and
execution at a transaction index read before the submission landed, with no
check that the index still held the intended action. See the linked issue
for the full analysis.
Test plan
forge build/forge test(487 tests, whole suite) — pass.FOUNDRY_PROFILE=production forge build— clean.cargo build --release,cargo clippy --release --workspace --all-targets --all-features— clean.cargo nextest run --release -p revm-supra-extension(153 tests) andgenesis_contract_bytecode_matches_expected_hashes(regenerated pinnedhash for
MultiSignatureWallet) — pass.confirmation bound to the wrong index's content, and the
SubmitTransactionevent carrying the assigned index.🤖 Generated with Claude Code