
Gnosis Safe Internals — Part 2 — Ownership
We have seen in part 1 that a Gnosis Safe wallet is only a proxy contract that redirects all transaction to an implementation contract.Owners and thresholdThe implementation contract source code can be found on github.contract Safe is Singleton, NativeCurrencyPaymentFallback, ModuleManager, GuardManager, OwnerManager, SignatureDecoder, SecuredTokenTransfer, ISignatureValidatorConstants, FallbackManager, StorageAccessible, ISafe { The Safe contracts inherit from multiple other contracts, each ...

Gnosis Safe Internals — Part 1 — SafeProxy
The recent Bybit hack was a wake-up call, reminding everyone that security is never guaranteed and good practices are essential. A hardware wallet is crucial for protecting private keys from malware, but it’s not enough if we blindly sign whatever data our computer presents. This blog series explores the Gnosis Safe wallet and how it works, giving readers the chance to use it directly on-chain without relying on a front-end. If you want to follow along and experiment, I highly recommend insta...
🕵️♂️ Hi! I'm a passionate blockchain enthusiast dedicating my time to discover and address security bugs.

Gnosis Safe Internals — Part 2 — Ownership
We have seen in part 1 that a Gnosis Safe wallet is only a proxy contract that redirects all transaction to an implementation contract.Owners and thresholdThe implementation contract source code can be found on github.contract Safe is Singleton, NativeCurrencyPaymentFallback, ModuleManager, GuardManager, OwnerManager, SignatureDecoder, SecuredTokenTransfer, ISignatureValidatorConstants, FallbackManager, StorageAccessible, ISafe { The Safe contracts inherit from multiple other contracts, each ...

Gnosis Safe Internals — Part 1 — SafeProxy
The recent Bybit hack was a wake-up call, reminding everyone that security is never guaranteed and good practices are essential. A hardware wallet is crucial for protecting private keys from malware, but it’s not enough if we blindly sign whatever data our computer presents. This blog series explores the Gnosis Safe wallet and how it works, giving readers the chance to use it directly on-chain without relying on a front-end. If you want to follow along and experiment, I highly recommend insta...
Share Dialog
Share Dialog
🕵️♂️ Hi! I'm a passionate blockchain enthusiast dedicating my time to discover and address security bugs.

Subscribe to Cizeon

Subscribe to Cizeon


Finally, we’re diving into message signing. The core of the Gnosis Safe wallet. By the end of this article, you’ll be able to craft your own transactions and use a Gnosis Safe wallet without relying on any front-end! Freeeeeedom! :-)
Check out the previous articles from this series:
Let’s go back to Safe.sol and look at the execTransaction() function. This function executes the desired transaction on-chain, provided all necessary requirements are met.
function execTransaction(
address to,
uint256 value,
bytes calldata data,
Enum.Operation operation,
uint256 safeTxGas,
uint256 baseGas,
uint256 gasPrice,
address gasToken,
address payable refundReceiver,
bytes memory signatures
) external payable override returns (bool success) {
The arguments are straightforward:
To: The address of the smart contract the wallet will interact with.
Data: The input data for the transaction.
Operation: 0 for a standard call, 1 for a delegatecall (be careful).
Gas-related variables: Parameters controlling gas usage.
Signatures: Approvals required to authorize the transaction.
The first step is to compute the transaction hash. This is what is signed by the owners at the end.
txHash = getTransactionHash( // Transaction info
to,
value,
data,
operation,
safeTxGas,
// Payment info
baseGas,
gasPrice,
gasToken,
refundReceiver,
// Signature info
// We use the post-increment here, so the current nonce value is used and incremented afterwards.
nonce++
);
This is nearly all the previous parameters and a nonce variable.
Let’s say we want to send 1 USDC on the Gnosis Safe chain to:
cizeon.eth — 0xffF4aA9E37a3661db92d550936E5e27442aa5fa6
The to argument is the address of the USDC token on the Gnosis chain:
Since the second article in this series, we’ve learned how to construct the calldata:
$ cast calldata "transfer(address,uint256)" 0xffF4aA9E37a3661db92d550936E5e27442aa5fa6 1000000
0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240
The operation variable is set to 0 since we’re making a standard call.
We’ll set all gas-related variables to 0 and set gasToken and refundReceiver to address(0).
To approve this transaction, we could simply sign all these parameters. This involves an owner using the private key of their externally owned address (EOA), their personal wallet, to generate a digital signature, ensuring the transaction’s authenticity and integrity.
However, this would be highly insecure. The recipient, cizeon.eth, could retrieve the transaction and its signature, then replay it to receive the USDC again and again. To prevent this replay attack, a nonce is used. A nonce is a unique number for each transaction, ensuring that once a transaction is executed, it cannot be used again.
To retrieve the nonce value to use, we can fetch it from the Safe wallet.
$ export ETH_RPC_URL=https://rpc.gnosischain.com
$ cast call 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D "nonce()"
0x0000000000000000000000000000000000000000000000000000000000000001
The nonce value here is one, this will be the first transaction from this Safe wallet.
Now, let’s explore this getTransactionHash() function. It follows the EIP-712 specifications for hashing typed data structures. However, we’ll go through this function line by line for a detailed explanation.
bytes32 domainHash = domainSeparator();
First, it retrieves a domain hash from the domainSeparator() public view function.
// keccak256(
// "EIP712Domain(uint256 chainId,address verifyingContract)"
// );
bytes32 private constant DOMAIN_SEPARATOR_TYPEHASH = 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218;
function domainSeparator() public view override returns (bytes32) {
uint256 chainId;
/* solhint-disable no-inline-assembly */
/// @solidity memory-safe-assembly
assembly {
chainId := chainid()
}
/* solhint-enable no-inline-assembly */
return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, this));
}
The DOMAIN_SEPARATOR_TYPEHASH is just a hash of the EIP712Domain.
$ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ keccak256("EIP712Domain(uint256 chainId,address verifyingContract)")
Type: bytes32
└ Data: 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218
The chainId in Ethereum is a unique identifier for each Ethereum network or blockchain. If you recall the replay attack explained earlier, cizeon.eth could attempt to replay the transaction, even with the correct nonce, but on a different EVM blockchain. Assuming the target smart contract exists on that other chain. Which could also be malicious!
We can retrieve the chain id using cast.
$ export ETH_RPC_URL=https://rpc.gnosischain.com
$ cast chain-id
100
Lastly, we need the address of the Safe wallet. Again, we want to prevent cizeon.eth from replaying the transaction. Even with the correct nonce value and on the correct chain, if it targets a different Safe wallet owned by the same owner.
We can compute the domainHash ourselves.
$ chisel
➜ keccak256(abi.encode(0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218, 100, 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D))
Type: bytes32
└ Data: 0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646
Since the domainSeparator() function is a public view, we could also have retrieve the domain hash from the wallet.
$ cast call 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D "domainSeparator()"
0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646
However, it’s better to do the maximum possible off-chain. Hopefully, the hashes match :-).
The domain hash is 0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646
Before we return to the getTransactionHash() function, we need an additional key value: the SAFE_TX_TYPEHASH.
// keccak256(
// "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"
// );
bytes32 private constant SAFE_TX_TYPEHASH = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8;
The rest of the getTransactionHash() function is written in assembly.
calldatacopy(ptr, data.offset, data.length)
let calldataHash := keccak256(ptr, data.length)
First, it computes a hash of the calldata, which we crafted earlier to send 1 USDC to cizeon.eth.
$ chisel
keccak256(hex"a9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240")
Type: bytes32
└ Data: 0x1133c5be11ee6385513e229829a8b3176a52ee02943ed2406ddf25beffe0b7ec
It then combines all the variables and the SAFE_TX_TYPEHASH into a single packed structure.
mstore(ptr, SAFE_TX_TYPEHASH)
mstore(add(ptr, 32), to)
mstore(add(ptr, 64), value)
mstore(add(ptr, 96), calldataHash)
mstore(add(ptr, 128), operation)
mstore(add(ptr, 160), safeTxGas)
mstore(add(ptr, 192), baseGas)
mstore(add(ptr, 224), gasPrice)
mstore(add(ptr, 256), gasToken)
mstore(add(ptr, 288), refundReceiver)
mstore(add(ptr, 320), _nonce)
The message hash is the result of hashing this structure. We can use chisel again to compute the hash of this structure ourselves.
$ chisel
➜ bytes32 safe_tx_typehash = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8;
➜ address to = 0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0;
➜ uint256 value = 0;
➜ bytes32 data_hashed = 0x1133c5be11ee6385513e229829a8b3176a52ee02943ed2406ddf25beffe0b7ec;
➜ uint8 operation = 0;
➜ uint256 safe_tx_gas = 0;
➜ uint256 base_gas = 0;
➜ uint256 gas_price = 0;
➜ address gas_token = address(0);
➜ address refund_receiver = address(0);
➜ uint256 nonce = 1;
➜ keccak256(abi.encode(safe_tx_typehash,to,value,data_hashed,operation,safe_tx_gas,base_gas,gas_price,gas_token,refund_receiver,nonce))
Type: bytes32
└ Data: 0x170fb87d7e389e2d5a22da94d29141f5e15bc4d316decc4d182060c8c2db84ba
The message hash is 0x170fb87d7e389e2d5a22da94d29141f5e15bc4d316decc4d182060c8c2db84ba
Finally, we need to combine this hash with the EIP-712 prefix and the domain hash, as shown in the getTransactionHash() function.
// Step 3: Calculate the final EIP-712 hash
// First, hash the SafeTX struct (352 bytes total length)
mstore(add(ptr, 64), keccak256(ptr, 352))
// Store the EIP-712 prefix (0x1901), note that integers are left-padded
// so the EIP-712 encoded data starts at add(ptr, 30)
mstore(ptr, 0x1901)
// Store the domain separator
mstore(add(ptr, 32), domainHash)
// Calculate the hash
txHash := keccak256(add(ptr, 30), 66)
Chisel to the rescue again. Note that here we need to use abi.encodePacked() and not abi.encode().
$ chisel
➜ bytes2 prefix = 0x1901;
➜ bytes32 domain_hash = 0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646;
➜ bytes32 message_hash = 0x170fb87d7e389e2d5a22da94d29141f5e15bc4d316decc4d182060c8c2db84ba;
➜ keccak256(abi.encodePacked(prefix,domain_hash,message_hash))
Type: bytes32
└ Data: 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a
The txHash is 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a
This is all we need to sign the transaction :-)
The sub command with cast is wallet sign. There are several ways to use the private key:
With -i (interactive mode), you simply need to copy and paste the private key.
Passing the private key or mnemonic as an argument, though this is not recommended.
Storing the key on the filesystem and protecting it with a password using cast wallet import.
$ cast wallet sign 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a -i --no-hash
0x75c0a770c7b65c23fa711c2c6e20332e7fa5c41f46e8f6cc0a089354974bed7d29f6b8e6606d2c11c3f03e7f406678bf40be41fa686d631a92a698becf7ef1df1c
With the signature, we can ask the Gnosis Safe wallet to execute the transaction. I suggest that we simulate the transaction first. This will be done forking the Gnosis chain locally. For that, we will use anvil, also part of the Foundry tool suite.
$ anvil --fork-url https://rpc.gnosischain.com
This creates a new local RPC on 127.0.0.1:8545 by default.
Let’s simulate the final transaction:
export ETH_RPC_URL=http://127.0.0.1:8545
Since the command line is quite long, I recommend creating a simple script to streamline the process. The trace argument will be valuable for understanding the simulated transaction.
export ETH_RPC_URL=http://127.0.0.1:8545
SAFE_WALLET=0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D
TO=0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0
VALUE=0
DATA=0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240
OPERATION=0
SAFE_TX_GAS=0
BASE_GAS=0
GAS_PRICE=0
GAS_TOKEN=0x0000000000000000000000000000000000000000
REFUND_RECEIVER=0x0000000000000000000000000000000000000000
SIGNATURE=0x75c0a770c7b65c23fa711c2c6e20332e7fa5c41f46e8f6cc0a089354974bed7d29f6b8e6606d2c11c3f03e7f406678bf40be41fa686d631a92a698becf7ef1df1c
cast call --trace \
$SAFE_WALLET \
"execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" \
$TO \
$VALUE \
$DATA \
$OPERATION \
$SAFE_TX_GAS \
$BASE_GAS \
$GAS_PRICE \
$GAS_TOKEN \
$REFUND_RECEIVER \
$SIGNATURE
The result is quite verbose, so I’ve only selected the most relevant part.
❯ sh exec.sh
Traces:
[59797]
[...]
0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0::transfer(0xffF4aA9E37a3661db92d550936E5e27442aa5fa6, 1000000 [1e6])
│ │ ├─ [16263] 0x107CF7fb73EA48D1D200989b156Ce1894d7AfEC7::transfer(0xffF4aA9E37a3661db92d550936E5e27442aa5fa6, 1000000 [1e6]) [delegatecall]
│ │ │ ├─ emit Transfer(param0: 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D, param1: 0xffF4aA9E37a3661db92d550936E5e27442aa5fa6, param2: 1000000 [1e6])
[...]
└─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000001
Transaction successfully executed.
Gas used: 79781
We can see the call to USDC::transfer() with cizeon.eth as the recipient for 1 USDC. The final return value is 1, indicating success.
Now, it’s time to execute this on-chain. Instead of using cast call, we’ll use cast send and provide a private key.
export ETH_RPC_URL=https://rpc.gnosischain.com
SAFE_WALLET=0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D
TO=0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0
VALUE=0
DATA=0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240
OPERATION=0
SAFE_TX_GAS=0
BASE_GAS=0
GAS_PRICE=0
GAS_TOKEN=0x0000000000000000000000000000000000000000
REFUND_RECEIVER=0x0000000000000000000000000000000000000000
SIGNATURE=0x75c0a770c7b65c23fa711c2c6e20332e7fa5c41f46e8f6cc0a089354974bed7d29f6b8e6606d2c11c3f03e7f406678bf40be41fa686d631a92a698becf7ef1df1c
cast send --interactive \
$SAFE_WALLET \
"execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" \
$TO \
$VALUE \
$DATA \
$OPERATION \
$SAFE_TX_GAS \
$BASE_GAS \
$GAS_PRICE \
$GAS_TOKEN \
$REFUND_RECEIVER \
$SIGNATURE
Once again, the output of the command is quite verbose, so I’ve only selected the relevant part.
The full transaction can be viewed on GnosisScan.
For this article, we used a private key in interactive mode. This is not recommended for production, as if your computer is compromised, malware could access the private key from memory and sign transactions on your behalf. Instead, using a hardware wallet is highly recommended.
Personally, I only own a Ledger device, so I’ve focused on signing with a Ledger key.
Directly signing the safeTxHash is not supported by the Ledger key.
$ cast wallet sign 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a --ledger --no-hash
Error: operation `sign_hash` is not supported by the signer
However, if provided in JSON format, the Ledger device understand EIP-712 and will gladly compute the message hash and the safeTxHash for you.
{
"domain": {
"chainId": 100,
"verifyingContract": "0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D"
},
"message": {
"to": "0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0",
"value": 0,
"data": "0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240",
"operation": 0,
"safeTxGas": 0,
"baseGas": 0,
"gasPrice": 0,
"gasToken": "0x0000000000000000000000000000000000000000",
"refundReceiver": "0x0000000000000000000000000000000000000000",
"nonce": 1
},
"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" }
]
}
}
$ cast wallet sign --ledger --mnemonic-index 1 --data --from-file transaction.json
0x8a14302c34d390ef6bee8291181677fd1dbbe9a483b1d0c1207b487cb58f54a463bd194075e2917a80258d6596b391ada5831daf4d5bc84b2e6d5d884cb242501b
Make sure to verify the domain hash and message hash displayed on the Ledger with the ones you’ve computed yourself.
And you now have the signature for your transaction: 0x8a14302c34d390ef6bee8291181677fd1dbbe9a483b1d0c1207b487cb58f54a463bd194075e2917a80258d6596b391ada5831daf4d5bc84b2e6d5d884cb242501b
If we had used the official Gnosis front-end, we would have obtained the same information.

The USDC recipient
The amount of USDC to send
The address of the USDC smart contract
Operation: call
The same domain hash as we have computed ourselves
The same message hash
The same safeTxHash
And lastly, the same signature from the Ledger key.
That’s it for today! I hope you had as much fun reading this as I did writing it. In a future article, we’ll dive into how signature verification is handled in the smart contract.
Finally, we’re diving into message signing. The core of the Gnosis Safe wallet. By the end of this article, you’ll be able to craft your own transactions and use a Gnosis Safe wallet without relying on any front-end! Freeeeeedom! :-)
Check out the previous articles from this series:
Let’s go back to Safe.sol and look at the execTransaction() function. This function executes the desired transaction on-chain, provided all necessary requirements are met.
function execTransaction(
address to,
uint256 value,
bytes calldata data,
Enum.Operation operation,
uint256 safeTxGas,
uint256 baseGas,
uint256 gasPrice,
address gasToken,
address payable refundReceiver,
bytes memory signatures
) external payable override returns (bool success) {
The arguments are straightforward:
To: The address of the smart contract the wallet will interact with.
Data: The input data for the transaction.
Operation: 0 for a standard call, 1 for a delegatecall (be careful).
Gas-related variables: Parameters controlling gas usage.
Signatures: Approvals required to authorize the transaction.
The first step is to compute the transaction hash. This is what is signed by the owners at the end.
txHash = getTransactionHash( // Transaction info
to,
value,
data,
operation,
safeTxGas,
// Payment info
baseGas,
gasPrice,
gasToken,
refundReceiver,
// Signature info
// We use the post-increment here, so the current nonce value is used and incremented afterwards.
nonce++
);
This is nearly all the previous parameters and a nonce variable.
Let’s say we want to send 1 USDC on the Gnosis Safe chain to:
cizeon.eth — 0xffF4aA9E37a3661db92d550936E5e27442aa5fa6
The to argument is the address of the USDC token on the Gnosis chain:
Since the second article in this series, we’ve learned how to construct the calldata:
$ cast calldata "transfer(address,uint256)" 0xffF4aA9E37a3661db92d550936E5e27442aa5fa6 1000000
0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240
The operation variable is set to 0 since we’re making a standard call.
We’ll set all gas-related variables to 0 and set gasToken and refundReceiver to address(0).
To approve this transaction, we could simply sign all these parameters. This involves an owner using the private key of their externally owned address (EOA), their personal wallet, to generate a digital signature, ensuring the transaction’s authenticity and integrity.
However, this would be highly insecure. The recipient, cizeon.eth, could retrieve the transaction and its signature, then replay it to receive the USDC again and again. To prevent this replay attack, a nonce is used. A nonce is a unique number for each transaction, ensuring that once a transaction is executed, it cannot be used again.
To retrieve the nonce value to use, we can fetch it from the Safe wallet.
$ export ETH_RPC_URL=https://rpc.gnosischain.com
$ cast call 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D "nonce()"
0x0000000000000000000000000000000000000000000000000000000000000001
The nonce value here is one, this will be the first transaction from this Safe wallet.
Now, let’s explore this getTransactionHash() function. It follows the EIP-712 specifications for hashing typed data structures. However, we’ll go through this function line by line for a detailed explanation.
bytes32 domainHash = domainSeparator();
First, it retrieves a domain hash from the domainSeparator() public view function.
// keccak256(
// "EIP712Domain(uint256 chainId,address verifyingContract)"
// );
bytes32 private constant DOMAIN_SEPARATOR_TYPEHASH = 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218;
function domainSeparator() public view override returns (bytes32) {
uint256 chainId;
/* solhint-disable no-inline-assembly */
/// @solidity memory-safe-assembly
assembly {
chainId := chainid()
}
/* solhint-enable no-inline-assembly */
return keccak256(abi.encode(DOMAIN_SEPARATOR_TYPEHASH, chainId, this));
}
The DOMAIN_SEPARATOR_TYPEHASH is just a hash of the EIP712Domain.
$ chisel
Welcome to Chisel! Type `!help` to show available commands.
➜ keccak256("EIP712Domain(uint256 chainId,address verifyingContract)")
Type: bytes32
└ Data: 0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218
The chainId in Ethereum is a unique identifier for each Ethereum network or blockchain. If you recall the replay attack explained earlier, cizeon.eth could attempt to replay the transaction, even with the correct nonce, but on a different EVM blockchain. Assuming the target smart contract exists on that other chain. Which could also be malicious!
We can retrieve the chain id using cast.
$ export ETH_RPC_URL=https://rpc.gnosischain.com
$ cast chain-id
100
Lastly, we need the address of the Safe wallet. Again, we want to prevent cizeon.eth from replaying the transaction. Even with the correct nonce value and on the correct chain, if it targets a different Safe wallet owned by the same owner.
We can compute the domainHash ourselves.
$ chisel
➜ keccak256(abi.encode(0x47e79534a245952e8b16893a336b85a3d9ea9fa8c573f3d803afb92a79469218, 100, 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D))
Type: bytes32
└ Data: 0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646
Since the domainSeparator() function is a public view, we could also have retrieve the domain hash from the wallet.
$ cast call 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D "domainSeparator()"
0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646
However, it’s better to do the maximum possible off-chain. Hopefully, the hashes match :-).
The domain hash is 0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646
Before we return to the getTransactionHash() function, we need an additional key value: the SAFE_TX_TYPEHASH.
// keccak256(
// "SafeTx(address to,uint256 value,bytes data,uint8 operation,uint256 safeTxGas,uint256 baseGas,uint256 gasPrice,address gasToken,address refundReceiver,uint256 nonce)"
// );
bytes32 private constant SAFE_TX_TYPEHASH = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8;
The rest of the getTransactionHash() function is written in assembly.
calldatacopy(ptr, data.offset, data.length)
let calldataHash := keccak256(ptr, data.length)
First, it computes a hash of the calldata, which we crafted earlier to send 1 USDC to cizeon.eth.
$ chisel
keccak256(hex"a9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240")
Type: bytes32
└ Data: 0x1133c5be11ee6385513e229829a8b3176a52ee02943ed2406ddf25beffe0b7ec
It then combines all the variables and the SAFE_TX_TYPEHASH into a single packed structure.
mstore(ptr, SAFE_TX_TYPEHASH)
mstore(add(ptr, 32), to)
mstore(add(ptr, 64), value)
mstore(add(ptr, 96), calldataHash)
mstore(add(ptr, 128), operation)
mstore(add(ptr, 160), safeTxGas)
mstore(add(ptr, 192), baseGas)
mstore(add(ptr, 224), gasPrice)
mstore(add(ptr, 256), gasToken)
mstore(add(ptr, 288), refundReceiver)
mstore(add(ptr, 320), _nonce)
The message hash is the result of hashing this structure. We can use chisel again to compute the hash of this structure ourselves.
$ chisel
➜ bytes32 safe_tx_typehash = 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d8;
➜ address to = 0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0;
➜ uint256 value = 0;
➜ bytes32 data_hashed = 0x1133c5be11ee6385513e229829a8b3176a52ee02943ed2406ddf25beffe0b7ec;
➜ uint8 operation = 0;
➜ uint256 safe_tx_gas = 0;
➜ uint256 base_gas = 0;
➜ uint256 gas_price = 0;
➜ address gas_token = address(0);
➜ address refund_receiver = address(0);
➜ uint256 nonce = 1;
➜ keccak256(abi.encode(safe_tx_typehash,to,value,data_hashed,operation,safe_tx_gas,base_gas,gas_price,gas_token,refund_receiver,nonce))
Type: bytes32
└ Data: 0x170fb87d7e389e2d5a22da94d29141f5e15bc4d316decc4d182060c8c2db84ba
The message hash is 0x170fb87d7e389e2d5a22da94d29141f5e15bc4d316decc4d182060c8c2db84ba
Finally, we need to combine this hash with the EIP-712 prefix and the domain hash, as shown in the getTransactionHash() function.
// Step 3: Calculate the final EIP-712 hash
// First, hash the SafeTX struct (352 bytes total length)
mstore(add(ptr, 64), keccak256(ptr, 352))
// Store the EIP-712 prefix (0x1901), note that integers are left-padded
// so the EIP-712 encoded data starts at add(ptr, 30)
mstore(ptr, 0x1901)
// Store the domain separator
mstore(add(ptr, 32), domainHash)
// Calculate the hash
txHash := keccak256(add(ptr, 30), 66)
Chisel to the rescue again. Note that here we need to use abi.encodePacked() and not abi.encode().
$ chisel
➜ bytes2 prefix = 0x1901;
➜ bytes32 domain_hash = 0xd6092a6c53243ed8a77ab41647fd6b9c38df40eeaa8c1a259e87e620a9cba646;
➜ bytes32 message_hash = 0x170fb87d7e389e2d5a22da94d29141f5e15bc4d316decc4d182060c8c2db84ba;
➜ keccak256(abi.encodePacked(prefix,domain_hash,message_hash))
Type: bytes32
└ Data: 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a
The txHash is 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a
This is all we need to sign the transaction :-)
The sub command with cast is wallet sign. There are several ways to use the private key:
With -i (interactive mode), you simply need to copy and paste the private key.
Passing the private key or mnemonic as an argument, though this is not recommended.
Storing the key on the filesystem and protecting it with a password using cast wallet import.
$ cast wallet sign 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a -i --no-hash
0x75c0a770c7b65c23fa711c2c6e20332e7fa5c41f46e8f6cc0a089354974bed7d29f6b8e6606d2c11c3f03e7f406678bf40be41fa686d631a92a698becf7ef1df1c
With the signature, we can ask the Gnosis Safe wallet to execute the transaction. I suggest that we simulate the transaction first. This will be done forking the Gnosis chain locally. For that, we will use anvil, also part of the Foundry tool suite.
$ anvil --fork-url https://rpc.gnosischain.com
This creates a new local RPC on 127.0.0.1:8545 by default.
Let’s simulate the final transaction:
export ETH_RPC_URL=http://127.0.0.1:8545
Since the command line is quite long, I recommend creating a simple script to streamline the process. The trace argument will be valuable for understanding the simulated transaction.
export ETH_RPC_URL=http://127.0.0.1:8545
SAFE_WALLET=0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D
TO=0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0
VALUE=0
DATA=0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240
OPERATION=0
SAFE_TX_GAS=0
BASE_GAS=0
GAS_PRICE=0
GAS_TOKEN=0x0000000000000000000000000000000000000000
REFUND_RECEIVER=0x0000000000000000000000000000000000000000
SIGNATURE=0x75c0a770c7b65c23fa711c2c6e20332e7fa5c41f46e8f6cc0a089354974bed7d29f6b8e6606d2c11c3f03e7f406678bf40be41fa686d631a92a698becf7ef1df1c
cast call --trace \
$SAFE_WALLET \
"execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" \
$TO \
$VALUE \
$DATA \
$OPERATION \
$SAFE_TX_GAS \
$BASE_GAS \
$GAS_PRICE \
$GAS_TOKEN \
$REFUND_RECEIVER \
$SIGNATURE
The result is quite verbose, so I’ve only selected the most relevant part.
❯ sh exec.sh
Traces:
[59797]
[...]
0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0::transfer(0xffF4aA9E37a3661db92d550936E5e27442aa5fa6, 1000000 [1e6])
│ │ ├─ [16263] 0x107CF7fb73EA48D1D200989b156Ce1894d7AfEC7::transfer(0xffF4aA9E37a3661db92d550936E5e27442aa5fa6, 1000000 [1e6]) [delegatecall]
│ │ │ ├─ emit Transfer(param0: 0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D, param1: 0xffF4aA9E37a3661db92d550936E5e27442aa5fa6, param2: 1000000 [1e6])
[...]
└─ ← [Return] 0x0000000000000000000000000000000000000000000000000000000000000001
Transaction successfully executed.
Gas used: 79781
We can see the call to USDC::transfer() with cizeon.eth as the recipient for 1 USDC. The final return value is 1, indicating success.
Now, it’s time to execute this on-chain. Instead of using cast call, we’ll use cast send and provide a private key.
export ETH_RPC_URL=https://rpc.gnosischain.com
SAFE_WALLET=0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D
TO=0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0
VALUE=0
DATA=0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240
OPERATION=0
SAFE_TX_GAS=0
BASE_GAS=0
GAS_PRICE=0
GAS_TOKEN=0x0000000000000000000000000000000000000000
REFUND_RECEIVER=0x0000000000000000000000000000000000000000
SIGNATURE=0x75c0a770c7b65c23fa711c2c6e20332e7fa5c41f46e8f6cc0a089354974bed7d29f6b8e6606d2c11c3f03e7f406678bf40be41fa686d631a92a698becf7ef1df1c
cast send --interactive \
$SAFE_WALLET \
"execTransaction(address,uint256,bytes,uint8,uint256,uint256,uint256,address,address,bytes)" \
$TO \
$VALUE \
$DATA \
$OPERATION \
$SAFE_TX_GAS \
$BASE_GAS \
$GAS_PRICE \
$GAS_TOKEN \
$REFUND_RECEIVER \
$SIGNATURE
Once again, the output of the command is quite verbose, so I’ve only selected the relevant part.
The full transaction can be viewed on GnosisScan.
For this article, we used a private key in interactive mode. This is not recommended for production, as if your computer is compromised, malware could access the private key from memory and sign transactions on your behalf. Instead, using a hardware wallet is highly recommended.
Personally, I only own a Ledger device, so I’ve focused on signing with a Ledger key.
Directly signing the safeTxHash is not supported by the Ledger key.
$ cast wallet sign 0x6403ca37fa9c54872e827f9da78cbc5818e7386a6bf5cbfb2788277b670ad80a --ledger --no-hash
Error: operation `sign_hash` is not supported by the signer
However, if provided in JSON format, the Ledger device understand EIP-712 and will gladly compute the message hash and the safeTxHash for you.
{
"domain": {
"chainId": 100,
"verifyingContract": "0xE27f243CD5CB7364Bbae758Bb05AA62ec2a5Fb7D"
},
"message": {
"to": "0x2a22f9c3b484c3629090FeED35F17Ff8F88f76F0",
"value": 0,
"data": "0xa9059cbb000000000000000000000000fff4aa9e37a3661db92d550936e5e27442aa5fa600000000000000000000000000000000000000000000000000000000000f4240",
"operation": 0,
"safeTxGas": 0,
"baseGas": 0,
"gasPrice": 0,
"gasToken": "0x0000000000000000000000000000000000000000",
"refundReceiver": "0x0000000000000000000000000000000000000000",
"nonce": 1
},
"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" }
]
}
}
$ cast wallet sign --ledger --mnemonic-index 1 --data --from-file transaction.json
0x8a14302c34d390ef6bee8291181677fd1dbbe9a483b1d0c1207b487cb58f54a463bd194075e2917a80258d6596b391ada5831daf4d5bc84b2e6d5d884cb242501b
Make sure to verify the domain hash and message hash displayed on the Ledger with the ones you’ve computed yourself.
And you now have the signature for your transaction: 0x8a14302c34d390ef6bee8291181677fd1dbbe9a483b1d0c1207b487cb58f54a463bd194075e2917a80258d6596b391ada5831daf4d5bc84b2e6d5d884cb242501b
If we had used the official Gnosis front-end, we would have obtained the same information.

The USDC recipient
The amount of USDC to send
The address of the USDC smart contract
Operation: call
The same domain hash as we have computed ourselves
The same message hash
The same safeTxHash
And lastly, the same signature from the Ledger key.
That’s it for today! I hope you had as much fun reading this as I did writing it. In a future article, we’ll dive into how signature verification is handled in the smart contract.
<100 subscribers
<100 subscribers
No activity yet