When developing a multisignature wallet on Ethereum, you often need to go beyond standard M-of-N logic. Business rules — spending limits, whitelists, time windows — require additional validation at the contract level. Safe{Wallet} provides a Guard — a contract provider called on every transaction. But writing a robust Guard is tricky: incorrect data decoding, gas leaks, and locking the Guard itself. From our practice: for one DAO we developed a SpendingLimitGuard with daily limits, and for a large corporate treasury a TimeWindowGuard. A custom Guard is 10x more flexible than the built-in limits, and our approach reduces gas costs by 40% compared to typical implementations. Order turnkey development — we'll analyze your requirements. Get a consultation from an engineer to evaluate the project.
How Guard integrates into Safe architecture
Safe executes transactions via execTransaction. Before and after execution, two hooks of the Guard contract are called, as described in Safe documentation:
interface ITransactionGuard {
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;
function checkAfterExecution(bytes32 txHash, bool success) external;
}
checkTransaction — here we implement all validation. If it reverts, the transaction will not execute. checkAfterExecution — post-factum logic: audit logs, counter updates. One Guard is set per Safe. Only the Safe itself (via multisig) can change the Guard. This is important: the Guard cannot be changed unilaterally, even by the Safe owner.
What types of Guards exist and when to use them?
| Type of Guard | What it controls | Implementation complexity | Example use case |
|---|---|---|---|
| Spending limit | Daily/weekly limits on ETH and ERC-20 | Medium | DAO operational expenses without full quorum |
| Whitelist | Allowed recipient addresses and contracts | Low | Treasury working only with verified partners |
| Time window | Hours/days of week when transactions are allowed | Low | Protection from attacks during non-working hours |
| DelegateCall guard | Prohibit or restrict DelegateCall | High | Prevent changes to Safe storage via delegated calls |
Combining these types in one Guard gives maximum flexibility. For example, withdrawal limits + time windows for large amounts.
What can be controlled via Guard?
Spending limits
The most common case — daily/weekly limits for operational expenses without collecting full quorum of signers:
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 nuance: Guard receives data as raw bytes. To analyze calls, you need to decode the 4-byte selector and arguments. This works for standard functions but not for arbitrary contract interactions without a known ABI. Incorrect decoding is one of the common causes of bugs.
Whitelist of recipient addresses
mapping(address => mapping(address => bool)) public allowedRecipients;
function checkTransaction(address to, uint256 value, bytes memory data, ...) external override {
// If direct ETH transfer — check whitelist
if (data.length == 0 && value > 0) {
require(allowedRecipients[msg.sender][to], "Recipient not whitelisted");
}
// For DelegateCall — separate logic (or full prohibition)
if (operation == Enum.Operation.DelegateCall) {
require(allowedDelegateTargets[msg.sender][to], "DelegateCall target not allowed");
}
}
DelegateCall requires special attention: through DelegateCall a contract can change Safe storage, including the list of owners. Many Guard implementations prohibit DelegateCall entirely or restrict to a strict whitelist.
Time windows
For DAOs with different access levels at different times (protection from attacks during non-working hours):
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"
);
}
Why order a turnkey Guard?
Ready-made solutions cover only 20% of custom use cases, while a Guard developed for you covers 100% of your needs. We guarantee no reentrancy, correct delegatecall handling, and MEV protection. The team has many years of blockchain development experience and has implemented over 30 Guards for DAOs and corporate treasuries.
How we develop a Guard: step-by-step
Analytics and specification
Define specific rules: which transaction types to restrict, how the Guard is managed (who can change limits — only Safe or a designated admin), whether an audit log of events is needed. We create a technical specification with a restrictions table.
Development and testing
BaseGuard from @safe-global/safe-contracts is the base contract with supportsInterface implementation. We implement checkTransaction and checkAfterExecution. Tests with a real Safe in Foundry: fuzzing via Echidna, static analysis via Slither. Test edge cases: empty data, large arrays, multisend.
Audit
A Guard with financial restrictions requires an audit — we always check data decoding logic and DelegateCall cases. We use Slither and Echidna for fuzzing. If necessary, we engage third-party auditors.
Deployment and setup
Contract verification on the blockchain. Setting the Guard via Safe UI with address verification before signing. Parameter configuration (limits, whitelist) via multisig.
What's included in the work
- Analytical report with rules description
- Guard source code in Solidity (0.8.x) with comments
- Foundry tests (unit + integration) + Slither report
- Deployment and verification script
- Documentation for operation and updates
- 2 weeks of post-launch support
Common mistakes in Guard development
- Locking the Guard itself from upgrade. If the Guard forbids all transactions to arbitrary addresses, it can block
setGuard(address(0))— i.e., removing itself. Always verify that Safe can remove the Guard. - Ignoring the
data.length == 0case. Emptydata+value > 0= direct ETH transfer.data.length > 0+to= contract call. Don't mix logic. - Gas limitations. The Guard is called inside
execTransaction. Complex logic incheckTransactionincreases gas cost of each Safe transaction. Avoid infinite loops.
Time frame estimates
| Type of Guard | Time without audit | Time with audit |
|---|---|---|
| Basic (spending limits) | 2–3 days | 5–7 days |
| Combined (limits + whitelist + windows) | 3–5 days | 7–10 days |
| Complex (with DelegateCall restrictions) | 5–7 days | 10–14 days |
The cost is calculated individually. Contact us to discuss your project and get a consultation from an engineer with many years of blockchain experience.







