Building a Fallback Handler for Safe Smart Account
This tutorial demonstrates how to build a custom Fallback Handler contract that adds a functions to a Safe Smart Account. You'll learn how to:
- Create a Fallback Handler
- Enable the Fallback Handler on a Safe Smart Account
- Write comprehensive tests for the Fallback Handler
You'll build a ERC1271FallbackHandler
that adds a support for ERC-1271 standard (opens in a new tab) to Safe Smart Account. This is only an example and should not be used in production without proper security audits.
Important Notice: The smart contract code provided in this tutorial is intended solely for educational purposes and serves only as an illustrative example. This example code has not undergone any security audits or formal verification processes. Safe does not guarantee the reliability, security, or correctness of this example code. Before deploying any smart contract code in a production environment, developers must conduct a thorough security audit and ensure rigorous testing procedures have been performed.
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-fallback-handler-tutorial && cd safe-fallback-handler-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 MyCustomFallbackHandler contract
_10// SPDX-License-Identifier: LGPL-3.0_10pragma solidity ^0.8.0;_10import {Safe} from "@safe-global/safe-contracts/contracts/Safe.sol";_10_10contract ERC1271FallbackHandler {_10_10}
Step 2: Define constants
_10 // keccak256("SafeMessage(bytes message)");_10 bytes32 private constant SAFE_MSG_TYPEHASH =_10 0x60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca;_10_10 // bytes4(keccak256("isValidSignature(bytes32,bytes)")_10 bytes4 internal constant EIP1271_MAGIC_VALUE = 0x1626ba7e;
Step 3: Create a function to encode message data
_21 /**_21 * @dev Returns the pre-image of the message hash (see getMessageHashForSafe)._21 * @param safe Safe to which the message is targeted._21 * @param message Message that should be encoded._21 * @return Encoded message._21 */_21 function encodeMessageDataForSafe(_21 Safe safe,_21 bytes memory message_21 ) public view returns (bytes memory) {_21 bytes32 safeMessageHash = keccak256(_21 abi.encode(SAFE_MSG_TYPEHASH, keccak256(message))_21 );_21 return_21 abi.encodePacked(_21 bytes1(0x19),_21 bytes1(0x01),_21 safe.domainSeparator(),_21 safeMessageHash_21 );_21 }
Explanation:
- This view function generates a encoded message that owners of the Safe can hash and sign.
Step 4: Implement the isValidSignature
function
_24 /**_24 * @notice Implementation of updated EIP-1271 signature validation method._24 * @param _dataHash Hash of the data signed on the behalf of address(msg.sender)_24 * @param _signature Signature byte array associated with _dataHash_24 * @return Updated EIP1271 magic value if signature is valid, otherwise 0x0_24 */_24 function isValidSignature(_24 bytes32 _dataHash,_24 bytes calldata _signature_24 ) external view returns (bytes4) {_24 // Caller should be a Safe_24 Safe safe = Safe(payable(msg.sender));_24 bytes memory messageData = encodeMessageDataForSafe(_24 safe,_24 abi.encode(_dataHash)_24 );_24 bytes32 messageHash = keccak256(messageData);_24 if (_signature.length == 0) {_24 require(safe.signedMessages(messageHash) != 0, "Hash not approved");_24 } else {_24 safe.checkSignatures(messageHash, messageData, _signature);_24 }_24 return EIP1271_MAGIC_VALUE;_24 }
- It computes a message hash from the provided data and checks for prior approval (if no signature is provided) or verifies the signature using the Safe's built-in
checkSignatures
method. Upon successful verification, it returns a specific value to confirm the signature's validity per EIP-1271. - If the signature verification fails, the function reverts.
Final contract code
_59// SPDX-License-Identifier: LGPL-3.0_59pragma solidity ^0.8.0;_59import {Safe} from "@safe-global/safe-contracts/contracts/Safe.sol";_59_59contract ERC1271FallbackHandler {_59 // keccak256("SafeMessage(bytes message)");_59 bytes32 private constant SAFE_MSG_TYPEHASH =_59 0x60b3cbf8b4a223d68d641b3b6ddf9a298e7f33710cf3d3a9d1146b5a6150fbca;_59_59 // bytes4(keccak256("isValidSignature(bytes32,bytes)")_59 bytes4 internal constant EIP1271_MAGIC_VALUE = 0x1626ba7e;_59_59 /**_59 * @dev Returns the pre-image of the message hash (see getMessageHashForSafe)._59 * @param safe Safe to which the message is targeted._59 * @param message Message that should be encoded._59 * @return Encoded message._59 */_59 function encodeMessageDataForSafe(_59 Safe safe,_59 bytes memory message_59 ) public view returns (bytes memory) {_59 bytes32 safeMessageHash = keccak256(_59 abi.encode(SAFE_MSG_TYPEHASH, keccak256(message))_59 );_59 return_59 abi.encodePacked(_59 bytes1(0x19),_59 bytes1(0x01),_59 safe.domainSeparator(),_59 safeMessageHash_59 );_59 }_59_59 /**_59 * @notice Implementation of updated EIP-1271 signature validation method._59 * @param _dataHash Hash of the data signed on the behalf of address(msg.sender)_59 * @param _signature Signature byte array associated with _dataHash_59 * @return Updated EIP1271 magic value if signature is valid, otherwise 0x0_59 */_59 function isValidSignature(_59 bytes32 _dataHash,_59 bytes calldata _signature_59 ) external view returns (bytes4) {_59 // Caller should be a Safe_59 Safe safe = Safe(payable(msg.sender));_59 bytes memory messageData = encodeMessageDataForSafe(_59 safe,_59 abi.encode(_dataHash)_59 );_59 bytes32 messageHash = keccak256(messageData);_59 if (_signature.length == 0) {_59 require(safe.signedMessages(messageHash) != 0, "Hash not approved");_59 } else {_59 safe.checkSignatures(messageHash, messageData, _signature);_59 }_59 return EIP1271_MAGIC_VALUE;_59 }_59}
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};
- 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 ERC1271FallbackHandler.test.ts
and include the following basic structure that will be filled in later steps (ignore the warnings about unused imports):
_32import { ethers } from "hardhat";_32import { expect } from "chai";_32import { Signer, ZeroAddress } from "ethers";_32import { Safe, Safe__factory, SafeProxyFactory } from "../typechain-types";_32import { ERC1271FallbackHandler } from "../typechain-types/contracts/ERC1271FallbackHandler";_32_32describe("ERC1271FallbackHandler.test", async function () {_32 let deployer: Signer;_32 let alice: Signer;_32 let masterCopy: Safe;_32 let proxyFactory: SafeProxyFactory;_32 let safeFactory: Safe__factory;_32 let safe: Safe;_32 let exampleFallbackHandler: ERC1271FallbackHandler;_32 const threshold = 1;_32_32 const EIP712_SAFE_MESSAGE_TYPE = {_32 // "SafeMessage(bytes message)"_32 SafeMessage: [{ type: "bytes", name: "message" }],_32 };_32_32 // Setup signers and deploy contracts before running tests_32 beforeEach(async () => {});_32_32 it("should revert if called directly", async () => {});_32_32 it("should revert if message was not signed", async () => {});_32_32 it("should revert if signature is not valid", async () => {});_32_32 it("should return magic value if enough owners signed and allow a mix different signature types", async () => {});_32});
Step 3: Setup contracts and variables in before hook
_50 // Setup signers and deploy contracts before running tests_50 beforeEach(async () => {_50 [deployer, alice] = await ethers.getSigners();_50_50 safeFactory = await ethers.getContractFactory("Safe", deployer);_50_50 // Deploy the ERC1271FallbackHandler contract_50 exampleFallbackHandler = await (_50 await ethers.getContractFactory("ERC1271FallbackHandler", deployer)_50 ).deploy();_50_50 masterCopy = await safeFactory.deploy();_50_50 proxyFactory = await (_50 await ethers.getContractFactory("SafeProxyFactory", deployer)_50 ).deploy();_50_50 const ownerAddresses = [await alice.getAddress()];_50_50 const safeData = masterCopy.interface.encodeFunctionData("setup", [_50 ownerAddresses,_50 threshold,_50 ZeroAddress,_50 "0x",_50 exampleFallbackHandler.target,_50 ZeroAddress,_50 0,_50 ZeroAddress,_50 ]);_50_50 // Read the safe address by executing the static call to createProxyWithNonce function_50 const safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_50 await masterCopy.getAddress(),_50 safeData,_50 0n_50 );_50_50 // Create the proxy with nonce_50 await proxyFactory.createProxyWithNonce(_50 await masterCopy.getAddress(),_50 safeData,_50 0n_50 );_50_50 if (safeAddress === ZeroAddress) {_50 throw new Error("Safe address not found");_50 }_50_50 safe = await ethers.getContractAt("Safe", safeAddress);_50 });
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.
- The Fallback Handler here is set during the Safe setup process. It is also possible to set the Fallback Handler after the Safe is created using
setFallbackHandler
function. - ⚠️ Security Note: Only trusted and audited code should be enabled as a Fallback Handler.
Step 4: Add test cases
_53 it("should revert if called directly", async () => {_53 const dataHash = ethers.keccak256("0xbaddad");_53 await expect(_53 exampleFallbackHandler.isValidSignature.staticCall(dataHash, "0x")_53 ).to.be.reverted;_53 });_53_53 it("should revert if message was not signed", async () => {_53 const validator = await ethers.getContractAt(_53 "ERC1271FallbackHandler",_53 safe.target_53 );_53 const dataHash = ethers.keccak256("0xbaddad");_53 await expect(_53 validator.isValidSignature.staticCall(dataHash, "0x")_53 ).to.be.revertedWith("Hash not approved");_53 });_53_53 it("should revert if signature is not valid", async () => {_53 const validator = await ethers.getContractAt(_53 "ERC1271FallbackHandler",_53 safe.target_53 );_53 const dataHash = ethers.keccak256("0xbaddad");_53 await expect(_53 validator.isValidSignature.staticCall(dataHash, "0xdeaddeaddeaddead")_53 ).to.be.reverted;_53 });_53_53 it("should return magic value if enough owners signed and allow a mix different signature types", async () => {_53 const validator = await ethers.getContractAt(_53 "ERC1271FallbackHandler",_53 safe.target_53 );_53_53 const validatorAddress = await validator.getAddress();_53 const dataHash = ethers.keccak256("0xbaddad");_53 const typedDataSig = {_53 signer: await alice.getAddress(),_53 data: await alice.signTypedData(_53 {_53 verifyingContract: validatorAddress,_53 chainId: (await ethers.provider.getNetwork()).chainId,_53 },_53 EIP712_SAFE_MESSAGE_TYPE,_53 { message: dataHash }_53 ),_53 };_53_53 expect(_53 await validator.isValidSignature.staticCall(dataHash, typedDataSig.data)_53 ).to.be.eq("0x1626ba7e");_53 });
- The test cases above cover the following scenarios:
- Reverting if the Fallback Handler is called directly.
- Reverting if the message was not signed.
- Reverting if the signature is not valid.
- Returning the magic value if enough owners signed and allowing a mix of different signature types.
- This is a basic set of tests to ensure the Fallback Handler is working as expected. More tests can be added to cover additional scenarios with different signing methods.
Final test code
_126import { ethers } from "hardhat";_126import { expect } from "chai";_126import { Signer, ZeroAddress } from "ethers";_126import { Safe, Safe__factory, SafeProxyFactory } from "../typechain-types";_126import { ERC1271FallbackHandler } from "../typechain-types/contracts/ERC1271FallbackHandler";_126_126describe("ERC1271FallbackHandler.test", async function () {_126 let deployer: Signer;_126 let alice: Signer;_126 let masterCopy: Safe;_126 let proxyFactory: SafeProxyFactory;_126 let safeFactory: Safe__factory;_126 let safe: Safe;_126 let exampleFallbackHandler: ERC1271FallbackHandler;_126 const threshold = 1;_126_126 const EIP712_SAFE_MESSAGE_TYPE = {_126 // "SafeMessage(bytes message)"_126 SafeMessage: [{ type: "bytes", name: "message" }],_126 };_126_126 // Setup signers and deploy contracts before running tests_126 beforeEach(async () => {_126 [deployer, alice] = await ethers.getSigners();_126_126 safeFactory = await ethers.getContractFactory("Safe", deployer);_126_126 // Deploy the ERC1271FallbackHandler contract_126 exampleFallbackHandler = await (_126 await ethers.getContractFactory("ERC1271FallbackHandler", deployer)_126 ).deploy();_126_126 masterCopy = await safeFactory.deploy();_126_126 proxyFactory = await (_126 await ethers.getContractFactory("SafeProxyFactory", deployer)_126 ).deploy();_126_126 const ownerAddresses = [await alice.getAddress()];_126_126 const safeData = masterCopy.interface.encodeFunctionData("setup", [_126 ownerAddresses,_126 threshold,_126 ZeroAddress,_126 "0x",_126 exampleFallbackHandler.target,_126 ZeroAddress,_126 0,_126 ZeroAddress,_126 ]);_126_126 // Read the safe address by executing the static call to createProxyWithNonce function_126 const safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_126 await masterCopy.getAddress(),_126 safeData,_126 0n_126 );_126_126 // Create the proxy with nonce_126 await proxyFactory.createProxyWithNonce(_126 await masterCopy.getAddress(),_126 safeData,_126 0n_126 );_126_126 if (safeAddress === ZeroAddress) {_126 throw new Error("Safe address not found");_126 }_126_126 safe = await ethers.getContractAt("Safe", safeAddress);_126 });_126_126 it("should revert if called directly", async () => {_126 const dataHash = ethers.keccak256("0xbaddad");_126 await expect(_126 exampleFallbackHandler.isValidSignature.staticCall(dataHash, "0x")_126 ).to.be.reverted;_126 });_126_126 it("should revert if message was not signed", async () => {_126 const validator = await ethers.getContractAt(_126 "ERC1271FallbackHandler",_126 safe.target_126 );_126 const dataHash = ethers.keccak256("0xbaddad");_126 await expect(_126 validator.isValidSignature.staticCall(dataHash, "0x")_126 ).to.be.revertedWith("Hash not approved");_126 });_126_126 it("should revert if signature is not valid", async () => {_126 const validator = await ethers.getContractAt(_126 "ERC1271FallbackHandler",_126 safe.target_126 );_126 const dataHash = ethers.keccak256("0xbaddad");_126 await expect(_126 validator.isValidSignature.staticCall(dataHash, "0xdeaddeaddeaddead")_126 ).to.be.reverted;_126 });_126_126 it("should return magic value if enough owners signed and allow a mix different signature types", async () => {_126 const validator = await ethers.getContractAt(_126 "ERC1271FallbackHandler",_126 safe.target_126 );_126_126 const validatorAddress = await validator.getAddress();_126 const dataHash = ethers.keccak256("0xbaddad");_126 const typedDataSig = {_126 signer: await alice.getAddress(),_126 data: await alice.signTypedData(_126 {_126 verifyingContract: validatorAddress,_126 chainId: (await ethers.provider.getNetwork()).chainId,_126 },_126 EIP712_SAFE_MESSAGE_TYPE,_126 { message: dataHash }_126 ),_126 };_126_126 expect(_126 await validator.isValidSignature.staticCall(dataHash, typedDataSig.data)_126 ).to.be.eq("0x1626ba7e");_126 });_126});
Run the tests
_10npx hardhat test
Congratulations! You have successfully created, enabled and tested a Fallback Handler for Safe Smart Account.
Do more with Safe and Fallback Handlers
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.