Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,9 +196,9 @@ just sign-stack <network> <task> [child-safe-name-depth-1] [child-safe-name-dept

**Environment variables:**
- `HD_PATH` - Hardware wallet derivation path index (default: 0). The value is inserted into the [BIP44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) Ethereum path as `m/44'/60'/$HD_PATH'/0/0` (e.g. `0` -> `m/44'/60'/0'/0/0`, `1` -> `m/44'/60'/1'/0/0`). Use this to select the desired Ethereum account on your hardware wallet.
- `USE_KEYSTORE` - If set, uses keystore instead of ledger. By default, keys are stored under `~/.foundry/keystores`.
- `SIMULATE_WITHOUT_WALLET` - Set to run the simulation phase without a wallet (i.e. Ledger/Trezor/KeyStore). This step will simulate a task with valid, default keys.
- `WALLET_TYPE` - This can be either `ledger`, `trezor` or `keystore`. The default value is `ledger`.
- `WALLET_TYPE` - This can be either `ledger`, `trezor` or `keystore` (By default, keys are stored under `~/.foundry/keystores`). The default value is `ledger`.
- `EIP712_ENCODED_DATA_TO_SIGN` - If set the encoded EIP-712 typed data JSON is used for signing; when unset, only the EIP-712 domain and message hashes (default) are used for signing.

**Examples:**

Expand Down
5 changes: 5 additions & 0 deletions src/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,11 @@ _get-signer-args hdPath='0' wallet_type='ledger':
root_dir=$(git rev-parse --show-toplevel)
root_just_file="${root_dir}/src/justfile"

# The 'eip712sign' library requires EIP712 typed data to be supplied for Trezor devices.
if [ "{{wallet_type}}" = "trezor" ]; then
export EIP712_ENCODED_DATA_TO_SIGN=true
fi

if [ "{{wallet_type}}" != "keystore" ]; then # either 'ledger' or 'trezor'
hdpaths="m/44'/60'/{{hdPath}}'/0/0"
echo "Using {{wallet_type}}" >&2
Expand Down
134 changes: 117 additions & 17 deletions src/libraries/GnosisSafeHashes.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {GnosisSafe} from "lib/safe-contracts/contracts/GnosisSafe.sol";
import {IMulticall3} from "forge-std/interfaces/IMulticall3.sol";
import {VmSafe} from "forge-std/Vm.sol";
import {IGnosisSafe, Enum} from "@base-contracts/script/universal/IGnosisSafe.sol";
import {Vm} from "forge-std/Vm.sol";
import {stdJson} from "forge-std/StdJson.sol";

/// @title GnosisSafeHashes
/// @notice Library for calculating domain separators and message hashes for Gnosis Safe transactions
Expand Down Expand Up @@ -164,27 +166,75 @@ library GnosisSafeHashes {
}

/// @notice Reads the result of a call to Safe.encodeTransactionData and returns the message hash.
function getDomainAndMessageHashFromEncodedTransactionData(bytes memory _encodedTxData)
function getDomainAndMessageHashFromDataToSign(bytes memory _dataToSign)
internal
pure
view
returns (bytes32 domainSeparator_, bytes32 messageHash_)
{
require(_encodedTxData.length == 66, "GnosisSafeHashes: Invalid encoded transaction data length.");
require(_encodedTxData[0] == bytes1(0x19), "GnosisSafeHashes: Expected prefix byte 0x19.");
require(_encodedTxData[1] == bytes1(0x01), "GnosisSafeHashes: Expected prefix byte 0x01.");

// Memory layout of a `bytes` array in Solidity:
// - The first 32 bytes store the array length (66 bytes here).
// - The actual data starts immediately after the length.
// Our data structure is:
// [0x19][0x01][32-byte domainSeparator][32-byte messageHash]
// The message hash begins at offset: 32 (skip length) + 34 = 66.
assembly {
// Domain separator starts after 2-byte prefix (offset 34 in bytes array)
domainSeparator_ := mload(add(_encodedTxData, 34))
// Message hash starts at offset 66 (after domain separator)
messageHash_ := mload(add(_encodedTxData, 66))
// If it looks like 0x1901-prefixed encoded bytes (66 bytes total), decode directly.
if (_dataToSign.length == 66 && _dataToSign[0] == bytes1(0x19) && _dataToSign[1] == bytes1(0x01)) {
// Memory layout of a `bytes` array in Solidity:
// - The first 32 bytes store the array length (66 bytes here).
// - The actual data starts immediately after the length.
// Our data structure is:
// [0x19][0x01][32-byte domainSeparator][32-byte messageHash]
// The message hash begins at offset: 32 (skip length) + 34 = 66.
assembly {
domainSeparator_ := mload(add(_dataToSign, 34))
messageHash_ := mload(add(_dataToSign, 66))
}
return (domainSeparator_, messageHash_);
}
// Otherwise, assume EIP-712 JSON produced by encodeEIP712Json and compute from JSON.
return getDomainAndMessageHashFromEip712Json(_dataToSign);
}

/// @notice Computes domain separator and message hash from an EIP-712 JSON payload produced by encodeEIP712Json.
/// @dev This mirrors the Safe EIP-712 encoding: dynamic types such as bytes are hashed before struct encoding.
function getDomainAndMessageHashFromEip712Json(bytes memory _json)
internal
view
returns (bytes32 domainSeparator_, bytes32 messageHash_)
{
string memory json = string(_json);
require(bytes(json).length != 0, "GnosisSafeHashes: empty EIP-712 JSON");

// Domain fields
uint256 chainId = stdJson.readUint(json, ".domain.chainId");
address verifyingContract = stdJson.readAddress(json, ".domain.verifyingContract");
require(verifyingContract != address(0), "GnosisSafeHashes: verifyingContract is zero");
// Compute domain separator according to the JSON schema used in encodeEIP712Json
domainSeparator_ = calculateDomainSeparator(chainId, verifyingContract);

// Message fields
address to = stdJson.readAddress(json, ".message.to");
require(to != address(0), "GnosisSafeHashes: to is zero");
uint256 value = stdJson.readUint(json, ".message.value");
bytes memory data = stdJson.readBytes(json, ".message.data");
uint8 operation = uint8(stdJson.readUint(json, ".message.operation"));
require(operation == 1, "GnosisSafeHashes: invalid operation, only DelegateCall is supported");
uint256 safeTxGas = stdJson.readUint(json, ".message.safeTxGas");
uint256 baseGas = stdJson.readUint(json, ".message.baseGas");
uint256 gasPrice = stdJson.readUint(json, ".message.gasPrice");
address gasToken = stdJson.readAddress(json, ".message.gasToken");
address refundReceiver = stdJson.readAddress(json, ".message.refundReceiver");
uint256 nonce = stdJson.readUint(json, ".message.nonce");
bytes32 dataHash = keccak256(data);
messageHash_ = keccak256(
abi.encode(
SAFE_TX_TYPEHASH,
to,
value,
dataHash,
operation,
safeTxGas,
baseGas,
gasPrice,
gasToken,
refundReceiver,
nonce
)
);
}

/// @notice Helper to decode multicall calldata and extract approveHash parameter.
Expand Down Expand Up @@ -274,4 +324,54 @@ library GnosisSafeHashes {
)
);
}

/// @notice Struct for a Safe transaction. Used as the EIP-712 hash struct.
struct SafeTransaction {
address to;
uint256 value;
bytes data;
uint8 operation;
uint256 safeTxGas;
uint256 baseGas;
uint256 gasPrice;
address gasToken;
address refundReceiver;
uint256 nonce;
}

/// @notice Encodes the EIP-712 JSON for a Safe transaction. Taken and modified from:
/// https://github.com/base/contracts/pull/148/files#diff-72074ac05b528d57b61e77e3e0c6796a09cd56c4966a3d88a2c086b4a539ffce
function encodeEIP712Json(address _multicallTarget, address _safe, SafeTransaction memory _safeTx)
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", _multicallTarget);
message = stdJson.serialize("message", "value", _safeTx.value);
message = stdJson.serialize("message", "data", _safeTx.data);
message = stdJson.serialize("message", "operation", uint256(_safeTx.operation));
message = stdJson.serialize("message", "safeTxGas", uint256(_safeTx.safeTxGas));
message = stdJson.serialize("message", "baseGas", uint256(_safeTx.baseGas));
message = stdJson.serialize("message", "gasPrice", uint256(_safeTx.gasPrice));
message = stdJson.serialize("message", "gasToken", address(_safeTx.gasToken));
message = stdJson.serialize("message", "refundReceiver", address(_safeTx.refundReceiver));
message = stdJson.serialize("message", "nonce", _safeTx.nonce);

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);
}
}
6 changes: 4 additions & 2 deletions src/libraries/MultisigTaskPrinter.sol
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,10 @@ library MultisigTaskPrinter {
}

/// @notice Prints encoded transaction data with formatted header and footer and instructions for signers.
/// @param dataToSign The encoded transaction data to sign.
function printEncodedTransactionData(bytes memory dataToSign) internal view {
/// @param dataToSign The encoded transaction data to sign. This can be encoded either as:
/// 1. EIP-712 Json string
/// 2. 0x1901<32-byte domainSeparator><32-byte messageHash>
function printDataToSign(bytes memory dataToSign) internal view {
// NOTE: Do not change the vvvvvvvv and ^^^^^^^^ lines, as the eip712sign tool explicitly
// looks for those specific lines to identify the data to sign.
printTitle("DATA TO SIGN");
Expand Down
8 changes: 8 additions & 0 deletions src/libraries/Utils.sol
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ library Utils {
return false;
}

/// @notice 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, set the environment variable accordingly.
/// We default to printing the hashes and not the structured data.
function printDataHashes() internal view returns (bool) {
return !isFeatureEnabled("EIP712_ENCODED_DATA_TO_SIGN");
}

/// @notice Checks that values have code on this chain.
/// This method is not storage-layout-aware and therefore is not perfect. It may return erroneous
/// results for cases like packed slots, and silently show that things are okay when they are not.
Expand Down
44 changes: 38 additions & 6 deletions src/tasks/MultisigTask.sol
Original file line number Diff line number Diff line change
Expand Up @@ -797,10 +797,8 @@ abstract contract MultisigTask is Test, Script, StateOverrideManager, TaskManage
MultisigTaskPrinter.printTitle(string.concat("Safe (Depth: ", vm.toString(level), ")"));
console.log("Safe Address: ", MultisigTaskPrinter.getAddressLabel(payload.safes[i]));
console.log("Safe Hash: ", vm.toString(safeHash));
address multicallAddress = _getMulticallAddress(payload.safes[i], payload.safes);
dataToSign_ = GnosisSafeHashes.getEncodedTransactionData(
payload.safes[i], payload.calldatas[i], 0, payload.originalNonces[i], multicallAddress
);
dataToSign_ =
_getDataToSign(payload.calldatas[i], payload.safes[i], payload.originalNonces[i], payload.safes);

bool isLastTask = i == 0;
if (isLastTask) {
Expand All @@ -814,13 +812,47 @@ abstract contract MultisigTask is Test, Script, StateOverrideManager, TaskManage
MultisigTaskPrinter.printNormalizedStateDiffHash(normalizedHash_);
}

/// @notice Builds the data that signers should sign for a given safe and call.
/// If Utils.printDataHashes() is true, returns the encoded transaction data; otherwise returns EIP-712 JSON.
function _getDataToSign(bytes memory callData, address safe, uint256 originalNonce, address[] memory allSafes)
internal
returns (bytes memory dataToSign_)
{
address multicallAddress = _getMulticallAddress(safe, allSafes);
bytes memory dataToSignDataHashes =
GnosisSafeHashes.getEncodedTransactionData(safe, callData, 0, originalNonce, multicallAddress);
bytes memory dataToSignJson = GnosisSafeHashes.encodeEIP712Json(
multicallAddress,
safe,
GnosisSafeHashes.SafeTransaction({
to: multicallAddress,
value: 0,
data: callData,
operation: uint8(Enum.Operation.DelegateCall),
safeTxGas: 0,
baseGas: 0,
gasPrice: 0,
gasToken: address(0),
refundReceiver: address(0),
nonce: originalNonce
})
);
(bytes32 domainHashFromData, bytes32 messageHashFromData) =
GnosisSafeHashes.getDomainAndMessageHashFromDataToSign(dataToSignDataHashes);
(bytes32 domainHashFromJson, bytes32 messageHashFromJson) =
GnosisSafeHashes.getDomainAndMessageHashFromDataToSign(dataToSignJson);
require(domainHashFromData == domainHashFromJson, "MultisigTask: EIP712 domain hash does not match.");
require(messageHashFromData == messageHashFromJson, "MultisigTask: EIP712 message hash does not match.");
return Utils.printDataHashes() ? dataToSignDataHashes : dataToSignJson;
}

/// @notice Helper function to print the final safe information.
function _printLastSafe(bytes memory dataToSign, address rootSafe, TaskPayload memory payload) private view {
(bytes32 domainSeparator, bytes32 messageHash) =
GnosisSafeHashes.getDomainAndMessageHashFromEncodedTransactionData(dataToSign);
GnosisSafeHashes.getDomainAndMessageHashFromDataToSign(dataToSign);
console.log("Domain Hash: ", vm.toString(domainSeparator));
console.log("Message Hash: ", vm.toString(messageHash));
MultisigTaskPrinter.printEncodedTransactionData(dataToSign);
MultisigTaskPrinter.printDataToSign(dataToSign);
address rootMulticallTarget = _getMulticallAddress(rootSafe, payload.safes);
address childMulticallTarget =
payload.safes.length > 1 ? _getMulticallAddress(payload.safes[0], payload.safes) : address(0);
Expand Down
2 changes: 1 addition & 1 deletion src/tasks/TaskManager.sol
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ contract TaskManager is Script {
function checkDataToSign(bytes memory _dataToSign, TaskConfig memory _config) public view returns (bool) {
string memory message = "Please check that you've added it to the VALIDATION markdown file.";
(bytes32 domainSeparator, bytes32 messageHash) =
GnosisSafeHashes.getDomainAndMessageHashFromEncodedTransactionData(_dataToSign);
GnosisSafeHashes.getDomainAndMessageHashFromDataToSign(_dataToSign);
bytes memory domainHashBytes = abi.encodePacked(domainSeparator);
bytes memory messageHashBytes = abi.encodePacked(messageHash);
bool containsDomainHash =
Expand Down
Loading