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 TokenWithdrawModule
allows:
- Safe owners to authorize token withdrawals via off-chain signatures
- Beneficiaries to execute withdrawals themselves without requiring Safe owner transactions
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
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 _beneficiary,_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 _beneficiary,_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:
_101// SPDX-License-Identifier: LGPL-3.0_101pragma solidity ^0.8.0;_101import "@safe-global/safe-contracts/contracts/common/Enum.sol";_101import "@safe-global/safe-contracts/contracts/Safe.sol";_101_101/**_101 * @title TokenWithdrawModule_101 * @dev Contract implementing the a module that transfers tokens from a Safe contract to the user having a valid signature._101 */_101contract TokenWithdrawModule {_101 bytes32 public immutable PERMIT_TYPEHASH =_101 keccak256(_101 "TokenWithdrawModule(uint256 amount,address _beneficiary,uint256 nonce,uint256 deadline)"_101 );_101 address public immutable safeAddress;_101 address public immutable tokenAddress;_101 mapping(address => uint256) public nonces;_101_101 /**_101 * @dev Constructor function for the contract_101 *_101 * @param _tokenAddress address of the ERC20 token contract_101 * @param _safeAddress address of the Safe contract_101 */_101 constructor(address _tokenAddress, address _safeAddress) {_101 tokenAddress = _tokenAddress;_101 safeAddress = _safeAddress;_101 }_101_101 /**_101 * @dev Generates the EIP-712 domain separator for the contract._101 *_101 * @return The EIP-712 domain separator._101 */_101 function getDomainSeparator() private view returns (bytes32) {_101 return keccak256(_101 abi.encode(_101 keccak256(_101 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"_101 ),_101 keccak256(bytes("TokenWithdrawModule")),_101 keccak256(bytes("1")),_101 block.chainid,_101 address(this)_101 )_101 );_101 }_101_101 /**_101 * @dev Transfers a specified amount of tokens to a beneficiary._101 *_101 * @param _amount amount of tokens to be transferred_101 * @param _beneficiary address of the beneficiary_101 * @param _deadline deadline for the validity of the signature_101 * @param _signatures signatures of the Safe owner(s)_101 */_101 function tokenTransfer(_101 uint _amount,_101 address _beneficiary,_101 uint256 _deadline,_101 bytes memory _signatures_101 ) public {_101 require(_deadline >= block.timestamp, "expired deadline");_101_101 bytes32 signatureData = keccak256(_101 abi.encode(_101 PERMIT_TYPEHASH,_101 _amount,_101 msg.sender,_101 nonces[msg.sender]++,_101 _deadline_101 )_101 );_101 _101 bytes32 hash = keccak256(_101 abi.encodePacked("\x19\x01", getDomainSeparator(), signatureData)_101 );_101_101 Safe(payable(safeAddress)).checkSignatures(_101 hash,_101 abi.encodePacked(signatureData),_101 _signatures_101 );_101_101 bytes memory data = abi.encodeWithSignature(_101 "transfer(address,uint256)",_101 _beneficiary,_101 _amount_101 );_101_101 require(_101 Safe(payable(safeAddress)).execTransactionFromModule(_101 tokenAddress,_101 0,_101 data,_101 Enum.Operation.Call_101 ),_101 "Could not execute token transfer"_101 );_101 }_101}
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 an 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, Safe__factory, 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:
_177// Import necessary libraries and types_177import { ethers } from "hardhat";_177import { expect } from "chai";_177import { Signer, TypedDataDomain, ZeroAddress } from "ethers";_177import {_177 Safe,_177 Safe__factory,_177 TestToken,_177 TokenWithdrawModule,_177} from "../typechain-types";_177import { execTransaction } from "./utils/utils";_177_177describe("TokenWithdrawModule Tests", function () {_177 // Define variables_177 let deployer: Signer;_177 let alice: Signer;_177 let bob: Signer;_177 let charlie: Signer;_177 let masterCopy: any;_177 let token: TestToken;_177 let safe: Safe;_177 let safeAddress: string;_177 let chainId: bigint;_177_177 // Setup signers and deploy contracts before running tests_177 before(async () => {_177 [deployer, alice, bob, charlie] = await ethers.getSigners();_177_177 chainId = (await ethers.provider.getNetwork()).chainId;_177 const safeFactory = await ethers.getContractFactory("Safe", deployer);_177 masterCopy = await safeFactory.deploy();_177_177 // Deploy a new token contract_177 token = await (_177 await ethers.getContractFactory("TestToken", deployer)_177 ).deploy("test", "T");_177_177 // Deploy a new SafeProxyFactory contract_177 const proxyFactory = await (_177 await ethers.getContractFactory("SafeProxyFactory", deployer)_177 ).deploy();_177_177 // Setup the Safe, Step 1, generate transaction data_177 const safeData = masterCopy.interface.encodeFunctionData("setup", [_177 [await alice.getAddress()],_177 1,_177 ZeroAddress,_177 "0x",_177 ZeroAddress,_177 ZeroAddress,_177 0,_177 ZeroAddress,_177 ]);_177_177 // Read the safe address by executing the static call to createProxyWithNonce function_177 safeAddress = await proxyFactory.createProxyWithNonce.staticCall(_177 await masterCopy.getAddress(),_177 safeData,_177 0n_177 );_177_177 if (safeAddress === ZeroAddress) {_177 throw new Error("Safe address not found");_177 }_177_177 // Setup the Safe, Step 2, execute the transaction_177 await proxyFactory.createProxyWithNonce(_177 await masterCopy.getAddress(),_177 safeData,_177 0n_177 );_177_177 safe = await ethers.getContractAt("Safe", safeAddress);_177_177 // Mint tokens to the safe address_177 await token_177 .connect(deployer)_177 .mint(safeAddress, BigInt(10) ** BigInt(18) * BigInt(100000));_177 });_177_177 // A Safe Module is a smart contract that is allowed to execute transactions on behalf of a Safe Smart Account._177 // This function deploys the TokenWithdrawModule contract and enables it in the Safe._177 const enableModule = async (): Promise<{_177 tokenWithdrawModule: TokenWithdrawModule;_177 }> => {_177 // Deploy the TokenWithdrawModule contract and pass the token and safe address as arguments_177 const tokenWithdrawModule = await (_177 await ethers.getContractFactory("TokenWithdrawModule", deployer)_177 ).deploy(token.target, safeAddress);_177_177 // Enable the module in the safe, Step 1, generate transaction data_177 const enableModuleData = masterCopy.interface.encodeFunctionData(_177 "enableModule",_177 [tokenWithdrawModule.target]_177 );_177_177 // Enable the module in the safe, Step 2, execute the transaction_177 await execTransaction([alice], safe, safe.target, 0, enableModuleData, 0);_177_177 // Verify that the module is enabled_177 expect(await safe.isModuleEnabled.staticCall(tokenWithdrawModule.target)).to_177 .be.true;_177_177 return { tokenWithdrawModule };_177 };_177_177 // Test case to verify token transfer to bob_177 it("Should successfully transfer tokens to bob", async function () {_177 // Enable the module in the Safe_177 const { tokenWithdrawModule } = await enableModule();_177_177 const amount = 10000000000000000000n; // 10 * 10^18_177 const deadline = 100000000000000n;_177 const nonce = await tokenWithdrawModule.nonces(await bob.getAddress());_177_177 // Our module expects a EIP-712 typed signature, so we need to define the EIP-712 domain, ..._177 const domain: TypedDataDomain = {_177 name: "TokenWithdrawModule",_177 version: "1",_177 chainId: chainId,_177 verifyingContract: await tokenWithdrawModule.getAddress(),_177 };_177_177 // ... and EIP-712 types ..._177 const types = {_177 TokenWithdrawModule: [_177 { name: "amount", type: "uint256" },_177 { name: "_beneficiary", type: "address" },_177 { name: "nonce", type: "uint256" },_177 { name: "deadline", type: "uint256" },_177 ],_177 };_177_177 // ... and EIP-712 values ..._177 const value = {_177 amount: amount,_177 _beneficiary: await bob.getAddress(),_177 nonce: nonce,_177 deadline: deadline,_177 };_177_177 // ... and finally hash the data using EIP-712_177 const digest = ethers.TypedDataEncoder.hash(domain, types, value);_177 const bytesDataHash = ethers.getBytes(digest);_177 let signatureBytes = "0x";_177_177 // Alice signs the digest_177 const flatSig = (await alice.signMessage(bytesDataHash))_177 .replace(/1b$/, "1f")_177 .replace(/1c$/, "20");_177 signatureBytes += flatSig.slice(2);_177_177 // We want to make sure that an invalid signer cannot call the module even with a valid signature_177 // We test this before the valid transaction, because it would fail because of an invalid nonce otherwise_177 await expect(_177 tokenWithdrawModule_177 .connect(charlie)_177 .tokenTransfer(_177 amount,_177 await charlie.getAddress(),_177 deadline,_177 signatureBytes_177 )_177 ).to.be.revertedWith("GS026");_177_177 // Now we use the signature to transfer via our module_177 await tokenWithdrawModule_177 .connect(bob)_177 .tokenTransfer(amount, await bob.getAddress(), deadline, signatureBytes);_177_177 // Verify the token balance of bob (should be 10000000000000000000)_177 const balanceBob = await token.balanceOf.staticCall(await bob.getAddress());_177 expect(balanceBob).to.be.equal(amount);_177_177 // All done._177 });_177});
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.