Building a Guard for Safe Smart Account
This tutorial demonstrates how to build a custom Guard contract that adds security restrictions to a Safe Smart Account. You'll learn how to:
- Create a Safe Guard that prevents delegate calls
- Enable the Guard on a Safe Smart Account
- Write comprehensive tests for the Guard
You'll build a NoDelegatecallGuard
that blocks delegatecall
operations through the Safe account. While this is a simple example, the same principles can be used to build more complex Guards for your specific security needs.
A Safe account supports two types of transactions:
- Safe Transaction: Executed through the Safe owners with required signatures
- Module Transaction: Executed through an enabled Safe Module
This tutorial focuses on Safe Transactions, as Guards only apply to those.
Understanding Safe Guards
Before we dive into the code, let's understand what Guards do:
- Guards are contracts that can inspect and validate transactions before and after they are executed by a Safe
- They implement a standard interface with two key functions:
checkTransaction
: Called before execution to validate the transactioncheckAfterExecution
: Called after execution for post-transaction checks
- Guards can block transactions by reverting if validation fails
Only enable Guards from trusted and audited code. A malicious Guard could block all transactions and make your Safe unusable.
Prerequisites
Before starting this tutorial, make sure you have:
- Experience with Solidity (opens in a new tab) and Hardhat (opens in a new tab)
- Node.js (opens in a new tab) and npm (opens in a new tab) installed
- Basic understanding of Smart Account concepts
Project Setup
Initialize Project
Create a new project directory and initialize npm:
_10mkdir safe-guard-tutorial && cd safe-guard-tutorial _10npm init -y
Configure Dependencies
Add the following to your package.json
:
_10{_10 // ... existing content ..._10 "overrides": {_10 "@safe-global/safe-contracts": {_10 "ethers": "^6.13.5"_10 }_10 }_10}
Install the required dependencies:
_10npm add -D hardhat @safe-global/safe-contracts hardhat-dependency-compiler
Setup Hardhat project
Initialize a TypeScript Hardhat project:
_10npx hardhat init
Now, try compiling the contracts to ensure everything is set up correctly.
_10npx hardhat compile
Select Create a TypeScript project
when prompted.
When compiling Safe contracts with solidity 0.8.x the bytecode size exceeds the limit of 24KB. To overcome this, set allowUnlimitedContractSize
to true
in the hardhat config.
In practise with production networks, use the officially deployed Safe contracts. Also, add dependencyCompiler
to import SafeProxyFactory
and Safe
contracts.
Update your hardhat.config.ts
:
_20import { HardhatUserConfig } from "hardhat/config";_20import "@nomicfoundation/hardhat-toolbox";_20import "hardhat-dependency-compiler";_20_20const config: HardhatUserConfig = {_20 solidity: "0.8.28",_20 networks: {_20 hardhat: {_20 allowUnlimitedContractSize: true, // Required for Safe contracts_20 },_20 },_20 dependencyCompiler: {_20 paths: [_20 "@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol",_20 "@safe-global/safe-contracts/contracts/Safe.sol",_20 ],_20 },_20};_20_20export default config;
Create a new Solidity contract
Delete the default contracts/Lock.sol
and test file test/Lock.ts
and create a new Solidity contract NoDelegatecallGuard.sol
in the contracts
directory.
Step 1. Create NoDelegatecallGuard contract
_10// SPDX-License-Identifier: LGPL-3.0_10pragma solidity ^0.8.0;_10import { BaseGuard } from "@safe-global/safe-contracts/contracts/base/GuardManager.sol";_10import { Enum } from "@safe-global/safe-contracts/contracts/common/Enum.sol";_10_10contract NoDelegatecallGuard is BaseGuard {_10 error DelegatecallNotAllowed();_10_10 // Functions will be added here_10}
Explanation:
BaseGuard.sol
: BaseGuard is an abstract contract that implements ERC-165 and inherits the Guard interface with two functions:checkTransaction
: This function is called before Safe transaction is executed.checkAfterExecution
: This function is called after Safe transaction is executed.
Enum.sol
: Provides EnumOperation
which can have values likeCall
orDelegateCall
.DelegatecallNotAllowed
is a custom error type that will be used to revert the transaction ifdelegatecall
is detected.
Step 2: Implement checkTransaction
function
_17function checkTransaction(_17 address /*to*/,_17 uint256 /*value*/,_17 bytes memory /*data*/,_17 Enum.Operation operation,_17 uint256 /*safeTxGas*/,_17 uint256 /*baseGas*/,_17 uint256 /*gasPrice*/,_17 address /*gasToken*/,_17 address payable /*refundReceiver*/,_17 bytes memory /*signatures*/,_17 address /*msgSender*/_17) external {_17 if(operation == Enum.Operation.DelegateCall) {_17 revert DelegatecallNotAllowed();_17 }_17}
Explanation:
- The
checkTransaction
function checks if the operation type isDelegateCall
. If it is, the function reverts with a custom errorDelegatecallNotAllowed
.
Step 3: Implement checkAfterExecution
function
_10function checkAfterExecution(bytes32 txHash, bool success) external {_10}
Explanation:
- The
checkAfterExecution
function is empty as we do not need to perform any action after the transaction is executed.
Final contract code
_32// SPDX-License-Identifier: UNLICENSED_32pragma solidity ^0.8.28;_32_32import { BaseGuard } from "@safe-global/safe-contracts/contracts/base/GuardManager.sol";_32import { Enum } from "@safe-global/safe-contracts/contracts/common/Enum.sol";_32_32contract NoDelegatecallGuard is BaseGuard {_32_32 error DelegatecallNotAllowed();_32_32 function checkTransaction(_32 address /*to*/,_32 uint256 /*value*/,_32 bytes memory /*data*/,_32 Enum.Operation operation,_32 uint256 /*safeTxGas*/,_32 uint256 /*baseGas*/,_32 uint256 /*gasPrice*/,_32 address /*gasToken*/,_32 address payable /*refundReceiver*/,_32 bytes memory /*signatures*/,_32 address /*msgSender*/_32 ) external {_32 if(operation == Enum.Operation.DelegateCall) {_32 revert DelegatecallNotAllowed();_32 }_32 }_32_32 function checkAfterExecution(bytes32 txHash, bool success) external {_32_32 }_32}
Testing the contract
Step 1: Create test/utils/utils.ts file
Create a new file named utils.ts
in the test/utils
directory and include the code below.
_76import { ethers } from "hardhat";_76import { Signer, AddressLike, BigNumberish, ZeroAddress } from "ethers";_76import { Safe } from "../../typechain-types";_76_76/**_76 * Executes a transaction on the Safe contract._76 * @param wallets - The signers of the transaction._76 * @param safe - The Safe contract instance._76 * @param to - The address to send the transaction to._76 * @param value - The value to send with the transaction._76 * @param data - The data to send with the transaction._76 * @param operation - The operation type (0 for call, 1 for delegate call)._76 */_76const execTransaction = async function (_76 wallets: Signer[],_76 safe: Safe,_76 to: AddressLike,_76 value: BigNumberish,_76 data: string,_76 operation: number,_76): Promise<void> {_76 // Get the current nonce of the Safe contract_76 const nonce = await safe.nonce();_76_76 // Get the transaction hash for the Safe transaction_76 const transactionHash = await safe.getTransactionHash(_76 to,_76 value,_76 data,_76 operation,_76 0,_76 0,_76 0,_76 ZeroAddress,_76 ZeroAddress,_76 nonce_76 );_76_76 let signatureBytes = "0x";_76 const bytesDataHash = ethers.getBytes(transactionHash);_76_76 // Get the addresses of the signers_76 const addresses = await Promise.all(wallets.map(wallet => wallet.getAddress()));_76 // Sort the signers by their addresses_76 const sorted = wallets.sort((a, b) => {_76 const addressA = addresses[wallets.indexOf(a)];_76 const addressB = addresses[wallets.indexOf(b)];_76 return addressA.localeCompare(addressB, "en", { sensitivity: "base" });_76 });_76_76 // Sign the transaction hash with each signer_76 for (let i = 0; i < sorted.length; i++) {_76 const flatSig = (await sorted[i].signMessage(bytesDataHash))_76 .replace(/1b$/, "1f")_76 .replace(/1c$/, "20");_76 signatureBytes += flatSig.slice(2);_76 }_76_76 // Execute the transaction on the Safe contract_76 await safe.execTransaction(_76 to,_76 value,_76 data,_76 operation,_76 0,_76 0,_76 0,_76 ZeroAddress,_76 ZeroAddress,_76 signatureBytes_76 );_76};_76_76export {_76 execTransaction,_76};
Explanation:
- This file contains utility function to execute transaction through the Safe account.
Step 2: Start with a boilerplate test file
Create a new file named NoDelegatecallGuard.test.ts
and include the following basic structure that will be filled in later steps (ignore the warnings about unused imports):
_26import { ethers } from "hardhat";_26import { expect } from "chai";_26import { Signer, ZeroAddress } from "ethers";_26import { Safe, Safe__factory, SafeProxyFactory } from "../typechain-types";_26import { execTransaction } from "./utils/utils";_26import { NoDelegatecallGuard } from "../typechain-types/contracts/NoDelegatecallGuard";_26_26describe("NoDelegatecallGuard", async function () {_26 let deployer: Signer;_26 let alice: Signer;_26 let masterCopy: Safe;_26 let proxyFactory: SafeProxyFactory;_26 let safeFactory: Safe__factory;_26 let safe: Safe;_26 let exampleGuard: NoDelegatecallGuard;_26 const threshold = 1;_26_26 beforeEach(async () => {});_26_26 // Add your test cases here_26 it("Should not allow delegatecall", async function () {});_26_26 it("Should allow call", async function () {});_26_26 it("Should allow to replace the guard", async function () {});_26});
Step 3: Setup contracts and variables in before hook
_58 // Setup signers and deploy contracts before running tests_58 beforeEach(async () => {_58 [deployer, alice] = await ethers.getSigners();_58_58 safeFactory = await ethers.getContractFactory("Safe", deployer);_58 masterCopy = await safeFactory.deploy();_58_58 proxyFactory = await (_58 await ethers.getContractFactory("SafeProxyFactory", deployer)_58 ).deploy();_58_58 const ownerAddresses = [await alice.getAddress()];_58_58 const safeData = masterCopy.interface.encodeFunctionData("setup", [_58 ownerAddresses,_58 threshold,_58 ZeroAddress,_58 "0x",_58 ZeroAddress,_58 ZeroAddress,_58 0,_58 ZeroAddress,_58 ]);_58_58 // Read the safe address by executing the static call to createProxyWithNonce function_58 const safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_58 await masterCopy.getAddress(),_58 safeData,_58 0n_58 );_58_58 // Create the proxy with nonce_58 await proxyFactory.createProxyWithNonce(_58 await masterCopy.getAddress(),_58 safeData,_58 0n_58 );_58_58 if (safeAddress === ZeroAddress) {_58 throw new Error("Safe address not found");_58 }_58_58 // Deploy the NoDelegatecallGuard contract_58 exampleGuard = await (_58 await ethers.getContractFactory("NoDelegatecallGuard", deployer)_58 ).deploy();_58_58 safe = await ethers.getContractAt("Safe", safeAddress);_58_58 // Set the guard in the safe_58 const setGuardData = masterCopy.interface.encodeFunctionData(_58 "setGuard",_58 [exampleGuard.target]_58 );_58_58 // Execute the transaction to set the Guard_58 await execTransaction([alice], safe, safe.target, 0, setGuardData, 0);_58 });
This step sets up the test environment by deploying and configuring the necessary contracts. Please note that:
- Alice is the only owner of the Safe and a threshold of 1 is set. Thus, only Alice's signature is required to execute transactions.
- Alice as the owner of the Safe is required to set the guard.
- The guard is enabled by calling the
setGuard
function on the Safe contract. - ⚠️ Security Note: Only trusted and audited code should be enabled as a guard, since guard can block transactions. A malicious guard make Safe unusable by blocking all transactions.
Step 4: Add test cases
_31 it("Should not allow delegatecall", async function () {_31 const wallets = [alice];_31_31 await expect(_31 execTransaction(wallets, safe, ZeroAddress, 0, "0x", 1)_31 ).to.be.revertedWithCustomError(exampleGuard, "DelegatecallNotAllowed");_31 });_31_31 it("Should allow call", async function () {_31 const wallets = [alice];_31_31 expect(await execTransaction(wallets, safe, ZeroAddress, 0, "0x", 0));_31 });_31_31 it("Should allow to replace the guard", async function () {_31 const wallets = [alice];_31_31 const setGuardData = masterCopy.interface.encodeFunctionData("setGuard", [_31 ZeroAddress,_31 ]);_31 expect(_31 await execTransaction(_31 wallets,_31 safe,_31 await safe.getAddress(),_31 0,_31 setGuardData,_31 0_31 )_31 );_31 });
Final test code
_107import { ethers } from "hardhat";_107import { expect } from "chai";_107import { Signer, ZeroAddress } from "ethers";_107import { Safe, Safe__factory, SafeProxyFactory } from "../typechain-types";_107import { execTransaction } from "./utils/utils";_107import { NoDelegatecallGuard } from "../typechain-types/contracts/NoDelegatecallGuard";_107_107describe("NoDelegatecallGuard", async function () {_107 let deployer: Signer;_107 let alice: Signer;_107 let masterCopy: Safe;_107 let proxyFactory: SafeProxyFactory;_107 let safeFactory: Safe__factory;_107 let safe: Safe;_107 let exampleGuard: NoDelegatecallGuard;_107 const threshold = 1;_107_107 // Setup signers and deploy contracts before running tests_107 beforeEach(async () => {_107 [deployer, alice] = await ethers.getSigners();_107_107 safeFactory = await ethers.getContractFactory("Safe", deployer);_107 masterCopy = await safeFactory.deploy();_107_107 proxyFactory = await (_107 await ethers.getContractFactory("SafeProxyFactory", deployer)_107 ).deploy();_107_107 const ownerAddresses = [await alice.getAddress()];_107_107 const safeData = masterCopy.interface.encodeFunctionData("setup", [_107 ownerAddresses,_107 threshold,_107 ZeroAddress,_107 "0x",_107 ZeroAddress,_107 ZeroAddress,_107 0,_107 ZeroAddress,_107 ]);_107_107 // Read the safe address by executing the static call to createProxyWithNonce function_107 const safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_107 await masterCopy.getAddress(),_107 safeData,_107 0n_107 );_107_107 // Create the proxy with nonce_107 await proxyFactory.createProxyWithNonce(_107 await masterCopy.getAddress(),_107 safeData,_107 0n_107 );_107_107 if (safeAddress === ZeroAddress) {_107 throw new Error("Safe address not found");_107 }_107_107 // Deploy the NoDelegatecallGuard contract_107 exampleGuard = await (_107 await ethers.getContractFactory("NoDelegatecallGuard", deployer)_107 ).deploy();_107_107 safe = await ethers.getContractAt("Safe", safeAddress);_107_107 // Set the guard in the safe_107 const setGuardData = masterCopy.interface.encodeFunctionData("setGuard", [_107 exampleGuard.target,_107 ]);_107_107 // Execute the transaction to set the Guard_107 await execTransaction([alice], safe, safe.target, 0, setGuardData, 0);_107 });_107_107 it("Should not allow delegatecall", async function () {_107 const wallets = [alice];_107_107 await expect(_107 execTransaction(wallets, safe, ZeroAddress, 0, "0x", 1)_107 ).to.be.revertedWithCustomError(exampleGuard, "DelegatecallNotAllowed");_107 });_107_107 it("Should allow call", async function () {_107 const wallets = [alice];_107_107 expect(await execTransaction(wallets, safe, ZeroAddress, 0, "0x", 0));_107 });_107_107 it("Should allow to replace the guard", async function () {_107 const wallets = [alice];_107_107 const setGuardData = masterCopy.interface.encodeFunctionData("setGuard", [_107 ZeroAddress,_107 ]);_107 expect(_107 await execTransaction(_107 wallets,_107 safe,_107 await safe.getAddress(),_107 0,_107 setGuardData,_107 0_107 )_107 );_107 });_107});
Run the tests
_10npx hardhat test
Congratulations! You have successfully created, enabled and tested a Safe Guard.
Do more with Safe and Guard
Did you encounter any difficulties? Let us know by opening an issue (opens in a new tab) or asking a question on Stack Exchange (opens in a new tab) with the safe-core
tag.