Advanced
Smart Account Modules Tutorial

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

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.


_10
mkdir safe-module-tutorial && cd safe-module-tutorial


_10
npm 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
}


_10
npm add -D hardhat @safe-global/safe-contracts @openzeppelin/contracts hardhat-dependency-compiler

Initialize hardhat project


_10
npx 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.


_10
npx 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.


_19
import { HardhatUserConfig } from "hardhat/config";
_19
import "@nomicfoundation/hardhat-toolbox";
_19
import "hardhat-dependency-compiler";
_19
_19
const 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
_19
export 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
_11
pragma solidity ^0.8.0;
_11
// Imports will be added here
_11
_11
contract 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


_10
import "@safe-global/safe-contracts/contracts/common/Enum.sol";
_10
import "@safe-global/safe-contracts/contracts/Safe.sol";

Explanation:

  • Enum.sol: Provides Enum Operation which can have values like Call or DelegateCall. 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.


_10
bytes32 public immutable PERMIT_TYPEHASH =
_10
keccak256(
_10
"TokenWithdrawModule(uint256 amount,address _beneficiary,uint256 nonce,uint256 deadline)"
_10
);
_10
address public immutable safeAddress;
_10
address public immutable tokenAddress;
_10
mapping(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.


_10
constructor(address _tokenAddress, address _safeAddress) {
_10
tokenAddress = _tokenAddress;
_10
safeAddress = _safeAddress;
_10
}

  • Initializes tokenAddress and safeAddress 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.


_45
function 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:

  1. Parameter Validation:
    • Ensure the _deadline is valid.
    • Construct signatureData with the provided details and PERMIT_TYPEHASH.
  2. Hash Calculation:
    • Compute the hash using the EIP712 format to ensure signature consistency.
  3. Signature Verification:
    • Call checkSignatures on the Safe to verify the signatures provided match the owners of the Safe.
  4. Transaction Execution:
    • A module can use execTransactionFromModule or execTransactionFromModuleReturnData 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.

Final contract code

Here is the complete code for reference with comments:


_101
// SPDX-License-Identifier: LGPL-3.0
_101
pragma solidity ^0.8.0;
_101
import "@safe-global/safe-contracts/contracts/common/Enum.sol";
_101
import "@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
*/
_101
contract 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
_16
pragma solidity ^0.8.0;
_16
_16
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
_16
import "@openzeppelin/contracts/access/Ownable.sol";
_16
_16
contract 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.


_76
import { ethers } from "hardhat";
_76
import { Signer, AddressLike, BigNumberish, ZeroAddress } from "ethers";
_76
import { 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
*/
_76
const 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
_76
export {
_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):


_30
import { ethers } from "hardhat";
_30
import { expect } from "chai";
_30
import { Signer, TypedDataDomain, ZeroAddress } from "ethers";
_30
import { Safe, Safe__factory, TestToken, TokenWithdrawModule } from "../typechain-types";
_30
import { execTransaction } from "./utils/utils";
_30
_30
describe("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
_177
import { ethers } from "hardhat";
_177
import { expect } from "chai";
_177
import { Signer, TypedDataDomain, ZeroAddress } from "ethers";
_177
import {
_177
Safe,
_177
Safe__factory,
_177
TestToken,
_177
TokenWithdrawModule,
_177
} from "../typechain-types";
_177
import { execTransaction } from "./utils/utils";
_177
_177
describe("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


_10
npx 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.

Was this page helpful?