Skip to content

About

Interact with the Safe API from Foundry scripts

Resources

Stars

20 stars

Watchers

5 watching

Forks

Repository files navigation

safe-utils

Interact with the Safe API from Foundry scripts.

Installation

forge install Recon-Fuzz/safe-utils

Usage

1. Import the library

import {Safe} from "safe-utils/Safe.sol";

2. Initialize the client

Build the client by passing your safe address.

using Safe for *;

Safe.Client safe;

function setUp() public {
  safe.initialize(safeAddress);
}

3. Propose transactions

safe.proposeTransaction(weth, abi.encodeCall(IWETH.withdraw, (0)), sender);

If you are using a hardware wallet, make sure to pass the derivation path as the last argument:

safe.proposeTransaction(weth, abi.encodeCall(IWETH.withdraw, (0)), sender, "m/44'/60'/0'/0/0");

Ledger is the default. To sign with a Trezor instead, set the HARDWARE_WALLET environment variable:

HARDWARE_WALLET=trezor forge script ... --ffi

Proposing a transaction/transactions using a hardware wallet will also require pre-computing the signature, due to a (current) limitation with forge.

The first step is to pre-compute the signature:

bytes memory signature = safe.sign(weth, abi.encodeCall(IWETH.withdraw, (0)), Enum.Operation.Call, sender, "m/44'/60'/0'/0/0");

Note that this call will fail if forge script is called with the --ledger or --trezor flag, as that would block this library's contracts from utilising the same device. Instead, pass the derivation path as an argument to the script.

The second step is to take the value for the returned bytes and provide them when proposing the transaction:

safe.proposeTransactionWithSignature(weth, abi.encodeCall(IWETH.withdraw, (0)), sender, signature);

Batch transactions

safe.proposeTransactions(targets, datas, sender, "m/44'/60'/0'/0/0");

For pre-computed signatures with hardware wallets:

(address to, bytes memory data) = safe.getProposeTransactionsTargetAndData(targets, datas);
bytes memory signature = safe.sign(to, data, Enum.Operation.DelegateCall, sender, "m/44'/60'/0'/0/0");
safe.proposeTransactionsWithSignature(targets, datas, sender, signature);

⚠️ Important: Batch transactions require Enum.Operation.DelegateCall (not Call). Using Call causes signature validation errors.

Batches delegatecall MultiSendCallOnly. On a chain the Safe transaction service does not serve, the library uses the canonical v1.4.1 deployment, then v1.3.0, whichever has code on the current chain. For a deployment at another address, set it on the client:

safe.withMultiSendCallOnly(0x...);

Direct execution with several owners (no Transaction Service)

On a chain without the Safe Transaction Service, owners can approve a transaction and anyone can execute it with execTransaction. Approvals come from offline signatures, on-chain approveHash calls, or a mix of both.

bytes32 safeTxHash = safe.getSafeTxHash(target, 0, data, Enum.Operation.Call, nonce);

// Path 1: offline signatures over safeTxHash, e.g. from safe.sign(...) or `cast wallet sign`, in any order.
// eth_sign signatures must have v + 4 (31/32), as safe.sign produces for Trezor.
bytes[] memory signatures = new bytes[](2);
signatures[0] = signatureFromOwnerA;
signatures[1] = signatureFromOwnerB;

// Path 2: an owner sends approveHash as an ordinary transaction, which works with --ledger or --trezor
// without the ffi signing step. Send this calldata to the Safe address:
bytes memory approveData = safe.getApproveHashData(safeTxHash);
// Then collect v = 1 signatures for every owner that approved, plus the executor if it is an owner:
bytes[] memory approved = safe.getApprovedHashSignatures(safeTxHash, executor);

// Build execTransaction calldata: signatures are checked against the owners and the threshold, then sorted
bytes memory execData = safe.getExecTransactionDataWithSignatures(target, data, signatures, nonce);
bytes memory batchExecData = safe.getExecTransactionsDataWithSignatures(targets, datas, signatures, nonce);

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:

// 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:

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.

// Single transaction
bool ok = safe.simulateTransactionNoSign(target, data, signerAddress);

// Batch
bool ok = safe.simulateTransactionsNoSign(targets, datas, signerAddress);

// Multi-sig (threshold > 1) — pass at least `threshold` valid owner addresses.
// Non-owners and duplicates in the array are silently filtered out.
address[] memory signers = new address[](2);
signers[0] = signer1;
signers[1] = signer2;
bool ok = safe.simulateTransactionMultiSigNoSign(target, data, signers);
bool ok = safe.simulateTransactionsMultiSigNoSign(targets, datas, signers);

All simulate functions return true on success and false on revert — they never throw, so you can inspect failures without aborting the script.

Mode detection helpers let you branch between simulation and broadcast in one script:

if (Safe.isSimulationMode()) { /* fork run */ }
if (Safe.isBroadcastMode()) { /* --broadcast run */ }

Set the SAFE_BROADCAST environment variable to true to force broadcast mode regardless of the --broadcast flag (useful in CI).

SafeScriptBase

SafeScriptBase is an abstract Foundry script that wires up simulation and broadcast automatically. Extend it instead of writing the routing logic yourself:

import {SafeScriptBase} from "safe-utils/SafeScriptBase.sol";

contract MyScript is SafeScriptBase {
    function run() external {
        _initializeSafe(); // reads DEPLOYER_SAFE_ADDRESS, SIGNER_ADDRESS, DERIVATION_PATH

        // Routes to simulate (no --broadcast) or propose (--broadcast) automatically
        _proposeTransaction(target, data, "Description shown in logs");

        // Batch
        _proposeTransactions(targets, datas, "Batch description");

        // Deployment — skips if code already present, reverts if missing after simulation
        _proposeTransactionWithVerification(factory, deployData, expectedAddr, "Deploy Foo");
    }
}

For multi-sig scripts use _initializeSafeMultiSig() instead, which reads SIGNER_ADDRESS_0, SIGNER_ADDRESS_1, … from the environment.

Environment variables for SafeScriptBase:

Variable Description
DEPLOYER_SAFE_ADDRESS The Safe address
SIGNER_ADDRESS Owner address (single-sig)
SIGNER_ADDRESS_0, _1, … Owner addresses (multi-sig)
DERIVATION_PATH HW wallet path, e.g. m/44'/60'/0'/0/0
HARDWARE_WALLET ledger (default) or trezor
SAFE_BROADCAST Set to true to force broadcast mode

Requirements

  • Foundry with FFI enabled:
    • Pass --ffi to your commands (e.g. forge test --ffi)
    • Or set ffi = true in your foundry.toml
[profile.default]
ffi = true
  • All Recon-Fuzz/solidity-http dependencies

Third-party integrations

The following blockchains are integrated via third-party APIs and not the official safe.global tx service:

Blockchain Provider
Plume OnChainDen

Demo

https://github.com/Recon-Fuzz/governance-proposals-done-right

Disclaimer

This code is provided "as is" and has not undergone a formal security audit.

Use it at your own risk. The author(s) assume no liability for any damages or losses resulting from the use of this code. It is your responsibility to thoroughly review, test, and validate its security and functionality before deploying or relying on it in any environment.

This is not an official @safe-global library

About

Interact with the Safe API from Foundry scripts

Resources

Stars

20 stars

Watchers

5 watching

Forks

Releases

Packages

Used by

Contributors

Languages