Back to blogs
Written by
Patrick Collins
Published on
January 30, 2025

How To Verify Safe Multi-Sig Wallet Signatures: The Radiant Capital Hack Fix - Learn This or Lose $50M

Learn how to verify Safe multi-sig wallet signatures with Cyfrin's safe-tx-hashes tool - the fix for the Radiant Capital hack.

Table of Contents

Huge thank you to pcaversaccio for pushing this and writing the original tool.

What web3 learned from watching Radiant Capital lose $50M

The Radiant Capital hack went (essentially) like this:

  1. They had a Safe Multi-sig wallet
  2. A few of their signers had compromised computers 
  3. Their computer screens showed “good” data, but they sent malicious data to their wallets
  4. They signed the wallet transactions because they didn’t know how to verify them

This costly mistake highlights the critical importance of understanding and verifying multi-signature (multi-sig) transactions. The ability to verify signatures is a skill you must have if you want to be involved in security councils, DAOs, incident response, or a DevOps team. Heck, even if you just want to be sure your personal accounts don’t get rekt.

Multi-sig wallets are renowned for their security, but to leverage this security effectively, you need to know how to verify that what you're signing is indeed what you intend to sign.

Let’s learn how you can verify every part of your Safe multi-sig process so you don’t fall victim to this attack.

If you’d rather watch, we also have a video on YouTube to guide you step by step.

Getting started: download the safe-tx-hashes tool

First, you’ll want to download the tool. Head over to the safe-tx-hashes repository to download the safe_hashes executeable.

This tool allows us to quickly input the safe transaction data, see what we should be getting, and verify that is indeed what’s showing up on our wallets. 

As we go through this, it’s important to first understand what security assumptions we are making and what the threat vectors may be, so we can avoid them.

With this tool, we already have some assumptions that the following tools are safe:

Verifying a transaction: The setup

Let’s start with a scenario. 

Your team needs to approve the Uniswap router to access your USDC for a swap - a relatively common DeFi transaction. Your Safe UI may look as such:

A transaction builder interface prompting the user to allow access to tokens with specified details and options for approval.

When you scroll to the bottom, you’ll get a big “sign” button. And when you click it, your wallet displays a message for you to verify, it might look something like this:

A hardware wallet displaying a cryptographic message with hexadecimal values on its screen.
A Trezor device showing a confirmation message with a hexadecimal code and a "Hold to Confirm" button.

This is the Safe transaction message hash that we need to verify and be 100% sure of what it’s doing. So, what is it doing?

This message is the EIP-712 signature of the message our multi-sig uses to verify transactions. Enough users must sign a message in a particular format for it to be accepted. We cover EIP-712 and 191 in our blog and in Cyfrin Updraft if you’d like to learn more.

The safe hash is calculated as such (in Solidity):

keccak256(abi.encodePacked(bytes1(0x19), bytes1(0x01), bytes32($domain_hash), bytes32($message_hash)))

And the message_hash is calculated with (with bash/foundry):

local message=$(cast abi-encode "SafeTxStruct(bytes32,address,uint256,bytes32,uint8,uint256,uint256,uint256,address,address,uint256)" \
       "$safe_tx_typehash" \
       "$to" \
       "$value" \
       "$data_hashed" \
       "$operation" \
       "$safe_tx_gas" \
       "$base_gas" \
       "$gas_price" \
       "$gas_token" \
       "$refund_receiver" \
       "$nonce")


   # Calculate the message hash.
   local message_hash=$(cast keccak "$message")

In essence, these hashes combine all critical transaction components to create a unique signature. This ensures that when the multi-sig wallet verifies a signature, it can be certain that it corresponds to this specific transaction on this specific wallet instance.

Now, what’s important here is that we must be completely sure this signature has the data we expect. If someone wanted us to sign a different transaction, they could just change the data that gets hashed as data_hashed. But, this would return a different signature! So if we are vigilant, we can see this, and not sign.

So our process will be to recreate what we expect the signature to be offline, then compare it to the signature we get on our hardware wallet.

Verifying transactions: Uninitialized with the Safe API

When you reach this page in the Safe UI, the Safe API will “save” the data here so that we can call the API to populate our tool. We can simply run:

safe_hashes --address <MULTI_SIG_WALLET_ADDRESS> --nonce XX --network xxxx --untrusted

And this pulls all the data from the Safe UI and shows it directly to us! Including the transaction parameters and the expected signature. For example, you can run this script yourself to see the output!

safe_hashes --network sepolia --address 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F --nonce 7 --untrusted

You’ll get an output like this:

Transaction Data
Multisig address: 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F
To: 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9
Value: 0
Data: 0x095ea7b3000000000000000000000000fe2f653f6579de62aaf8b186e618887d03fa31260000000000000000000000000000000000000000000000000000000000000001
Encoded message: 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d80000000000000000000000007b79995e5f793a07bc00c21412e50ecae098e7f900000000000000000000000000000000000000000000000000000000000000001c62604b0ed9a9ec0e55efe8fb203b3029e147d994854cf0dd8a9fcf5b240d600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007
Method: approve
Parameters: [
  {
    "name": "guy",
    "type": "address",
    "value": "0xfE2f653f6579dE62aAF8B186E618887d03FA3126"
  },
  {
    "name": "wad",
    "type": "uint256",
    "value": "1"
  }
]

Legacy Ledger Format
Binary string literal: \xe8\xc9\xfbR\xf3$\\xc5\xe451\x1b\xa4\xd5pAJ4=\x80\xeal7g\xa1jy\xbd*\xaa\x0c7

Hashes
Domain hash: 0xE411DFD2D178C853945BE30E1CEFBE090E56900073377BA8B8D0B47BAEC31EDB
Message hash: 0x27B5FF2CB38C914873DAF41B5A4984C76DB35F1812877F72A9D5DEA6960ED0B1
Safe transaction hash: 0xe8c9fb52f3245cc5e435311ba4d570414a343d80ea6c3767a16a79bd2aaa0c37

The transaction is calculated based on the data the Safe UI is giving us. We can also see the transaction that is being sent here too! With this, we can verify the transaction is correct, and verify the Safe transaction hash matches what we see in our wallet. 

If a hacker took over the Safe UI/API when they sent us the malicious transaction, our script would recalculate the expected hash, and we would either see the transaction data is malicious (by inspecting the method section) or we’d see a different hash, and we’d reject the transaction!

However, if the Safe API is compromised, we’d have to manually do our verification process.

Verifying transactions: Uninitialized without the Safe API

For us to verify the hash without the Safe API, we’d just manually add the data to our tool, and use the --offline flag. Like this (you can also run it!):

safe_hashes --offline --data 0x095ea7b3000000000000000000000000fe2f653f6579de62aaf8b186e618887d03fa31260000000000000000000000000000000000000000000000000000000000000001 --address 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F --network sepolia --nonce 6 --to 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9

The data was calculated using the cast tool:

cast calldata "approve(address,uint256) 0xfe2f653f6579de62aaf8b186e618887d03fa3126 1

You can learn more about getting calldata and verifying transactions from this video. This would give you an output like this:

Transaction Data
Multisig address: 0x86D46EcD553d25da0E3b96A9a1B442ac72fa9e9F
To: 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9
Value: 0
Data: 0x095ea7b3000000000000000000000000fe2f653f6579de62aaf8b186e618887d03fa31260000000000000000000000000000000000000000000000000000000000000001
Encoded message: 0xbb8310d486368db6bd6f849402fdd73ad53d316b5a4b2644ad6efe0f941286d80000000000000000000000007b79995e5f793a07bc00c21412e50ecae098e7f900000000000000000000000000000000000000000000000000000000000000001c62604b0ed9a9ec0e55efe8fb203b3029e147d994854cf0dd8a9fcf5b240d600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006
Skipping decoded data, since raw data was passed

Legacy Ledger Format
Binary string literal: !;\xe07'\\x94D\x9a(\xb4\xed\xea\xd7k\x0dc\xc7\xe1+R%\x7f\x9dV\x86\xd9\x8b\x9a\x1a_\xf4

Hashes
Domain hash: 0xE411DFD2D178C853945BE30E1CEFBE090E56900073377BA8B8D0B47BAEC31EDB
Message hash: 0x4BBDE73F23B1792683730E7AE534A56A0EFAA8B7B467FF605202763CE2124DBC
Safe transaction hash: 0x213be037275c94449a28b4edead76b0d63c7e12b52257f9d5686d98b9a1a5ff4

With this, you don’t even need to trust the safe API!

Finishing up

Once a transaction has been signed at least once, you can get the signature for it from the Safe API without passing the --untrusted command. 

Then, once everyone has signed and verified, you can run the command again with the --print-mst-calldata flag to see what the calldata should look like, so you can go a step further and finally verify the actual transaction. 

You can view more examples, documentation, and tools in the GitHub repo for the safe_hashes tool. 

Final thoughts

Ensuring the security of multi-sig transactions is not just about using the right tools but also about understanding the process and the potential risks involved. By following these guidelines, you can safeguard your cryptocurrency assets effectively.

Please have a process for you and your team to verify signatures so you don’t fall for the same hack that plagued the Radiant team!

Secure your protocol today

Join some of the biggest protocols and companies in creating a better internet. Our security researchers will help you throughout the whole process.
Stay on the bleeding edge of security
Carefully crafted, short smart contract security tips and news freshly delivered every week.