From 13b09193013f85b3671ca863f0f8cde9641d5caf Mon Sep 17 00:00:00 2001 From: Michael de Hoog Date: Thu, 10 Jul 2025 20:36:26 +1000 Subject: [PATCH] Add support for signing EIP-712 typed data --- script/universal/MultisigDeploy.sol | 8 +--- script/universal/MultisigScript.sol | 47 ++++++++++++++++++- .../DoubleNestedMultisigBuilder.t.sol | 4 ++ test/universal/MultisigBuilder.t.sol | 30 ++++++++++++ test/universal/NestedMultisigBuilder.t.sol | 4 ++ 5 files changed, 85 insertions(+), 8 deletions(-) diff --git a/script/universal/MultisigDeploy.sol b/script/universal/MultisigDeploy.sol index a5872fb..669f5ae 100644 --- a/script/universal/MultisigDeploy.sol +++ b/script/universal/MultisigDeploy.sol @@ -135,7 +135,7 @@ contract MultisigDeployScript is Script { console.log(" Salt Nonce:", saltNonce); // Resolve owner addresses (combine direct owners + referenced safe addresses) - address[] memory resolvedOwners = resolveOwnerAddresses(config, safes); + address[] memory resolvedOwners = resolveOwnerAddresses(config); console.log(" Total Owners:", resolvedOwners.length); console.log(" Direct Owners:", config.owners.length); @@ -186,11 +186,7 @@ contract MultisigDeployScript is Script { } } - function resolveOwnerAddresses(SafeWallet memory config, SafeWallet[] memory safes) - internal - view - returns (address[] memory) - { + function resolveOwnerAddresses(SafeWallet memory config) internal view returns (address[] memory) { uint256 totalOwners = config.owners.length + config.ownerRefIndices.length; address[] memory resolved = new address[](totalOwners); diff --git a/script/universal/MultisigScript.sol b/script/universal/MultisigScript.sol index 160c0e0..8c238fd 100644 --- a/script/universal/MultisigScript.sol +++ b/script/universal/MultisigScript.sol @@ -6,6 +6,7 @@ import {console} from "forge-std/console.sol"; import {Script} from "forge-std/Script.sol"; import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol"; import {Vm} from "forge-std/Vm.sol"; +import {stdJson} from "forge-std/StdJson.sol"; import {IGnosisSafe, Enum} from "./IGnosisSafe.sol"; import {Signatures} from "./Signatures.sol"; @@ -332,7 +333,7 @@ abstract contract MultisigScript is Script { return safes; } - function _transactionDatas(address[] memory _safes) private view returns (bytes[] memory datas, uint256 value) { + function _transactionDatas(address[] memory _safes) internal view returns (bytes[] memory datas, uint256 value) { // Build the calls and sum the values IMulticall3.Call3Value[] memory calls = _buildCalls(); for (uint256 i = 0; i < calls.length; i++) { @@ -375,7 +376,8 @@ abstract contract MultisigScript is Script { } function _printDataToSign(address _safe, bytes memory _data, uint256 _value) internal { - bytes memory txData = _encodeTransactionData(_safe, _data, _value); + bytes memory txData = + _printDataHashes() ? _encodeTransactionData(_safe, _data, _value) : _encodeEIP712Json(_safe, _data, _value); bytes32 hash = _getTransactionHash(_safe, _data, _value); emit DataToSign(txData); @@ -397,6 +399,14 @@ abstract contract MultisigScript is Script { console.log("###############################"); } + // Controls whether the safe tx is printed as structured EIP-712 data, or just hashes. + // + // If you want to print and sign hashed EIP-712 data (domain + message hash) rather than the + // typed EIP-712 data struct, override this function and return `true`. + function _printDataHashes() internal view virtual returns (bool) { + return false; + } + function _executeTransaction( address _safe, bytes memory _data, @@ -566,6 +576,39 @@ abstract contract MultisigScript is Script { }); } + function _encodeEIP712Json(address _safe, bytes memory _data, uint256 _value) internal returns (bytes memory) { + string memory types = '{"EIP712Domain":[' '{"name":"chainId","type":"uint256"},' + '{"name":"verifyingContract","type":"address"}],' '"SafeTx":[' '{"name":"to","type":"address"},' + '{"name":"value","type":"uint256"},' '{"name":"data","type":"bytes"},' + '{"name":"operation","type":"uint8"},' '{"name":"safeTxGas","type":"uint256"},' + '{"name":"baseGas","type":"uint256"},' '{"name":"gasPrice","type":"uint256"},' + '{"name":"gasToken","type":"address"},' '{"name":"refundReceiver","type":"address"},' + '{"name":"nonce","type":"uint256"}]}'; + + string memory domain = stdJson.serialize("domain", "chainId", uint256(block.chainid)); + domain = stdJson.serialize("domain", "verifyingContract", address(_safe)); + + string memory message = stdJson.serialize("message", "to", MULTICALL3_ADDRESS); + message = stdJson.serialize("message", "value", _value); + message = stdJson.serialize("message", "data", _data); + message = stdJson.serialize( + "message", "operation", uint256(_value == 0 ? Enum.Operation.DelegateCall : Enum.Operation.Call) + ); + message = stdJson.serialize("message", "safeTxGas", uint256(0)); + message = stdJson.serialize("message", "baseGas", uint256(0)); + message = stdJson.serialize("message", "gasPrice", uint256(0)); + message = stdJson.serialize("message", "gasToken", address(0)); + message = stdJson.serialize("message", "refundReceiver", address(0)); + message = stdJson.serialize("message", "nonce", _getNonce(_safe)); + + string memory json = stdJson.serialize("", "primaryType", string("SafeTx")); + json = stdJson.serialize("", "types", types); + json = stdJson.serialize("", "domain", domain); + json = stdJson.serialize("", "message", message); + + return abi.encodePacked(json); + } + function _execTransactionCalldata(address _safe, bytes memory _data, uint256 _value, bytes memory _signatures) internal pure diff --git a/test/universal/DoubleNestedMultisigBuilder.t.sol b/test/universal/DoubleNestedMultisigBuilder.t.sol index ef3ebc1..1672067 100644 --- a/test/universal/DoubleNestedMultisigBuilder.t.sol +++ b/test/universal/DoubleNestedMultisigBuilder.t.sol @@ -77,6 +77,10 @@ contract DoubleNestedMultisigBuilderTest is Test, DoubleNestedMultisigBuilder { return safe4; } + function _printDataHashes() internal view override returns (bool) { + return true; + } + function test_sign_double_nested_safe1() external { vm.recordLogs(); bytes memory txData = abi.encodeCall(DoubleNestedMultisigBuilder.sign, (safe1, safe3)); diff --git a/test/universal/MultisigBuilder.t.sol b/test/universal/MultisigBuilder.t.sol index 4452712..c5eb532 100644 --- a/test/universal/MultisigBuilder.t.sol +++ b/test/universal/MultisigBuilder.t.sol @@ -20,6 +20,7 @@ contract MultisigBuilderTest is Test, MultisigBuilder { Counter internal counter = new Counter(address(safe)); function () internal view returns (IMulticall3.Call3Value[] memory) buildCallsInternal; + function () internal view returns (bool) printDataHashesInternal = printDataHashesEnabled; bytes internal dataToSignNoValue = // solhint-disable-next-line max-line-length @@ -29,6 +30,10 @@ contract MultisigBuilderTest is Test, MultisigBuilder { // solhint-disable-next-line max-line-length hex"1901d4bb33110137810c444c1d9617abe97df097d587ecde64e6fcb38d7f49e1280cd150dbb03d4bb38e5325a914ff3861da880437fd5856c0f7e39054e64e05aed0"; + string internal dataToSignTyped = + // solhint-disable-next-line max-line-length + '{"domain":{"chainId":31337,"verifyingContract":"0x00000000000000000000000000000000000003e9"},"message":{"baseGas":0,"data":"0x174dea710000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000200000000000000000000000005615deb798bb3e4dfa0139dfa1b3d433cc23b72f0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000004d09de08a00000000000000000000000000000000000000000000000000000000","gasPrice":0,"gasToken":"0x0000000000000000000000000000000000000000","nonce":0,"operation":1,"refundReceiver":"0x0000000000000000000000000000000000000000","safeTxGas":0,"to":"0xcA11bde05977b3631167028862bE2a173976CA11","value":0},"primaryType":"SafeTx","types":{"EIP712Domain":[{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"SafeTx":[{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"data","type":"bytes"},{"name":"operation","type":"uint8"},{"name":"safeTxGas","type":"uint256"},{"name":"baseGas","type":"uint256"},{"name":"gasPrice","type":"uint256"},{"name":"gasToken","type":"address"},{"name":"refundReceiver","type":"address"},{"name":"nonce","type":"uint256"}]}}'; + function setUp() public { vm.etch(safe, Preinstalls.getDeployedCode(Preinstalls.Safe_v130, block.chainid)); vm.etch(Preinstalls.MultiCall3, Preinstalls.getDeployedCode(Preinstalls.MultiCall3, block.chainid)); @@ -54,6 +59,10 @@ contract MultisigBuilderTest is Test, MultisigBuilder { return address(safe); } + function _printDataHashes() internal view override returns (bool) { + return printDataHashesInternal(); + } + function test_sign_no_value() external { buildCallsInternal = _buildCallsNoValue; @@ -78,6 +87,19 @@ contract MultisigBuilderTest is Test, MultisigBuilder { assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSignWithValue))); } + function test_sign_typed_data() external { + buildCallsInternal = _buildCallsNoValue; + printDataHashesInternal = printDataHashesDisabled; + + vm.recordLogs(); + bytes memory txData = abi.encodeCall(MultisigBuilder.sign, ()); + vm.prank(wallet1.addr); + (bool success,) = address(this).call(txData); + vm.assertTrue(success); + Vm.Log[] memory logs = vm.getRecordedLogs(); + assertEq(keccak256(logs[logs.length - 1].data), keccak256(abi.encode(dataToSignTyped))); + } + function test_run() external { buildCallsInternal = _buildCallsNoValue; (uint8 v1, bytes32 r1, bytes32 s1) = vm.sign(wallet1, keccak256(dataToSignNoValue)); @@ -111,4 +133,12 @@ contract MultisigBuilderTest is Test, MultisigBuilder { return calls; } + + function printDataHashesEnabled() internal pure returns (bool) { + return true; + } + + function printDataHashesDisabled() internal pure returns (bool) { + return false; + } } diff --git a/test/universal/NestedMultisigBuilder.t.sol b/test/universal/NestedMultisigBuilder.t.sol index 61c4600..22a2268 100644 --- a/test/universal/NestedMultisigBuilder.t.sol +++ b/test/universal/NestedMultisigBuilder.t.sol @@ -71,6 +71,10 @@ contract NestedMultisigBuilderTest is Test, NestedMultisigBuilder { return address(safe3); } + function _printDataHashes() internal view override returns (bool) { + return true; + } + function test_sign_safe1() external { vm.recordLogs(); bytes memory txData = abi.encodeCall(NestedMultisigBuilder.sign, (safe1));