diff --git a/README.md b/README.md index 1485c59..4cea8c1 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,27 @@ bytes memory batchExecData = safe.getExecTransactionsDataWithSignatures(targets, Both paths can be mixed in one `bytes[]`. Batches must be signed with `Enum.Operation.DelegateCall`. `combineSignatures(safeTxHash, signatures)` returns the combined `signatures` bytes on their own. +#### 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 beb7e44..31fed4f 100644 --- a/src/Safe.sol +++ b/src/Safe.sol @@ -29,6 +29,12 @@ 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); @@ -38,6 +44,8 @@ library Safe { error SignerNotOwner(address signer); error DuplicateSigner(address signer); error NotEnoughSignatures(uint256 provided, uint256 threshold); + error GetProposedTransactionFailed(bytes32 safeTxHash, uint256 statusCode, string response); + error ProposedTransactionMismatch(string field, string expected, string actual); struct Instance { address safe; @@ -67,6 +75,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]; @@ -255,6 +271,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 // ========================================================================= @@ -500,6 +713,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 +}