-
Notifications
You must be signed in to change notification settings - Fork 2
🔨 contracts: add safe propose script #883
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: redeployer
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,154 @@ | ||||||||||||||||||||||||
| // SPDX-License-Identifier: AGPL-3.0 | ||||||||||||||||||||||||
| pragma solidity ^0.8.0; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import { LibString } from "solady/utils/LibString.sol"; | ||||||||||||||||||||||||
| import { Surl } from "surl/Surl.sol"; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| import { BaseScript, stdJson } from "./Base.s.sol"; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| contract SafePropose is BaseScript { | ||||||||||||||||||||||||
| using LibString for address; | ||||||||||||||||||||||||
| using LibString for bytes; | ||||||||||||||||||||||||
| using LibString for uint256; | ||||||||||||||||||||||||
| using stdJson for string; | ||||||||||||||||||||||||
| using Surl for string; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| IMultiSendCallOnly internal constant MULTISEND = IMultiSendCallOnly(0x9641d764fc13c8B624c04430C7356C1C7C8102e2); // github.com/safe-global/safe-deployments v1.4.1 | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function run(string calldata path) external { | ||||||||||||||||||||||||
| string memory json = vm.readFile(path); // forge-lint: disable-line(unsafe-cheatcode) | ||||||||||||||||||||||||
| uint256 length; | ||||||||||||||||||||||||
| while (vm.keyExistsJson(json, string.concat(".transactions[", length.toString(), "]"))) ++length; | ||||||||||||||||||||||||
| if (length == 0) revert EmptyBroadcast(); | ||||||||||||||||||||||||
| Call[] memory calls = new Call[](length); | ||||||||||||||||||||||||
| address safe; | ||||||||||||||||||||||||
| for (uint256 i = 0; i < length; ++i) { | ||||||||||||||||||||||||
| string memory prefix = string.concat(".transactions[", i.toString(), "]"); | ||||||||||||||||||||||||
| if (keccak256(bytes(json.readString(string.concat(prefix, ".transactionType")))) != keccak256("CALL")) { | ||||||||||||||||||||||||
| revert NotACall(); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| prefix = string.concat(prefix, ".transaction"); | ||||||||||||||||||||||||
| address from = json.readAddress(string.concat(prefix, ".from")); | ||||||||||||||||||||||||
| if (i == 0) safe = from; | ||||||||||||||||||||||||
| else if (from != safe) revert SenderMismatch(); | ||||||||||||||||||||||||
| calls[i] = Call({ | ||||||||||||||||||||||||
| to: json.readAddress(string.concat(prefix, ".to")), | ||||||||||||||||||||||||
| value: json.readUint(string.concat(prefix, ".value")), | ||||||||||||||||||||||||
| data: json.readBytes(string.concat(prefix, ".input")) | ||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| if (length == 1) propose(ISafe(safe), calls[0].to, calls[0].value, calls[0].data, 0); | ||||||||||||||||||||||||
| else proposeBatch(ISafe(safe), calls); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function proposeBatch(ISafe safe, Call[] memory calls) internal { | ||||||||||||||||||||||||
| bytes memory packed; | ||||||||||||||||||||||||
| for (uint256 i = 0; i < calls.length; ++i) { | ||||||||||||||||||||||||
| packed = abi.encodePacked(packed, uint8(0), calls[i].to, calls[i].value, calls[i].data.length, calls[i].data); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| propose(safe, address(MULTISEND), 0, abi.encodeCall(MULTISEND.multiSend, (packed)), 1); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function propose(ISafe safe, address to, uint256 value, bytes memory data, uint8 operation) internal virtual { | ||||||||||||||||||||||||
| string memory hexSafe = address(safe).toHexStringChecksummed(); | ||||||||||||||||||||||||
| string memory url = string.concat( | ||||||||||||||||||||||||
| "https://api.safe.global/tx-service/", _chainPrefix(), "/api/v2/safes/", hexSafe, "/multisig-transactions/" | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
|
Comment on lines
+54
to
+56
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||||||||||||||||||||||||
| uint256 nonce = safe.nonce(); | ||||||||||||||||||||||||
| bytes32 safeTxHash = | ||||||||||||||||||||||||
| safe.getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), payable(address(0)), nonce); | ||||||||||||||||||||||||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the target Safe already has a pending multisig transaction, Useful? React with 👍 / 👎. |
||||||||||||||||||||||||
| address sender; | ||||||||||||||||||||||||
| bytes memory signature; | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| (uint8 v, bytes32 r, bytes32 s) = vm.sign(safeTxHash); | ||||||||||||||||||||||||
| sender = ecrecover(safeTxHash, v, r, s); | ||||||||||||||||||||||||
| signature = abi.encodePacked(r, s, v); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+62
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Consider validating
🛡️ Proposed fix {
(uint8 v, bytes32 r, bytes32 s) = vm.sign(safeTxHash);
sender = ecrecover(safeTxHash, v, r, s);
+ require(sender != address(0), "Invalid signature");
signature = abi.encodePacked(r, s, v);
}📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||
| string[] memory headers = new string[](1); | ||||||||||||||||||||||||
| headers[0] = "Content-Type: application/json"; | ||||||||||||||||||||||||
|
Comment on lines
+67
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When this posts to the hosted Useful? React with 👍 / 👎. |
||||||||||||||||||||||||
| (uint256 status, bytes memory response) = | ||||||||||||||||||||||||
| url.post(headers, _body(to, value, data, operation, nonce, safeTxHash, sender, signature)); | ||||||||||||||||||||||||
| if (status != 201) revert ProposalFailed(status, string(response)); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // solhint-disable quotes | ||||||||||||||||||||||||
| function _body( | ||||||||||||||||||||||||
| address to, | ||||||||||||||||||||||||
| uint256 value, | ||||||||||||||||||||||||
| bytes memory data, | ||||||||||||||||||||||||
| uint8 operation, | ||||||||||||||||||||||||
| uint256 nonce, | ||||||||||||||||||||||||
| bytes32 safeTxHash, | ||||||||||||||||||||||||
| address sender, | ||||||||||||||||||||||||
| bytes memory signature | ||||||||||||||||||||||||
| ) internal pure returns (string memory) { | ||||||||||||||||||||||||
| return string.concat( | ||||||||||||||||||||||||
| string.concat( | ||||||||||||||||||||||||
| '{"to":"', | ||||||||||||||||||||||||
| to.toHexStringChecksummed(), | ||||||||||||||||||||||||
| '","value":"', | ||||||||||||||||||||||||
| value.toString(), | ||||||||||||||||||||||||
| '","data":"', | ||||||||||||||||||||||||
| data.length == 0 ? "0x" : data.toHexString(), | ||||||||||||||||||||||||
| '","operation":', | ||||||||||||||||||||||||
| uint256(operation).toString(), | ||||||||||||||||||||||||
| ',"safeTxGas":"0","baseGas":"0","gasPrice":"0","gasToken":"', | ||||||||||||||||||||||||
| address(0).toHexStringChecksummed(), | ||||||||||||||||||||||||
| '","refundReceiver":"', | ||||||||||||||||||||||||
| address(0).toHexStringChecksummed() | ||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||
| '","nonce":', | ||||||||||||||||||||||||
| nonce.toString(), | ||||||||||||||||||||||||
| ',"contractTransactionHash":"', | ||||||||||||||||||||||||
| bytes.concat(safeTxHash).toHexString(), | ||||||||||||||||||||||||
| '","sender":"', | ||||||||||||||||||||||||
| sender.toHexStringChecksummed(), | ||||||||||||||||||||||||
| '","signature":"', | ||||||||||||||||||||||||
| signature.toHexString(), | ||||||||||||||||||||||||
| '"}' | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
itofarina marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| // solhint-enable quotes | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function _chainPrefix() internal view returns (string memory) { | ||||||||||||||||||||||||
| if (block.chainid == 1) return "eth"; | ||||||||||||||||||||||||
| if (block.chainid == 10) return "oeth"; | ||||||||||||||||||||||||
| if (block.chainid == 137) return "pol"; | ||||||||||||||||||||||||
| if (block.chainid == 8453) return "base"; | ||||||||||||||||||||||||
| if (block.chainid == 42_161) return "arb1"; | ||||||||||||||||||||||||
| if (block.chainid == 204) return "opbnb"; | ||||||||||||||||||||||||
| revert UnsupportedChain(); | ||||||||||||||||||||||||
|
Comment on lines
+113
to
+120
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| error EmptyBroadcast(); | ||||||||||||||||||||||||
| error NotACall(); | ||||||||||||||||||||||||
| error ProposalFailed(uint256 status, string response); | ||||||||||||||||||||||||
| error SenderMismatch(); | ||||||||||||||||||||||||
| error UnsupportedChain(); | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| struct Call { | ||||||||||||||||||||||||
| address to; | ||||||||||||||||||||||||
| uint256 value; | ||||||||||||||||||||||||
| bytes data; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| interface IMultiSendCallOnly { | ||||||||||||||||||||||||
| function multiSend(bytes memory transactions) external; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| interface ISafe { | ||||||||||||||||||||||||
| function nonce() external view returns (uint256); | ||||||||||||||||||||||||
| function getTransactionHash( | ||||||||||||||||||||||||
| address to, | ||||||||||||||||||||||||
| uint256 value, | ||||||||||||||||||||||||
| bytes calldata data, | ||||||||||||||||||||||||
| uint8 operation, | ||||||||||||||||||||||||
| uint256 safeTxGas, | ||||||||||||||||||||||||
| uint256 baseGas, | ||||||||||||||||||||||||
| uint256 gasPrice, | ||||||||||||||||||||||||
| address gasToken, | ||||||||||||||||||||||||
| address payable refundReceiver, | ||||||||||||||||||||||||
| uint256 _nonce | ||||||||||||||||||||||||
| ) external view returns (bytes32); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,11 +104,13 @@ | |
| "modelcontextprotocol", | ||
| "moti", | ||
| "multicall", | ||
| "MULTISEND", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 One-off identifiers added to global cspell dictionary instead of using inline
Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| "mysten", | ||
| "natspec", | ||
| "nfmelendez", | ||
| "nomiclabs", | ||
| "nystrom", | ||
| "oeth", | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 One-off chain prefix "oeth" added to global cspell dictionary instead of inline cspell:ignore "oeth" is a Safe Transaction Service chain prefix string that only appears in Was this helpful? React with 👍 or 👎 to provide feedback.
Comment on lines
+107
to
+113
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add "opbnb" to the words list for consistency. Static analysis flags 🔧 Proposed fix "oeth",
"offramp",
+ "opbnb",
"oneline", |
||
| "offramp", | ||
| "oneline", | ||
| "onesignal", | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
runcurrently treats.transactions[i].transaction.fromas the Safe address, but in standard Forge broadcast artifacts that field is the broadcasting EOA (for example, existingcontracts/broadcast/**/run-latest.jsonfiles use a deployer EOA infrom). In that common workflowsafeis set to an EOA, so subsequentsafe.nonce()/getTransactionHash(...)calls inproposerevert or build an invalid proposal, making the script unusable for normal broadcast outputs.Useful? React with 👍 / 👎.