Safe{Wallet} Guard Development
Safe{Wallet} is the de facto standard for multisig wallets in the Ethereum ecosystem. Over $100B TVL flows through Safe contracts. The basic M-of-N multisig functionality covers most use cases. However, corporate treasuries, DAO treasuries, and DeFi protocols often need additional restrictions: transaction limits, recipient whitelists, function call blocking, and time windows for withdrawals. For this, Safe provides the Guard interface.
How Guard Works in Safe Architecture
Safe executes transactions through execTransaction. Before and after execution, two Guard contract hooks are called:
interface ITransactionGuard {
// Called BEFORE transaction execution
function checkTransaction(
address to,
uint256 value,
bytes memory data,
Enum.Operation operation,
uint256 safeTxGas,
uint256 baseGas,
uint256 gasPrice,
address gasToken,
address payable refundReceiver,
bytes memory signatures,
address msgSender
) external;
// Called AFTER transaction execution
function checkAfterExecution(bytes32 txHash, bool success) external;
}
checkTransaction is where all validation logic is implemented. A revert here prevents transaction execution. checkAfterExecution handles post-execution logic: audit logs, counter updates.
A Guard is set per Safe. Only the Safe itself (through multisig) can change it. This is critical: no single owner can change the Guard, even with full Safe authority.
What Can Be Controlled Through Guard
Spending Limits
The most common use case is daily/weekly limits for operational expenses without requiring full multisig quorum:
contract SpendingLimitGuard is BaseGuard {
struct Limit {
uint256 dailyLimit;
uint256 spent;
uint256 lastReset;
}
mapping(address => mapping(address => Limit)) public limits; // safe => token => limit
function checkTransaction(
address to,
uint256 value,
bytes memory data,
Enum.Operation operation,
// ... other parameters
) external override {
address safe = msg.sender;
// Check ETH limit
if (value > 0) {
Limit storage ethLimit = limits[safe][address(0)];
_resetIfNeeded(ethLimit);
require(
ethLimit.spent + value <= ethLimit.dailyLimit,
"Daily ETH limit exceeded"
);
ethLimit.spent += value;
}
// Decode ERC-20 transfer if this is a transfer() call
if (data.length >= 4 && bytes4(data[:4]) == IERC20.transfer.selector) {
(address recipient, uint256 amount) = abi.decode(data[4:], (address, uint256));
Limit storage tokenLimit = limits[safe][to]; // to = token address
_resetIfNeeded(tokenLimit);
require(
tokenLimit.spent + amount <= tokenLimit.dailyLimit,
"Daily token limit exceeded"
);
tokenLimit.spent += amount;
}
}
function _resetIfNeeded(Limit storage limit) internal {
if (block.timestamp >= limit.lastReset + 1 days) {
limit.spent = 0;
limit.lastReset = block.timestamp;
}
}
}
Important caveat: Guard receives data as raw bytes. Analyzing function calls requires decoding the 4-byte selector and arguments. This works for standard functions but not for arbitrary contract interactions without a known ABI.
Recipient Whitelist
mapping(address => mapping(address => bool)) public allowedRecipients;
function checkTransaction(address to, uint256 value, bytes memory data, ...) external override {
// For direct ETH transfers, check whitelist
if (data.length == 0 && value > 0) {
require(allowedRecipients[msg.sender][to], "Recipient not whitelisted");
}
// DelegateCall has separate logic (or complete ban)
if (operation == Enum.Operation.DelegateCall) {
require(allowedDelegateTargets[msg.sender][to], "DelegateCall target not allowed");
}
}
DelegateCall requires special attention: it can modify Safe storage, including the owner list. Many Guard implementations ban DelegateCall entirely or restrict it to a strict whitelist.
Time Windows
For DAOs with different access levels at different times of day (protection against off-hours attacks):
uint256 public allowedStartHour; // 0-23 UTC
uint256 public allowedEndHour;
function checkTransaction(...) external override {
uint256 hour = (block.timestamp / 3600) % 24;
require(
hour >= allowedStartHour && hour < allowedEndHour,
"Transactions not allowed at this time"
);
}
Testing Guard Contracts
Test Guards in the context of a real Safe. Use the safe-contracts package to deploy Safe in Foundry tests:
// Foundry test
function setUp() public {
safe = deploySafe(owners, threshold);
guard = new SpendingLimitGuard();
// Set Guard through Safe transaction
bytes memory setGuardData = abi.encodeCall(
GuardManager.setGuard,
address(guard)
);
executeSafeTx(safe, address(safe), 0, setGuardData);
}
function test_revertOnLimitExceeded() public {
// Configure 1 ETH/day limit
guard.setLimit(address(safe), address(0), 1 ether);
// First transfer of 0.9 ETH — passes
executeSafeTx(safe, recipient, 0.9 ether, "");
// Second transfer of 0.2 ETH — exceeds limit
vm.expectRevert("Daily ETH limit exceeded");
executeSafeTx(safe, recipient, 0.2 ether, "");
}
Common Guard Development Mistakes
Guard blocks its own upgrade. If a Guard forbids all transactions to arbitrary addresses, it can block setGuard(address(0)) — removing itself. Always ensure the Safe can remove the Guard.
Ignoring data.length == 0 case. Empty data + value > 0 = direct ETH transfer. data.length > 0 + to = contract call. Don't mix the logic.
Gas constraints. Guard is called inside execTransaction. Complex logic in checkTransaction increases gas cost for every Safe transaction. Avoid unbounded loops.
Work Process
Analytics. Define specific rules: which transaction types to restrict, how the Guard is managed (who can change limits — only Safe or designated admin), whether event audit logs are needed.
Development. Use BaseGuard from @safe-global/safe-contracts as the base contract with supportsInterface implementation. Implement checkTransaction and checkAfterExecution. Test with real Safe.
Audit. Guards with financial restrictions require audit — especially data decoding logic and DelegateCall edge cases. Use Slither and Echidna for fuzzing.
Deployment. Contract verification. Guard installation through Safe UI with address correctness verification before signing.
Timeline Estimates
Single-restriction Guard (e.g., spending limits only): 2-3 days. Guard with combined rules (whitelist + limits + time windows) and tests: 3-5 days. With audit required: add 3-5 days.







