Building Applications with Safe Modules
This tutorial demonstrates how to:
- Create a Safe Module
- Enable a module on a Safe account
- Execute transactions through the module
You will build a TokenWithdrawModule
that enables beneficiaries to withdraw ERC20 tokens from a Safe account using off-chain signatures from Safe owners.
Prerequisites
- 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
Implementation Details
The module gives a third party an allowance via a signature for the token that can freely be used by that third party. In the module this third party is represented as the "beneficiary" in the EIP-712 struct (mentioned in the PERMIT_TYPEHASH). To use this allowance the third party / "beneficiary" calls the tokenTransfer
method and specifies which token to use and who should receive it.
The Safe owners grant the permission for token transfer to the third party by signing the EIP-712 struct without requiring Safe owners to execute any on-chain transaction.
Limitations
- Each beneficiary has a sequential nonce, requiring withdrawals to be processed in order
- The module is bound to a specific token and Safe address at deployment
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.
Project Setup
Start a new project directory and initialize npm.
_10mkdir safe-module-tutorial && cd safe-module-tutorial
_10npm init
You can choose all default values.
Install dependencies
Add overrides in package.json
so that there are no peer dependency related issues.
_10{_10 // ... _10 "overrides": {_10 "@safe-global/safe-contracts": {_10 "ethers": "^6.13.5"_10 }_10 }_10}
_10npm add -D hardhat @safe-global/safe-contracts @openzeppelin/contracts hardhat-dependency-compiler
Initialize hardhat project
_10npx hardhat init
Select Create a TypeScript project
and leave the default values for the rest of the prompts.
Now, try compiling the contracts to ensure everything is set up correctly.
_10npx hardhat compile
Update hardhat.config.ts
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
contract.
_19import { HardhatUserConfig } from "hardhat/config";_19import "@nomicfoundation/hardhat-toolbox";_19import "hardhat-dependency-compiler";_19_19const config: HardhatUserConfig = {_19 solidity: "0.8.28",_19 networks: {_19 hardhat: {_19 allowUnlimitedContractSize: true,_19 },_19 },_19 dependencyCompiler: {_19 paths: [_19 "@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol",_19 ],_19 },_19};_19_19export 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 TokenWithdrawModule.sol
in the contracts
directory.
Step 1. Create empty contract
_11// SPDX-License-Identifier: LGPL-3.0_11pragma solidity ^0.8.0;_11// Imports will be added here_11_11contract TokenWithdrawModule {_11 // State variables will be added here_11_11 // Constructor will be added here_11_11 // Functions will be added here_11}
Explanation:
- SPDX License Identifier: Specifies the license type.
pragma solidity ^0.8.0
: Defines the Solidity compiler version.contract TokenWithdrawModule
: Declares the contract name.
Step 2: Import required dependencies
_10import "@safe-global/safe-contracts/contracts/common/Enum.sol";_10import "@safe-global/safe-contracts/contracts/Safe.sol";
Explanation:
Enum.sol
: Provides EnumOperation
which can have values likeCall
orDelegateCall
. This will be used further in the contract when a module calls a Safe account where the module specifies the operation type.Safe.sol
: Includes the Safe contract interface to interact with Safe accounts.
Step 3: Define state variables
Declare the necessary state variables for the contract.
_10bytes32 public immutable PERMIT_TYPEHASH =_10 keccak256(_10 "TokenWithdrawModule(uint256 amount,address beneficiary,uint256 nonce,uint256 deadline)"_10 );_10address public immutable safeAddress;_10address public immutable tokenAddress;_10mapping(address => uint256) public nonces;
Explanation:
PERMIT_TYPEHASH
: Used to construct the signature hash for the token transfer.safeAddress
: Stores the Safe contract address.tokenAddress
: Stores the ERC20 token contract address.nonces
: Tracks unique nonce to prevent replay attacks.
Step 4: Create the Constructor
Define a constructor to initialize the Safe and token contract addresses.
_10constructor(address _tokenAddress, address _safeAddress) {_10 tokenAddress = _tokenAddress;_10 safeAddress = _safeAddress;_10}
- Initializes
tokenAddress
andsafeAddress
with provided values during deployment. Thus, in this module the token and Safe addresses are fixed.
Step 5: Implement the getDomainSeparator
function
Add a helper function to compute the EIP-712 domain separator.
_13 function getDomainSeparator() private view returns (bytes32) {_13 return keccak256(_13 abi.encode(_13 keccak256(_13 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"_13 ),_13 keccak256(bytes("TokenWithdrawModule")),_13 keccak256(bytes("1")),_13 block.chainid,_13 address(this)_13 )_13 );_13 }
Explanation:
- Computes the
EIP712Domain
separator for the current chain and contract. - Ensures compatibility with the EIP-712 standard for off-chain signing.
- Using a Domain separator ensures that the signature is valid for specific contracts in context and the chain. Thus, preventing replay attacks.
Step 6: Implement the tokenTransfer
function
Add a function to handle token transfers from the Safe.
_45function tokenTransfer(_45 uint amount,_45 address receiver,_45 uint256 deadline,_45 bytes memory signatures_45) public {_45 require(deadline >= block.timestamp, "expired deadline");_45_45 bytes32 signatureData = keccak256(_45 abi.encode(_45 PERMIT_TYPEHASH,_45 amount,_45 msg.sender,_45 nonces[msg.sender]++,_45 deadline_45 )_45 );_45_45 bytes32 hash = keccak256(_45 abi.encodePacked("\x19\x01", getDomainSeparator(), signatureData)_45 );_45_45 Safe(payable(safeAddress)).checkSignatures(_45 hash,_45 abi.encodePacked(signatureData),_45 signatures_45 );_45_45 bytes memory data = abi.encodeWithSignature(_45 "transfer(address,uint256)",_45 receiver,_45 amount_45 );_45_45 // Calling `execTransactionFromModule` with the transaction data to execute the token transfer through the Safe account._45 require(_45 Safe(payable(safeAddress)).execTransactionFromModule(_45 tokenAddress,_45 0,_45 data,_45 Enum.Operation.Call_45 ),_45 "Could not execute token transfer"_45 );_45}
Explanation:
- Parameter Validation:
- Ensure the
deadline
is valid. - Construct
signatureData
with the provided details andPERMIT_TYPEHASH
.
- Ensure the
- Hash Calculation:
- Compute the hash using the
EIP712
format to ensure signature consistency.
- Compute the hash using the
- Signature Verification:
- Call
checkSignatures
on the Safe to verify the signatures provided match the owners of the Safe.
- Call
- Transaction Execution:
- A module can use
execTransactionFromModule
orexecTransactionFromModuleReturnData
function to execute transactions through a Safe account on which the module is enabled. - Encode the transfer call using
abi.encodeWithSignature
. - Use
execTransactionFromModule
to execute the token transfer via the Safe. - Ensure execution succeeds, otherwise revert.
- A module can use
Final contract code
Here is the complete code for reference with comments:
_104// SPDX-License-Identifier: LGPL-3.0_104pragma solidity ^0.8.0;_104import "@safe-global/safe-contracts/contracts/common/Enum.sol";_104import "@safe-global/safe-contracts/contracts/Safe.sol";_104_104/**_104 * @title TokenWithdrawModule_104 * @dev This contract implements a Safe module that enables a user with a valid signature to_104 * transfer ERC20 tokens from a Safe contract to a specified receiver._104 */_104contract TokenWithdrawModule {_104 bytes32 public immutable PERMIT_TYPEHASH =_104 keccak256(_104 "TokenWithdrawModule(uint256 amount,address beneficiary,uint256 nonce,uint256 deadline)"_104 );_104 address public immutable safeAddress;_104 address public immutable tokenAddress;_104 mapping(address => uint256) public nonces;_104_104 /**_104 * @dev Constructor function for the contract_104 * @param _tokenAddress address of the ERC20 token contract_104 * @param _safeAddress address of the Safe contract_104 */_104 constructor(address _tokenAddress, address _safeAddress) {_104 tokenAddress = _tokenAddress;_104 safeAddress = _safeAddress;_104 }_104_104 /**_104 * @dev Generates the EIP-712 domain separator for the contract._104 *_104 * @return The EIP-712 domain separator._104 */_104 function getDomainSeparator() private view returns (bytes32) {_104 return_104 keccak256(_104 abi.encode(_104 keccak256(_104 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"_104 ),_104 keccak256(bytes("TokenWithdrawModule")),_104 keccak256(bytes("1")),_104 block.chainid,_104 address(this)_104 )_104 );_104 }_104_104 /**_104 * @dev Transfers the specified amount of tokens to a receiver address. The msg.sender must hold a valid signature._104 * The msg.sender address must be used as the `beneficiary` parameter in the EIP-712 structured data for_104 * signature generation. However, msg.sender can specify a different `receiver` address to receive the tokens_104 * when withdrawing the tokens._104 * @param amount amount of tokens to be transferred_104 * @param receiver address to which the tokens will be transferred_104 * @param deadline deadline for the validity of the signature_104 * @param signatures signatures of the Safe owner(s)_104 */_104 function tokenTransfer(_104 uint amount,_104 address receiver,_104 uint256 deadline,_104 bytes memory signatures_104 ) public {_104 require(deadline >= block.timestamp, "expired deadline");_104_104 bytes32 signatureData = keccak256(_104 abi.encode(_104 PERMIT_TYPEHASH,_104 amount,_104 msg.sender,_104 nonces[msg.sender]++,_104 deadline_104 )_104 );_104_104 bytes32 hash = keccak256(_104 abi.encodePacked("\x19\x01", getDomainSeparator(), signatureData)_104 );_104_104 Safe(payable(safeAddress)).checkSignatures(_104 hash,_104 abi.encodePacked(signatureData),_104 signatures_104 );_104_104 bytes memory data = abi.encodeWithSignature(_104 "transfer(address,uint256)",_104 receiver,_104 amount_104 );_104_104 require(_104 Safe(payable(safeAddress)).execTransactionFromModule(_104 tokenAddress,_104 0,_104 data,_104 Enum.Operation.Call_104 ),_104 "Could not execute token transfer"_104 );_104 }_104}
Create TestToken.sol contract
Create a new file in the contracts
directory named TestToken.sol
and add the following code:
_16// SPDX-License-Identifier: LGPL-3.0_16pragma solidity ^0.8.0;_16_16import "@openzeppelin/contracts/token/ERC20/ERC20.sol";_16import "@openzeppelin/contracts/access/Ownable.sol";_16_16contract TestToken is ERC20, Ownable {_16 constructor(_16 string memory _name,_16 string memory _symbol_16 ) ERC20(_name, _symbol) Ownable(msg.sender){}_16_16 function mint(address to, uint256 amount) public onlyOwner {_16 _mint(to, amount);_16 }_16}
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 TokenWithdrawModule.test.ts
and include the following basic structure that will be filled in later steps (ignore the warnings about unused imports):
_30import { ethers } from "hardhat";_30import { expect } from "chai";_30import { Signer, TypedDataDomain, ZeroAddress } from "ethers";_30import { Safe, TestToken, TokenWithdrawModule } from "../typechain-types";_30import { execTransaction } from "./utils/utils";_30_30describe("TokenWithdrawModule Tests", function () {_30 // Define variables_30 let deployer: Signer;_30 let alice: Signer;_30 let bob: Signer;_30 let charlie: Signer;_30 let masterCopy: any;_30 let token: TestToken;_30 let safe: Safe;_30 let safeAddress: string;_30 let chainId: bigint;_30 _30 // Before hook to setup the contracts_30 before(async () => {_30 });_30_30 // Enable the module in the Safe_30 const enableModule = async () => {_30 }_30_30 // Add your test cases here_30 it("Should successfully transfer tokens to bob", async function () {_30 });_30});
Step 3: Setup contracts and variables in before hook
_55 // Setup signers and deploy contracts before running tests_55 before(async () => {_55 [deployer, alice, bob, charlie] = await ethers.getSigners();_55_55 chainId = (await ethers.provider.getNetwork()).chainId;_55 const safeFactory = await ethers.getContractFactory("Safe", deployer);_55 masterCopy = await safeFactory.deploy();_55_55 // Deploy a new token contract_55 token = await (_55 await ethers.getContractFactory("TestToken", deployer)_55 ).deploy("test", "T");_55_55 // Deploy a new SafeProxyFactory contract_55 const proxyFactory = await (_55 await ethers.getContractFactory("SafeProxyFactory", deployer)_55 ).deploy();_55_55 // Setup the Safe, Step 1, generate transaction data_55 const safeData = masterCopy.interface.encodeFunctionData("setup", [_55 [await alice.getAddress()],_55 1,_55 ZeroAddress,_55 "0x",_55 ZeroAddress,_55 ZeroAddress,_55 0,_55 ZeroAddress,_55 ]);_55_55 // Read the safe address by executing the static call to createProxyWithNonce function_55 safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_55 await masterCopy.getAddress(),_55 safeData,_55 0n_55 );_55 _55 if (safeAddress === ZeroAddress) {_55 throw new Error("Safe address not found");_55 }_55_55 // Setup the Safe, Step 2, execute the transaction_55 await proxyFactory.createProxyWithNonce(_55 await masterCopy.getAddress(),_55 safeData,_55 0n_55 );_55_55 safe = await ethers.getContractAt("Safe", safeAddress);_55_55 // Mint tokens to the safe address_55 await token_55 .connect(deployer)_55 .mint(safeAddress, BigInt(10) ** BigInt(18) * BigInt(100000));_55 });
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.
- We can receive the Safe address before deploying the Safe.
Step 4: Deploy and enable module in enableModule
function
_25 // A Safe Module is a smart contract that is allowed to execute transactions on behalf of a Safe Smart Account._25 // This function deploys the TokenWithdrawModule contract and enables it in the Safe._25 const enableModule = async (): Promise<{_25 tokenWithdrawModule: TokenWithdrawModule;_25 }> => {_25 // Deploy the TokenWithdrawModule contract and pass the token and safe address as arguments_25 const tokenWithdrawModule = await (_25 await ethers.getContractFactory("TokenWithdrawModule", deployer)_25 ).deploy(token.target, safeAddress);_25_25 // Enable the module in the safe, Step 1, generate transaction data_25 const enableModuleData = masterCopy.interface.encodeFunctionData(_25 "enableModule",_25 [tokenWithdrawModule.target]_25 );_25_25 // Enable the module in the safe, Step 2, execute the transaction_25 await execTransaction([alice], safe, safe.target, 0, enableModuleData, 0);_25_25 // Verify that the module is enabled_25 expect(await safe.isModuleEnabled.staticCall(tokenWithdrawModule.target)).to_25 .be.true;_25_25 return { tokenWithdrawModule };_25 };
This step deploys the TokenWithdrawModule contract and enables it in the Safe.
Please note that:
- Alice as the owner of the Safe is required to enable the module.
- The module is enabled by calling the
enableModule
function on the Safe contract. - The
enableModule
function is called with the address of the newly deployed module. - ⚠️ Security Note: Only trusted and audited code should be enabled as a module, since modules have full access to the Safe's assets. A malicious module could drain all funds.
Step 5: Add test case
_71_71 // Test case to verify token transfer to bob_71 it("Should successfully transfer tokens to bob", async function () {_71 // Enable the module in the Safe_71 const { tokenWithdrawModule } = await enableModule();_71_71 const amount = 10000000000000000000n; // 10 * 10^18_71 const deadline = 100000000000000n;_71 const nonce = await tokenWithdrawModule.nonces(await bob.getAddress());_71_71 // Our module expects a EIP-712 typed signature, so we need to define the EIP-712 domain, ..._71 const domain: TypedDataDomain = {_71 name: "TokenWithdrawModule",_71 version: "1",_71 chainId: chainId,_71 verifyingContract: await tokenWithdrawModule.getAddress(),_71 };_71_71 // ... and EIP-712 types ..._71 const types = {_71 TokenWithdrawModule: [_71 { name: "amount", type: "uint256" },_71 { name: "beneficiary", type: "address" },_71 { name: "nonce", type: "uint256" },_71 { name: "deadline", type: "uint256" },_71 ],_71 };_71_71 // ... and EIP-712 values ..._71 const value = {_71 amount: amount,_71 beneficiary: await bob.getAddress(),_71 nonce: nonce,_71 deadline: deadline,_71 };_71_71 // ... and finally hash the data using EIP-712_71 const digest = ethers.TypedDataEncoder.hash(domain, types, value);_71 const bytesDataHash = ethers.getBytes(digest);_71 let signatureBytes = "0x";_71_71 // Alice signs the digest_71 const flatSig = (await alice.signMessage(bytesDataHash))_71 .replace(/1b$/, "1f")_71 .replace(/1c$/, "20");_71 signatureBytes += flatSig.slice(2);_71_71 // We want to make sure that an invalid signer cannot call the module even with a valid signature_71 // We test this before the valid transaction, because it would fail because of an invalid nonce otherwise_71 await expect(_71 tokenWithdrawModule_71 .connect(charlie)_71 .tokenTransfer(_71 amount,_71 await charlie.getAddress(),_71 deadline,_71 signatureBytes_71 )_71 ).to.be.revertedWith("GS026");_71_71 // Now we use the signature to transfer via our module_71 await tokenWithdrawModule_71 .connect(bob)_71 .tokenTransfer(amount, await bob.getAddress(), deadline, signatureBytes);_71_71 // Verify the token balance of bob (should be 10000000000000000000)_71 const balanceBob = await token.balanceOf.staticCall(await bob.getAddress());_71 expect(balanceBob).to.be.equal(amount);_71_71 // All done._71 });
This step tests the token transfer functionality of the module.
Note that:
- The module can execute transactions on behalf of the Safe by calling the
execTransactionFromModule
function. - We added an security check to the module that checks if the signers of a Safe signed the typed EIP-712 data. A module without this check could be called by any address.
Final test code
Here is the complete code for reference:
_176// Import necessary libraries and types_176import { ethers } from "hardhat";_176import { expect } from "chai";_176import { Signer, TypedDataDomain, ZeroAddress } from "ethers";_176import {_176 Safe,_176 TestToken,_176 TokenWithdrawModule,_176} from "../typechain-types";_176import { execTransaction } from "./utils/utils";_176_176describe("TokenWithdrawModule Tests", function () {_176 // Define variables_176 let deployer: Signer;_176 let alice: Signer;_176 let bob: Signer;_176 let charlie: Signer;_176 let masterCopy: any;_176 let token: TestToken;_176 let safe: Safe;_176 let safeAddress: string;_176 let chainId: bigint;_176_176 // Setup signers and deploy contracts before running tests_176 before(async () => {_176 [deployer, alice, bob, charlie] = await ethers.getSigners();_176_176 chainId = (await ethers.provider.getNetwork()).chainId;_176 const safeFactory = await ethers.getContractFactory("Safe", deployer);_176 masterCopy = await safeFactory.deploy();_176_176 // Deploy a new token contract_176 token = await (_176 await ethers.getContractFactory("TestToken", deployer)_176 ).deploy("test", "T");_176_176 // Deploy a new SafeProxyFactory contract_176 const proxyFactory = await (_176 await ethers.getContractFactory("SafeProxyFactory", deployer)_176 ).deploy();_176_176 // Setup the Safe, Step 1, generate transaction data_176 const safeData = masterCopy.interface.encodeFunctionData("setup", [_176 [await alice.getAddress()],_176 1,_176 ZeroAddress,_176 "0x",_176 ZeroAddress,_176 ZeroAddress,_176 0,_176 ZeroAddress,_176 ]);_176_176 // Read the safe address by executing the static call to createProxyWithNonce function_176 safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_176 await masterCopy.getAddress(),_176 safeData,_176 0n_176 );_176_176 if (safeAddress === ZeroAddress) {_176 throw new Error("Safe address not found");_176 }_176_176 // Setup the Safe, Step 2, execute the transaction_176 await proxyFactory.createProxyWithNonce(_176 await masterCopy.getAddress(),_176 safeData,_176 0n_176 );_176_176 safe = await ethers.getContractAt("Safe", safeAddress);_176_176 // Mint tokens to the safe address_176 await token_176 .connect(deployer)_176 .mint(safeAddress, BigInt(10) ** BigInt(18) * BigInt(100000));_176 });_176_176 // A Safe Module is a smart contract that is allowed to execute transactions on behalf of a Safe Smart Account._176 // This function deploys the TokenWithdrawModule contract and enables it in the Safe._176 const enableModule = async (): Promise<{_176 tokenWithdrawModule: TokenWithdrawModule;_176 }> => {_176 // Deploy the TokenWithdrawModule contract and pass the token and safe address as arguments_176 const tokenWithdrawModule = await (_176 await ethers.getContractFactory("TokenWithdrawModule", deployer)_176 ).deploy(token.target, safeAddress);_176_176 // Enable the module in the safe, Step 1, generate transaction data_176 const enableModuleData = masterCopy.interface.encodeFunctionData(_176 "enableModule",_176 [tokenWithdrawModule.target]_176 );_176_176 // Enable the module in the safe, Step 2, execute the transaction_176 await execTransaction([alice], safe, safe.target, 0, enableModuleData, 0);_176_176 // Verify that the module is enabled_176 expect(await safe.isModuleEnabled.staticCall(tokenWithdrawModule.target)).to_176 .be.true;_176_176 return { tokenWithdrawModule };_176 };_176_176 // Test case to verify token transfer to bob_176 it("Should successfully transfer tokens to bob", async function () {_176 // Enable the module in the Safe_176 const { tokenWithdrawModule } = await enableModule();_176_176 const amount = 10000000000000000000n; // 10 * 10^18_176 const deadline = 100000000000000n;_176 const nonce = await tokenWithdrawModule.nonces(await bob.getAddress());_176_176 // Our module expects a EIP-712 typed signature, so we need to define the EIP-712 domain, ..._176 const domain: TypedDataDomain = {_176 name: "TokenWithdrawModule",_176 version: "1",_176 chainId: chainId,_176 verifyingContract: await tokenWithdrawModule.getAddress(),_176 };_176_176 // ... and EIP-712 types ..._176 const types = {_176 TokenWithdrawModule: [_176 { name: "amount", type: "uint256" },_176 { name: "beneficiary", type: "address" },_176 { name: "nonce", type: "uint256" },_176 { name: "deadline", type: "uint256" },_176 ],_176 };_176_176 // ... and EIP-712 values ..._176 const value = {_176 amount: amount,_176 beneficiary: await bob.getAddress(),_176 nonce: nonce,_176 deadline: deadline,_176 };_176_176 // ... and finally hash the data using EIP-712_176 const digest = ethers.TypedDataEncoder.hash(domain, types, value);_176 const bytesDataHash = ethers.getBytes(digest);_176 let signatureBytes = "0x";_176_176 // Alice signs the digest_176 const flatSig = (await alice.signMessage(bytesDataHash))_176 .replace(/1b$/, "1f")_176 .replace(/1c$/, "20");_176 signatureBytes += flatSig.slice(2);_176_176 // We want to make sure that an invalid signer cannot call the module even with a valid signature_176 // We test this before the valid transaction, because it would fail because of an invalid nonce otherwise_176 await expect(_176 tokenWithdrawModule_176 .connect(charlie)_176 .tokenTransfer(_176 amount,_176 await charlie.getAddress(),_176 deadline,_176 signatureBytes_176 )_176 ).to.be.revertedWith("GS026");_176_176 // Now we use the signature to transfer via our module_176 await tokenWithdrawModule_176 .connect(bob)_176 .tokenTransfer(amount, await bob.getAddress(), deadline, signatureBytes);_176_176 // Verify the token balance of bob (should be 10000000000000000000)_176 const balanceBob = await token.balanceOf.staticCall(await bob.getAddress());_176 expect(balanceBob).to.be.equal(amount);_176_176 // All done._176 });_176});
Run the tests
_10npx hardhat test
Congratulations! You have successfully created, enabled and tested a Safe Module.
Do more with Safe and Safe Modules
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.