From ae1079c4e58aa6cf7138a6f7dcbbae3e77106243 Mon Sep 17 00:00:00 2001 From: aelmanaa Date: Sat, 31 Oct 2026 15:58:32 +0000 Subject: [PATCH] feat: validate proposed transactions against local payloads (#5) Add getSafeTxHashes, which computes the EIP-712 domain separator, SafeTx message hash and safeTxHash locally (the triple a hardware wallet shows), and logSafeTxHashes, which logs them as [safe-utils] lines. Add validateProposedTransaction and validateProposedTransactions, which fetch a proposal from the Transaction Service (GET /v2/multisig-transactions/{safeTxHash}/, as api-kit does), compare safe, to, value, data, operation, nonce and the zeroed gas fields with the locally built transaction, and recompute the safeTxHash. They revert with ProposedTransactionMismatch(field, expected, actual) on the first difference. validateProposedTransactionJson checks an already-fetched response, so tests run against a fixture instead of the live service. proposeTransaction now also logs the three hashes after a successful proposal. Co-Authored-By: Claude Opus 5.5 --- README.md | 21 ++ foundry.toml | 1 + src/Safe.sol | 214 +++++++++++++++ test/SafeValidation.t.sol | 266 +++++++++++++++++++ test/fixtures/base-multisig-transaction.json | 87 ++++++ 5 files changed, 589 insertions(+) create mode 100644 test/SafeValidation.t.sol create mode 100644 test/fixtures/base-multisig-transaction.json diff --git a/README.md b/README.md index 999c442..c3bb772 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,27 @@ safe.proposeTransactionsWithSignature(targets, datas, sender, signature); **⚠️ Important**: Batch transactions require `Enum.Operation.DelegateCall` (not `Call`). Using `Call` causes signature validation errors. +#### Validate a proposal before signing + +A hardware wallet shows only hashes. Before co-signing, check that a proposal on the Transaction Service matches the transaction your script builds: + +```solidity +// Single transaction: reverts with ProposedTransactionMismatch(field, expected, actual) on the first difference +safe.validateProposedTransaction(safeTxHash, to, value, data, Enum.Operation.Call, nonce); + +// Batch: rebuilds the MultiSend payload from targets and datas, as proposeTransactions does +safe.validateProposedTransactions(safeTxHash, targets, datas, nonce); +``` + +On success it logs the domain hash, message hash and safeTxHash to compare with the device screen. To compute them without the Transaction Service: + +```solidity +Safe.SafeTxHashes memory hashes = safe.getSafeTxHashes(to, value, data, operation, nonce); +Safe.logSafeTxHashes(hashes); +``` + +`validateProposedTransactionJson` checks a response you already fetched, for example in tests. + #### Simulation (no hardware wallet required) Simulate transactions against a local fork before broadcasting. No signing device is needed — the library writes directly to the Safe's `approvedHashes` storage slot. diff --git a/foundry.toml b/foundry.toml index cbd8bf8..059d270 100644 --- a/foundry.toml +++ b/foundry.toml @@ -3,5 +3,6 @@ src = "src" out = "out" libs = ["lib"] ffi = true +fs_permissions = [{ access = "read", path = "./test/fixtures" }] # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options diff --git a/src/Safe.sol b/src/Safe.sol index 45cd152..1d8c3c6 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -29,10 +29,18 @@ library Safe { // https://github.com/safe-global/safe-smart-account/blob/release/v1.4.1/contracts/libraries/SafeStorage.sol uint256 constant SAFE_APPROVED_HASHES_SLOT = 8; + // https://github.com/safe-global/safe-smart-account/blob/release/v1.4.1/contracts/Safe.sol + // keccak256("EIP712Domain(uint256 chainId,address verifyingContract)") + bytes32 constant DOMAIN_SEPARATOR_TYPEHASH = 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218; + // keccak256("SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)") + bytes32 constant SAFE_TX_TYPEHASH = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8; + error ApiKitUrlNotFound(uint256 chainId); error MultiSendCallOnlyNotFound(uint256 chainId); error ArrayLengthsMismatch(uint256 a, uint256 b); error ProposeTransactionFailed(uint256 statusCode, string response); + error GetProposedTransactionFailed(bytes32 safeTxHash, uint256 statusCode, string response); + error ProposedTransactionMismatch(string field, string expected, string actual); struct Instance { address safe; @@ -60,6 +68,14 @@ library Safe { uint256 nonce; } + /// @notice The hashes a hardware wallet displays when signing a Safe transaction + /// @dev safeTxHash = keccak256(0x19 || 0x01 || domainSeparator || messageHash) + struct SafeTxHashes { + bytes32 domainSeparator; + bytes32 messageHash; + bytes32 safeTxHash; + } + function initialize(Client storage self, address safe) internal returns (Client storage) { self.instances.push(); Instance storage i = self.instances[self.instances.length - 1]; @@ -221,6 +237,203 @@ library Safe { .getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), address(0), nonce); } + // ========================================================================= + // Proposal validation + // ========================================================================= + + /// @notice Compute the EIP-712 hashes of a Safe transaction without calling the Safe + /// @dev Uses the zeroed gas fields this library proposes with (safeTxGas, baseGas, gasPrice, + /// gasToken, refundReceiver). The domain includes chainId, as in Safe v1.3.0 and later. + /// + /// @param safe The Safe address (EIP-712 verifyingContract) + /// @param chainId The chain id (EIP-712 chainId) + /// @param to The target address for the transaction + /// @param value The value of the transaction + /// @param data The data payload for the transaction + /// @param operation The operation to perform + /// @param nonce The nonce of the transaction + /// @return hashes The domain separator, the SafeTx message hash and the safeTxHash + function getSafeTxHashes( + address safe, + uint256 chainId, + address to, + uint256 value, + bytes memory data, + Enum.Operation operation, + uint256 nonce + ) internal pure returns (SafeTxHashes memory hashes) { + hashes.domainSeparator = keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, safe)); + hashes.messageHash = keccak256( + abi.encode(SAFE_TX_TYPEHASH, to, value, keccak256(data), operation, 0, 0, 0, address(0), address(0), nonce) + ); + hashes.safeTxHash = + keccak256(abi.encodePacked(bytes1(0x19), bytes1(0x01), hashes.domainSeparator, hashes.messageHash)); + } + + /// @notice Compute the EIP-712 hashes of a Safe transaction for this Safe on the current chain + function getSafeTxHashes( + Client storage self, + address to, + uint256 value, + bytes memory data, + Enum.Operation operation, + uint256 nonce + ) internal view returns (SafeTxHashes memory) { + return getSafeTxHashes(instance(self).safe, block.chainid, to, value, data, operation, nonce); + } + + /// @notice Log the hashes a hardware wallet displays, so a signer can compare them with the device + function logSafeTxHashes(SafeTxHashes memory hashes) internal pure { + console.log(string.concat("[safe-utils] domain hash: ", vm.toString(hashes.domainSeparator))); + console.log(string.concat("[safe-utils] message hash: ", vm.toString(hashes.messageHash))); + console.log(string.concat("[safe-utils] safeTxHash: ", vm.toString(hashes.safeTxHash))); + } + + /// @notice The Transaction Service URL of a proposed transaction, as used by api-kit's getTransaction + function getProposedTransactionUrl(Client storage self, uint256 chainId, bytes32 safeTxHash) + internal + pure + returns (string memory) + { + return string.concat(getApiKitUrl(self, chainId), "/v2/multisig-transactions/", vm.toString(safeTxHash), "/"); + } + + /// @notice Fetch a proposed transaction from the Safe Transaction Service + /// + /// @param self The Safe client + /// @param safeTxHash The hash of the proposed Safe transaction + /// @return json The raw JSON response of the Transaction Service + function getProposedTransaction(Client storage self, bytes32 safeTxHash) internal returns (string memory json) { + HTTP.Response memory response = instance(self) + .http + .instance() + .GET(getProposedTransactionUrl(self, block.chainid, safeTxHash)). + // The request is reused across calls; clear the body a previous POST set. + withBody("") + .request(); + + if (response.status < 200 || response.status >= 300) { + revert GetProposedTransactionFailed(safeTxHash, response.status, response.data); + } + + return response.data; + } + + /// @notice Check that a proposed transaction matches the transaction the script intends to sign + /// @dev Fetches the proposal from the Transaction Service, then calls {validateProposedTransactionJson}. + /// Reverts with ProposedTransactionMismatch naming the first field that differs. + /// + /// @param self The Safe client + /// @param safeTxHash The hash of the proposed Safe transaction + /// @param to The expected target address + /// @param value The expected value + /// @param data The expected data payload + /// @param operation The expected operation + /// @param nonce The expected nonce + /// @return hashes The hashes recomputed from the expected parameters + function validateProposedTransaction( + Client storage self, + bytes32 safeTxHash, + address to, + uint256 value, + bytes memory data, + Enum.Operation operation, + uint256 nonce + ) internal returns (SafeTxHashes memory hashes) { + string memory json = getProposedTransaction(self, safeTxHash); + return validateProposedTransactionJson(self, json, safeTxHash, to, value, data, operation, nonce); + } + + /// @notice Check that a proposed batch matches the MultiSend payload built from targets and datas + /// @dev Rebuilds the payload with {getProposeTransactionsTargetAndData} and expects DelegateCall and value 0, + /// as {proposeTransactions} does. + function validateProposedTransactions( + Client storage self, + bytes32 safeTxHash, + address[] memory targets, + bytes[] memory datas, + uint256 nonce + ) internal returns (SafeTxHashes memory hashes) { + (address to, bytes memory data) = getProposeTransactionsTargetAndData(self, targets, datas); + return validateProposedTransaction(self, safeTxHash, to, 0, data, Enum.Operation.DelegateCall, nonce); + } + + /// @notice Check an already-fetched Transaction Service response against the expected transaction + /// @dev Compares the Safe, safeTxHash, to, value, data, operation, nonce and the zeroed gas fields + /// this library proposes with, then recomputes the safeTxHash locally. Reverts with + /// ProposedTransactionMismatch naming the first field that differs. Logs the three hashes on success. + /// + /// @param self The Safe client + /// @param json A multisig-transactions response of the Transaction Service + /// @param safeTxHash The hash of the proposed Safe transaction + /// @param to The expected target address + /// @param value The expected value + /// @param data The expected data payload + /// @param operation The expected operation + /// @param nonce The expected nonce + /// @return hashes The hashes recomputed from the expected parameters + function validateProposedTransactionJson( + Client storage self, + string memory json, + bytes32 safeTxHash, + address to, + uint256 value, + bytes memory data, + Enum.Operation operation, + uint256 nonce + ) internal view returns (SafeTxHashes memory hashes) { + _checkBytes32("safeTxHash", safeTxHash, vm.parseJsonBytes32(json, ".safeTxHash")); + _checkAddress("safe", instance(self).safe, vm.parseJsonAddress(json, ".safe")); + _checkAddress("to", to, vm.parseJsonAddress(json, ".to")); + _checkUint("value", value, vm.parseJsonUint(json, ".value")); + _checkBytes("data", data, _parseJsonNullableBytes(json, ".data")); + _checkUint("operation", uint8(operation), vm.parseJsonUint(json, ".operation")); + _checkUint("nonce", nonce, vm.parseJsonUint(json, ".nonce")); + _checkUint("safeTxGas", 0, vm.parseJsonUint(json, ".safeTxGas")); + _checkUint("baseGas", 0, vm.parseJsonUint(json, ".baseGas")); + _checkUint("gasPrice", 0, vm.parseJsonUint(json, ".gasPrice")); + _checkAddress("gasToken", address(0), _parseJsonNullableAddress(json, ".gasToken")); + _checkAddress("refundReceiver", address(0), _parseJsonNullableAddress(json, ".refundReceiver")); + + hashes = getSafeTxHashes(self, to, value, data, operation, nonce); + _checkBytes32("recomputed safeTxHash", hashes.safeTxHash, safeTxHash); + console.log("[safe-utils] proposed transaction matches (nonce %d)", nonce); + logSafeTxHashes(hashes); + } + + /// @dev The Transaction Service returns null for empty data and for the zero gasToken and refundReceiver. + function _parseJsonNullableBytes(string memory json, string memory key) private view returns (bytes memory) { + if (!vm.keyExistsJson(json, key)) return ""; + string memory raw = vm.parseJsonString(json, key); + if (keccak256(bytes(raw)) == keccak256("null")) return ""; + return vm.parseBytes(raw); + } + + function _parseJsonNullableAddress(string memory json, string memory key) private view returns (address) { + if (!vm.keyExistsJson(json, key)) return address(0); + string memory raw = vm.parseJsonString(json, key); + if (keccak256(bytes(raw)) == keccak256("null")) return address(0); + return vm.parseAddress(raw); + } + + function _checkAddress(string memory field, address expected, address actual) private pure { + if (expected != actual) revert ProposedTransactionMismatch(field, vm.toString(expected), vm.toString(actual)); + } + + function _checkUint(string memory field, uint256 expected, uint256 actual) private pure { + if (expected != actual) revert ProposedTransactionMismatch(field, vm.toString(expected), vm.toString(actual)); + } + + function _checkBytes(string memory field, bytes memory expected, bytes memory actual) private pure { + if (keccak256(expected) != keccak256(actual)) { + revert ProposedTransactionMismatch(field, vm.toString(expected), vm.toString(actual)); + } + } + + function _checkBytes32(string memory field, bytes32 expected, bytes32 actual) private pure { + if (expected != actual) revert ProposedTransactionMismatch(field, vm.toString(expected), vm.toString(actual)); + } + // ========================================================================= // Simulation // ========================================================================= @@ -466,6 +679,7 @@ library Safe { revert ProposeTransactionFailed(response.status, response.data); } + logSafeTxHashes(getSafeTxHashes(self, params.to, params.value, params.data, params.operation, params.nonce)); return safeTxHash; } diff --git a/test/SafeValidation.t.sol b/test/SafeValidation.t.sol new file mode 100644 index 0000000..32bb5c4 --- /dev/null +++ b/test/SafeValidation.t.sol @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {Test} from "forge-std/Test.sol"; +import {Safe} from "../src/Safe.sol"; +import {ISafeSmartAccount} from "../src/ISafeSmartAccount.sol"; +import {IWETH} from "./interfaces/IWETH.sol"; +import {Enum} from "safe-smart-account/common/Enum.sol"; + +interface ISafeDomain { + function domainSeparator() external view returns (bytes32); +} + +/// @notice Shared values of the Base proposal in test/fixtures/base-multisig-transaction.json. +/// The fixture is the Transaction Service response for +/// GET /v2/multisig-transactions/0x73ad…cdec/ on Base, fetched on 2026-10-31: a MultiSend batch of two +/// WETH.withdraw(0) calls, nonce 3, proposed for the test Safe used across this suite. +abstract contract BaseProposalFixture is Test { + address constant SAFE_ADDRESS = 0xF3a292Dda3F524EA20b5faF2EE0A1c4abA665e4F; + address constant WETH = 0x4200000000000000000000000000000000000006; + uint256 constant BASE_CHAIN_ID = 8453; + uint256 constant PROPOSAL_NONCE = 3; + + bytes32 constant PROPOSAL_SAFE_TX_HASH = 0x73ad1f3077d266e79c4934abad9c29c90ccfce33c2674a4d0787bb6d4e54cdec; + // Computed with viem 2.57.5 hashDomain / hashStruct; the safeTxHash also matches the Transaction Service. + bytes32 constant PROPOSAL_DOMAIN_SEPARATOR = 0x0fe2094a92e7bdf87c5ea791a6b9179173226e32eb36893655ad6a4792eb8fcc; + bytes32 constant PROPOSAL_MESSAGE_HASH = 0x3350bc3861b7d6833ee7d8d7dff0682d0a66e84d28e0f6f46f4ffbd3cae5fcf0; + + function _batch(uint256 secondAmount) internal pure returns (address[] memory targets, bytes[] memory datas) { + targets = new address[](2); + datas = new bytes[](2); + targets[0] = WETH; + datas[0] = abi.encodeCall(IWETH.withdraw, (0)); + targets[1] = WETH; + datas[1] = abi.encodeCall(IWETH.withdraw, (secondAmount)); + } +} + +/// @notice Hash computation without a fork: known vectors. +contract SafeHashesTest is BaseProposalFixture { + function test_Safe_getSafeTxHashes_matchesViemVector() public pure { + // viem 2.57.5: hashDomain, hashStruct and hashTypedData with the SafeTx type of + // safe-smart-account/src/utils/execution.ts and zeroed gas fields. + Safe.SafeTxHashes memory hashes = Safe.getSafeTxHashes( + 0x1234567890123456789012345678901234567890, + 1, + 0x000000000000000000000000000000000000dEaD, + 1 ether, + hex"deadbeef", + Enum.Operation.DelegateCall, + 42 + ); + assertEq(hashes.domainSeparator, 0x3888d34b69042fc92d822804722836d705e22c56efd96e8e04c0de3ec131831b); + assertEq(hashes.messageHash, 0xd41fefa5aaf04dff0dd6dca13ceb09330bc0a4eaac556a841639271dfb984254); + assertEq(hashes.safeTxHash, 0x1fa90a462d4a2e896cbedc3b223562bf9cbd9981668bd9b6982fa67172703bec); + } + + function test_Safe_getSafeTxHashes_matchesTransactionServiceHash() public { + string memory json = vm.readFile("test/fixtures/base-multisig-transaction.json"); + Safe.SafeTxHashes memory hashes = Safe.getSafeTxHashes( + SAFE_ADDRESS, + BASE_CHAIN_ID, + vm.parseJsonAddress(json, ".to"), + 0, + vm.parseJsonBytes(json, ".data"), + Enum.Operation.DelegateCall, + PROPOSAL_NONCE + ); + assertEq(hashes.domainSeparator, PROPOSAL_DOMAIN_SEPARATOR); + assertEq(hashes.messageHash, PROPOSAL_MESSAGE_HASH); + assertEq(hashes.safeTxHash, PROPOSAL_SAFE_TX_HASH); + } +} + +/// @notice Hash computation against a real Safe v1.4.1 on a Base fork. +contract SafeHashesForkTest is BaseProposalFixture { + using Safe for *; + + Safe.Client safe; + + function setUp() public { + vm.createSelectFork("https://mainnet.base.org"); + safe.initialize(SAFE_ADDRESS); + } + + function test_Safe_getSafeTxHashes_matchesSafeContract() public view { + bytes memory data = abi.encodeCall(IWETH.withdraw, (0)); + uint256 nonce = safe.getNonce(); + Safe.SafeTxHashes memory hashes = safe.getSafeTxHashes(WETH, 0, data, Enum.Operation.Call, nonce); + + assertEq(hashes.domainSeparator, ISafeDomain(SAFE_ADDRESS).domainSeparator()); + assertEq(hashes.safeTxHash, safe.getSafeTxHash(WETH, 0, data, Enum.Operation.Call, nonce)); + } + + function test_Safe_getSafeTxHashes_matchesSafeContractForBatch() public view { + (address[] memory targets, bytes[] memory datas) = _batch(0); + (address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas); + Safe.SafeTxHashes memory hashes = safe.getSafeTxHashes(to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE); + + assertEq(hashes.safeTxHash, safe.getSafeTxHash(to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE)); + assertEq(hashes.safeTxHash, PROPOSAL_SAFE_TX_HASH); + } + + function testFuzz_Safe_getSafeTxHashes_matchesSafeContract( + address to, + uint256 value, + bytes memory data, + bool delegateCall, + uint256 nonce + ) public view { + Enum.Operation operation = delegateCall ? Enum.Operation.DelegateCall : Enum.Operation.Call; + Safe.SafeTxHashes memory hashes = safe.getSafeTxHashes(to, value, data, operation, nonce); + bytes32 expected = ISafeSmartAccount(SAFE_ADDRESS) + .getTransactionHash(to, value, data, operation, 0, 0, 0, address(0), address(0), nonce); + assertEq(hashes.safeTxHash, expected); + } +} + +/// @notice Validation against a fixture response: no fork and no Transaction Service needed. +contract SafeValidationTest is BaseProposalFixture { + using Safe for *; + + Safe.Client safe; + string json; + + function setUp() public { + vm.chainId(BASE_CHAIN_ID); + safe.initialize(SAFE_ADDRESS); + json = vm.readFile("test/fixtures/base-multisig-transaction.json"); + } + + function test_Safe_validateProposedTransactionJson_matches() public view { + (address to, bytes memory data) = _batchTargetAndData(0); + Safe.SafeTxHashes memory hashes = safe.validateProposedTransactionJson( + json, PROPOSAL_SAFE_TX_HASH, to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE + ); + assertEq(hashes.domainSeparator, PROPOSAL_DOMAIN_SEPARATOR); + assertEq(hashes.messageHash, PROPOSAL_MESSAGE_HASH); + assertEq(hashes.safeTxHash, PROPOSAL_SAFE_TX_HASH); + } + + function test_Safe_validateProposedTransactionJson_revertsOnWrongData() public { + (address to, bytes memory data) = _batchTargetAndData(1); + (, bytes memory proposedData) = _batchTargetAndData(0); + vm.expectRevert( + abi.encodeWithSelector( + Safe.ProposedTransactionMismatch.selector, "data", vm.toString(data), vm.toString(proposedData) + ) + ); + this.exposedValidate(to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE); + } + + function test_Safe_validateProposedTransactionJson_revertsOnWrongNonce() public { + (address to, bytes memory data) = _batchTargetAndData(0); + vm.expectRevert(abi.encodeWithSelector(Safe.ProposedTransactionMismatch.selector, "nonce", "4", "3")); + this.exposedValidate(to, 0, data, Enum.Operation.DelegateCall, 4); + } + + function test_Safe_validateProposedTransactionJson_revertsOnWrongTo() public { + (address to, bytes memory data) = _batchTargetAndData(0); + address wrongTo = 0x9641d764fc13c8B624c04430C7356C1C7C8102e2; + vm.expectRevert( + abi.encodeWithSelector( + Safe.ProposedTransactionMismatch.selector, "to", vm.toString(wrongTo), vm.toString(to) + ) + ); + this.exposedValidate(wrongTo, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE); + } + + function test_Safe_validateProposedTransactionJson_revertsOnWrongOperation() public { + (address to, bytes memory data) = _batchTargetAndData(0); + vm.expectRevert(abi.encodeWithSelector(Safe.ProposedTransactionMismatch.selector, "operation", "0", "1")); + this.exposedValidate(to, 0, data, Enum.Operation.Call, PROPOSAL_NONCE); + } + + function test_Safe_validateProposedTransactionJson_revertsOnWrongSafe() public { + // The client uses its latest instance, so this points it at another Safe. + safe.initialize(address(0xBEEF)); + (address to, bytes memory data) = _batchTargetAndData(0); + vm.expectRevert( + abi.encodeWithSelector( + Safe.ProposedTransactionMismatch.selector, + "safe", + vm.toString(address(0xBEEF)), + vm.toString(SAFE_ADDRESS) + ) + ); + this.exposedValidate(to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE); + } + + function test_Safe_validateProposedTransactionJson_revertsOnOtherChain() public { + vm.chainId(1); + (address to, bytes memory data) = (vm.parseJsonAddress(json, ".to"), vm.parseJsonBytes(json, ".data")); + Safe.SafeTxHashes memory onMainnet = + Safe.getSafeTxHashes(SAFE_ADDRESS, 1, to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE); + vm.expectRevert( + abi.encodeWithSelector( + Safe.ProposedTransactionMismatch.selector, + "recomputed safeTxHash", + vm.toString(onMainnet.safeTxHash), + vm.toString(PROPOSAL_SAFE_TX_HASH) + ) + ); + this.exposedValidate(to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE); + } + + function test_Safe_validateProposedTransactionJson_revertsOnOtherHash() public { + bytes32 otherHash = bytes32(uint256(1)); + vm.expectRevert( + abi.encodeWithSelector( + Safe.ProposedTransactionMismatch.selector, + "safeTxHash", + vm.toString(otherHash), + vm.toString(PROPOSAL_SAFE_TX_HASH) + ) + ); + this.exposedValidateHash(otherHash); + } + + /// @dev The Transaction Service returns null, not "0x", for an empty data payload. + function test_Safe_validateProposedTransactionJson_acceptsNullData() public { + Safe.SafeTxHashes memory expected = safe.getSafeTxHashes(WETH, 1 ether, "", Enum.Operation.Call, PROPOSAL_NONCE); + string memory response = vm.replace( + json, + '"to": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D"', + '"to": "0x4200000000000000000000000000000000000006"' + ); + response = vm.replace(response, '"value": "0"', '"value": "1000000000000000000"'); + response = vm.replace(response, '"operation": 1', '"operation": 0'); + response = vm.replace(response, vm.toString(PROPOSAL_SAFE_TX_HASH), vm.toString(expected.safeTxHash)); + response = + vm.replace(response, string.concat('"data": "', vm.parseJsonString(json, ".data"), '"'), '"data": null'); + + Safe.SafeTxHashes memory hashes = safe.validateProposedTransactionJson( + response, expected.safeTxHash, WETH, 1 ether, "", Enum.Operation.Call, PROPOSAL_NONCE + ); + assertEq(hashes.safeTxHash, expected.safeTxHash); + } + + function test_Safe_getProposedTransactionUrl() public view { + assertEq( + safe.getProposedTransactionUrl(BASE_CHAIN_ID, PROPOSAL_SAFE_TX_HASH), + "https://api.safe.global/tx-service/base/api/v2/multisig-transactions/0x73ad1f3077d266e79c4934abad9c29c90ccfce33c2674a4d0787bb6d4e54cdec/" + ); + } + + function exposedValidate(address to, uint256 value, bytes memory data, Enum.Operation operation, uint256 nonce) + external + view + returns (Safe.SafeTxHashes memory) + { + return safe.validateProposedTransactionJson(json, PROPOSAL_SAFE_TX_HASH, to, value, data, operation, nonce); + } + + function exposedValidateHash(bytes32 safeTxHash) external view returns (Safe.SafeTxHashes memory) { + (address to, bytes memory data) = _batchTargetAndData(0); + return safe.validateProposedTransactionJson( + json, safeTxHash, to, 0, data, Enum.Operation.DelegateCall, PROPOSAL_NONCE + ); + } + + function _batchTargetAndData(uint256 secondAmount) private view returns (address, bytes memory) { + (address[] memory targets, bytes[] memory datas) = _batch(secondAmount); + return safe.getProposeTransactionsTargetAndData(targets, datas); + } +} diff --git a/test/fixtures/base-multisig-transaction.json b/test/fixtures/base-multisig-transaction.json new file mode 100644 index 0000000..ab280e4 --- /dev/null +++ b/test/fixtures/base-multisig-transaction.json @@ -0,0 +1,87 @@ +{ + "safe": "0xF3a292Dda3F524EA20b5faF2EE0A1c4abA665e4F", + "to": "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D", + "value": "0", + "data": "0x8d80ff0a000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000f2004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000242e1a7d4d0000000000000000000000000000000000000000000000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000242e1a7d4d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "operation": 1, + "gasToken": null, + "safeTxGas": "0", + "baseGas": "0", + "gasPrice": "0", + "refundReceiver": null, + "nonce": "3", + "executionDate": null, + "submissionDate": "2026-05-12T21:37:13.566404Z", + "modified": "2026-05-12T21:37:13.597945Z", + "blockNumber": null, + "transactionHash": null, + "safeTxHash": "0x73ad1f3077d266e79c4934abad9c29c90ccfce33c2674a4d0787bb6d4e54cdec", + "proposer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "proposedByDelegate": null, + "executor": null, + "isExecuted": false, + "isSuccessful": null, + "ethGasPrice": null, + "maxFeePerGas": null, + "maxPriorityFeePerGas": null, + "gasUsed": null, + "fee": null, + "payment": null, + "origin": "{}", + "dataDecoded": { + "method": "multiSend", + "parameters": [ + { + "name": "transactions", + "type": "bytes", + "value": "0x004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000242e1a7d4d0000000000000000000000000000000000000000000000000000000000000000004200000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000242e1a7d4d0000000000000000000000000000000000000000000000000000000000000000", + "valueDecoded": [ + { + "operation": 0, + "to": "0x4200000000000000000000000000000000000006", + "value": "0", + "data": "0x2e1a7d4d0000000000000000000000000000000000000000000000000000000000000000", + "dataDecoded": { + "method": "withdraw", + "parameters": [ + { + "name": "wad", + "type": "uint256", + "value": "0" + } + ] + } + }, + { + "operation": 0, + "to": "0x4200000000000000000000000000000000000006", + "value": "0", + "data": "0x2e1a7d4d0000000000000000000000000000000000000000000000000000000000000000", + "dataDecoded": { + "method": "withdraw", + "parameters": [ + { + "name": "wad", + "type": "uint256", + "value": "0" + } + ] + } + } + ] + } + ] + }, + "confirmationsRequired": 2, + "confirmations": [ + { + "owner": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "submissionDate": "2026-05-12T21:37:13.597945Z", + "transactionHash": null, + "signature": "0x550cdc3ab7e0157f2616c4dc88245352b17b1bbceea87030f74f80ff5f4115c4781634703be0ca99108deb1e04c7db8f59b4584db74b01935fc565cb016ea1631b", + "signatureType": "EOA" + } + ], + "trusted": true, + "signatures": null +}