Building an ERC20 Permit Token on Rootstock: A Complete Guide


Introduction
In this tutorial, we will explore ERC20 permits, their benefits, and how to implement them by deploying an ERC20 token with permit functionality on the Rootstock (RSK) testnet.
What is Rootstock (RSK)?
Rootstock is the first and longest-lasting Bitcoin sidechain. It brings full Ethereum-compatible smart contract capabilities (EVM) to Bitcoin, enabling developers to deploy Solidity smart contracts while leveraging the security of the Bitcoin network.
RBTC is the native token used to pay for transaction execution on the Rootstock network. This allows Ethereum developers to write Solidity smart contracts and deploy them in an environment similar to Ethereum but with Bitcoin's security model.
Key Features:
EVM-compatible
Bitcoin merge-mined security
Low gas costs
Uses RBTC for gas payment
What is ERC20 Permit (EIP-2612)?
ERC20 permit is an extension introduced by EIP-2612 that allows for gasless approvals. Traditional ERC20 tokens require two transactions for delegated transfers:
approve(spender, amount)
transferFrom(owner, spender, amount)
With permit, users can sign a message off-chain (using their private key), and the spender can submit it on-chain to set the allowance, saving gas and improving UX.
Benefits:
Gasless approvals (user signs, dApp submits)
Reduces the number of transactions
Useful for Web3 wallets, DeFi platforms, and relayer-based systems
Step 1: Project Setup
1. Install hardhat if you don't have it installed already
npm install --save-dev hardhat
To use your local installation of Hardhat, you need to use npx to run it (i.e., npx hardhat init).
2. Initialize a hardhat project
npx hardhat init
When prompted, select 'Create a TypeScript project'.
Note: This tutorial uses Ethers.js v6, which introduces some new APIs like Signature.from(). Be sure to upgrade if you're using v5 or adapt accordingly.
3. Install dotenv
npm install dotenv
This will install the dependencies:
Hardhat (development environment)
Hardhat Toolbox (testing, ethers, waffle, chai)
dotenv (to manage sensitive data securely)
4. Configure Environment Variables
Create a .env file in your project root:
PRIVATE_KEY=<your_rootstock_private_key>
Important: Never commit your .env
file to source control. Always add it to .gitignore
.
Step 2: Get Test RBTC
To deploy or interact with contracts on the RSK testnet, you'll need test RBTC.
Faucet:
Visit: RSK Faucet
Paste your RSK testnet address
Click "Send me RBTC"
You’ll receive a small amount of RBTC for deploying and testing contracts.
Step 3: Writing the ERC20 Permit Token
Create the contract at contracts/SpecialToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title ERC20 Token Contract
/// @notice Implements an ERC20 token with permit functionality.
/// @dev Inherits from OpenZeppelin's:
/// - ERC20: Standard ERC20 implementation
/// - Ownable: Ownership management
/// - ERC20Permit: Permit-based approvals
contract SpecialToken is ERC20, Ownable, ERC20Permit {
using SafeERC20 for IERC20;
// Custom errors for gas efficiency
error InvalidToken();
error InvalidRecipient();
error InvalidAmount();
error InsufficientBalance();
uint256 public constant MAX_TOTAL_SUPPLY = 1000000000 * 10 ** 18; // 1 billion tokens with 18 decimals
event EmergencyWithdraw(
address indexed token,
address indexed recipient,
uint256 indexed amount
);
/// @notice Initializes the Score token contract.
/// @param owner Address receiving the initial max token supply.
/// @dev Mints the max token supply to the owner.
constructor(
address owner
) ERC20("SpecialToken", "SPT") ERC20Permit("SpecialToken") Ownable(owner) {
_mint(owner, MAX_TOTAL_SUPPLY);
}
/// @notice Fetches the maximum total supply of the token.
/// @return MAX_TOTAL_SUPPLY The maximum total supply of the token.
function maxSupply() public pure returns (uint256) {
return MAX_TOTAL_SUPPLY;
}
/// @notice Allows the owner to emergency withdraw tokens from the contract.
/// @param token The address of the token to withdraw.
/// @param recipient The address of the recipient.
/// @param amount The amount of tokens to withdraw.
function emergencyWithdraw(
address token,
address recipient,
uint256 amount
) external onlyOwner {
// Gas efficient checks using custom errors
if (token == address(0)) revert InvalidToken();
if (recipient == address(0)) revert InvalidRecipient();
if (amount == 0) revert InvalidAmount();
// Check balance using cached address
if (amount > IERC20(token).balanceOf(address(this)))
revert InsufficientBalance();
// Transfer tokens
IERC20(token).safeTransfer(recipient, amount);
emit EmergencyWithdraw(token, recipient, amount);
}
}
Explanation:
Let's break down each part of the SpecialToken.sol smart contract in detail:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.29;
- Specifies the license identifier and Solidity version.
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
ERC20: Provides the core ERC-20 token implementation.
Ownable: Adds ownership functionality (e.g., onlyOwner modifiers).
ERC20Permit: Implements the permit() method from EIP-2612, enabling gasless approvals.
safeERC20: It ensures compatibility with non-standard ERC20 tokens
contract SpecialToken is ERC20, Ownable, ERC20Permit {
- The contract inherits from all three imported OpenZeppelin contracts, combining ERC20 token features, ownership access control, and signature-based permits.
using SafeERC20 for IERC20;
- Using safeTransfer ensures compatibility with non-standard ERC20s that don't return true.
uint256 public constant MAX_TOTAL_SUPPLY = 1_000_000_000 * 10 ** 18;
- Declares a constant for the maximum total supply (1 billion tokens with 18 decimal places).
event EmergencyWithdraw(address indexed token, address indexed recipient, uint256 indexed amount);
- Defines a custom event for emergencies that helps the owner withdraw ERC20 tokens from the contract.
constructor(
address owner
) ERC20("SpecialToken", "SPT") ERC20Permit("SpecialToken") Ownable(owner) {
_mint(owner, MAX_TOTAL_SUPPLY);
}
Constructor:
Initializes the ERC20 token with name "SpecialToken" and symbol "SPT".
Initializes the ERC20Permit extension with the token name.
Sets the provided address as the owner using Ownable(owner).
// Mints the total supply to the owner's address immediately.
function maxSupply() public pure returns (uint256) {
return MAX_TOTAL_SUPPLY;
}
- A public helper function to return the max supply constant.
function emergencyWithdraw(
address token,
address recipient,
uint256 amount
) external onlyOwner {
// Gas efficient checks using custom errors
if (token == address(0)) revert InvalidToken();
if (recipient == address(0)) revert InvalidRecipient();
if (amount == 0) revert InvalidAmount();
// Check balance using cached address
if (amount > IERC20(token).balanceOf(address(this)))
revert InsufficientBalance();
// Transfer tokens
IERC20(token).safeTransfer(recipient, amount);
emit EmergencyWithdraw(token, recipient, amount);
}
- The Emergency Withdraw function helps recover tokens mistakenly sent to the contract or reclaim tokens in critical situations.
This contract is secure, simple, and highly compatible with DeFi protocols. It enables powerful UX improvements via permit(), especially for gasless approval flows in frontend apps and wallets.
ERC20: Standard token functionality
Ownable: Allows only the contract owner to perform sensitive actions
ERC20Permit: Enables permit() for signature-based approvals
MAX_TOTAL_SUPPLY: Fixed total supply of 1 billion tokens
Step 4: Understanding the Tests
The SpecialToken.ts
file contains comprehensive test cases to validate the behavior of our ERC20 + Permit token contract. Each describe block groups related tests, and loadFixture() ensures a clean deployment for every test, avoiding state pollution.
Create the test file at test/SpecialToken.ts:
import { loadFixture, time } from "@nomicfoundation/hardhat-network-helpers";
import { Signature } from "ethers";
import { ethers } from "hardhat";
import { expect } from "chai";
describe("SpecialToken", function () {
// Define a fixture that deploys the SpecialToken contract
async function deploySpecialTokenFixture() {
// Get signers
const [owner, user1, user2, user3] = await ethers.getSigners();
// Deploy the SpecialToken contract
const SpecialTokenFactory = await ethers.getContractFactory("SpecialToken");
const specialToken = await SpecialTokenFactory.deploy(owner.address);
return { specialToken, owner, user1, user2, user3 };
}
describe("Deployment", function () {
it("should set the correct name", async function () {
const { specialToken } = await loadFixture(deploySpecialTokenFixture);
expect(await specialToken.name()).to.equal("SpecialToken");
});
it("should set the correct symbol", async function () {
const { specialToken } = await loadFixture(deploySpecialTokenFixture);
expect(await specialToken.symbol()).to.equal("SPT");
});
it("should mint tokens to owner on deployment", async function () {
const { specialToken, owner } = await loadFixture(
deploySpecialTokenFixture
);
// Check the total supply
const maxSupply = await specialToken.maxSupply();
const ownerBalance = await specialToken.balanceOf(owner.address);
// Verify that the owner received all tokens
expect(ownerBalance).to.equal(maxSupply);
});
it("should set the maxTotalSupply correctly", async function () {
const { specialToken } = await loadFixture(deploySpecialTokenFixture);
const MAX_TOTAL_SUPPLY = ethers.parseEther("1000000000"); // 1 billion tokens with 18 decimals
expect(await specialToken.maxSupply()).to.equal(MAX_TOTAL_SUPPLY);
});
});
describe("Transfers", function () {
it("should allow transfers between accounts", async function () {
const { specialToken, owner, user1 } = await loadFixture(
deploySpecialTokenFixture
);
// Transfer tokens from owner to user1
const transferAmount = ethers.parseEther("1000");
await specialToken.connect(owner).transfer(user1.address, transferAmount);
// Check the balances
expect(await specialToken.balanceOf(user1.address)).to.equal(
transferAmount
);
});
it("should fail if sender does not have enough balance", async function () {
const { specialToken, user1, user2 } = await loadFixture(
deploySpecialTokenFixture
);
const transferAmount = ethers.parseEther("1000");
// User1 has no tokens initially
await expect(
specialToken.connect(user1).transfer(user2.address, transferAmount)
).to.be.reverted;
});
it("should not allow transfers to the zero address", async function () {
const { specialToken, owner } = await loadFixture(
deploySpecialTokenFixture
);
const transferAmount = ethers.parseEther("1000");
await expect(
specialToken.connect(owner).transfer(ethers.ZeroAddress, transferAmount)
).to.be.reverted;
});
});
describe("Approvals", function () {
it("should allow approvals and transfers", async function () {
const { specialToken, owner, user1, user2 } = await loadFixture(
deploySpecialTokenFixture
);
const transferAmount = ethers.parseEther("1000");
await specialToken.connect(owner).approve(user1.address, transferAmount);
await specialToken
.connect(user1)
.transferFrom(owner.address, user2.address, transferAmount);
expect(await specialToken.balanceOf(user2.address)).to.equal(
transferAmount
);
});
it("should fail if sender does not have enough allowance", async function () {
const { specialToken, owner, user1, user2 } = await loadFixture(
deploySpecialTokenFixture
);
const transferAmount = ethers.parseEther("1000");
await expect(
specialToken
.connect(user1)
.transferFrom(owner.address, user2.address, transferAmount)
).to.be.reverted;
});
it("should not allow approval to the zero address", async function () {
const { specialToken, owner } = await loadFixture(
deploySpecialTokenFixture
);
const approvalAmount = ethers.parseEther("1000");
await expect(
specialToken.connect(owner).approve(ethers.ZeroAddress, approvalAmount)
).to.be.reverted;
});
});
describe("Permit", function () {
it("should allow approvals and transfers with permit", async function () {
const { specialToken, owner, user1, user2 } = await loadFixture(
deploySpecialTokenFixture
);
const value = ethers.parseEther("1000");
const nonce = await specialToken.nonces(owner.address);
const now = await time.latest();
const deadline = now + 3600;
// Define the EIP-712 domain object used for signing typed data
const domainSeparator = {
name: await specialToken.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: specialToken.target.toString(),
};
// Define the types for EIP-712
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
// Prepare the message object
const message = {
owner: owner.address,
spender: user1.address,
value: value,
nonce: nonce,
deadline: deadline,
};
// Sign the typed data
const signature = await owner.signTypedData(
domainSeparator,
types,
message
);
// Split the signature
const sig = Signature.from(signature);
// Call permit with the signature components
await specialToken.permit(
owner.address,
user1.address,
value,
deadline,
sig.v,
sig.r,
sig.s
);
// Verify the allowance was set correctly
expect(
await specialToken.allowance(owner.address, user1.address)
).to.equal(value);
// Use the allowance to transfer tokens
await specialToken
.connect(user1)
.transferFrom(owner.address, user2.address, value);
// Check balances to ensure the transfer succeeded
expect(await specialToken.balanceOf(user2.address)).to.equal(value);
});
it("should prevent replay attacks with same nonce", async function () {
const { specialToken, owner, user1 } = await loadFixture(
deploySpecialTokenFixture
);
const value = ethers.parseEther("1000");
const nonce = await specialToken.nonces(owner.address);
const now = await time.latest();
const deadline = now + 3600;
const domainSeparator = {
name: await specialToken.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: specialToken.target.toString(),
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const message = {
owner: owner.address,
spender: user1.address,
value: value,
nonce: nonce,
deadline: deadline,
};
const signature = await owner.signTypedData(
domainSeparator,
types,
message
);
const sig = Signature.from(signature);
// First permit call should succeed
await specialToken.permit(
owner.address,
user1.address,
value,
deadline,
sig.v,
sig.r,
sig.s
);
// create a new signature with the same nonce
const deadline2 = (await time.latest()) + 3600;
const message2 = {
owner: owner.address,
spender: user1.address,
value: value,
nonce: nonce,
deadline: deadline2,
};
const signature2 = await owner.signTypedData(
domainSeparator,
types,
message2
);
const sig2 = Signature.from(signature2);
// Second permit call with same nonce should fail
await expect(
specialToken.permit(
owner.address,
user1.address,
value,
deadline2,
sig2.v,
sig2.r,
sig2.s
)
).to.be.revertedWithCustomError(specialToken, "ERC2612InvalidSigner");
});
it("should prevent approvals after deadline", async function () {
const { specialToken, owner, user1 } = await loadFixture(
deploySpecialTokenFixture
);
const value = ethers.parseEther("1000");
const nonce = await specialToken.nonces(owner.address);
const now = await time.latest();
const deadline = now + 3600; // 1 hour from now
const domainSeparator = {
name: await specialToken.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: specialToken.target.toString(),
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const message = {
owner: owner.address,
spender: user1.address,
value: value,
nonce: nonce,
deadline: deadline,
};
const signature = await owner.signTypedData(
domainSeparator,
types,
message
);
const sig = Signature.from(signature);
// Increase blockchain time to exceed the deadline
await time.increase(3601); // 1 hour and 1 second
// Should fail because the deadline has passed
await expect(
specialToken.permit(
owner.address,
user1.address,
value,
deadline,
sig.v,
sig.r,
sig.s
)
).to.be.revertedWithCustomError(specialToken, "ERC2612ExpiredSignature");
});
it("should prevent approvals with invalid signatures", async function () {
const { specialToken, owner, user1, user2 } = await loadFixture(
deploySpecialTokenFixture
);
const value = ethers.parseEther("1000");
const nonce = await specialToken.nonces(owner.address);
const now = await time.latest();
const deadline = now + 3600;
const domainSeparator = {
name: await specialToken.name(),
version: "1",
chainId: (await ethers.provider.getNetwork()).chainId,
verifyingContract: specialToken.target.toString(),
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const message = {
owner: owner.address,
spender: user1.address,
value: value,
nonce: nonce,
deadline: deadline,
};
// user2 signs instead of owner - should be invalid
const signature = await user2.signTypedData(
domainSeparator,
types,
message
);
const sig = Signature.from(signature);
await expect(
specialToken.permit(
owner.address,
user1.address,
value,
deadline,
sig.v,
sig.r,
sig.s
)
).to.be.revertedWithCustomError(specialToken, "ERC2612InvalidSigner");
});
});
describe("EmergencyWithdraw", function () {
it("should allow owner to withdraw tokens", async function () {
const { specialToken, owner, user1 } = await loadFixture(
deploySpecialTokenFixture
);
const tokenAmount = ethers.parseEther("1000");
// Transfer tokens to the contract first
await specialToken
.connect(owner)
.transfer(specialToken.target, tokenAmount);
await specialToken
.connect(owner)
.emergencyWithdraw(specialToken.target, user1.address, tokenAmount);
expect(await specialToken.balanceOf(user1.address)).to.equal(tokenAmount);
});
it("Should revert if amount/recipient/token is 0", async function () {
const { specialToken, owner, user1 } = await loadFixture(
deploySpecialTokenFixture
);
await expect(
specialToken
.connect(owner)
.emergencyWithdraw(
specialToken.target,
user1.address,
ethers.parseEther("0")
)
).to.be.revertedWithCustomError(specialToken, "InvalidAmount");
await expect(
specialToken
.connect(owner)
.emergencyWithdraw(
ethers.ZeroAddress,
user1.address,
ethers.parseEther("1000")
)
).to.be.revertedWithCustomError(specialToken, "InvalidToken");
await expect(
specialToken
.connect(owner)
.emergencyWithdraw(
specialToken.target,
ethers.ZeroAddress,
ethers.parseEther("1000")
)
).to.be.revertedWithCustomError(specialToken, "InvalidRecipient");
});
it("Should revert if amount is greater than balance", async function () {
const { specialToken, owner, user1 } = await loadFixture(
deploySpecialTokenFixture
);
const tokenAmount = ethers.parseEther("1000");
await specialToken.connect(owner).transfer(user1.address, tokenAmount);
await expect(
specialToken
.connect(owner)
.emergencyWithdraw(
owner.address,
owner.address,
ethers.parseEther("100000")
)
).to.be.reverted;
});
});
});
Note: These custom error messages, like ERC2612InvalidSigner
and ERC2612ExpiredSignature
are part of the OpenZeppelin ERC20Permit
implementation. They are automatically used when permit validation fails. You don't need to define them manually in your contract.
Test Summary
Deployment: Checks token name, symbol, and initial supply.
Transfer: Valid and invalid token transfers.
Approval: Tests
approve()
andtransferFrom()
behavior.Permit (EIP-2612): Validates signature-based approvals via
permit()
.Emergency Withdraw:
Only the owner can withdraw tokens.
Fails on zero address/token/amount or insufficient balance.
Each test is structured using loadFixture() from Hardhat to isolate deployments.
Run the tests and confirm they all pass:
npx hardhat test test/SpecialToken.ts
Result:
Test Breakdown
Permit Signature Components Explained
When using permit(), a user signs a message off-chain and submits it on-chain. The signature must be split into three components:
const sig = Signature.from(signature);
This gives us:
sig.v: The recovery ID (used to recover the signer address).
sig.r: First 32 bytes of the ECDSA signature.
sig.s: Second 32 bytes of the ECDSA signature.
These three values are required by the permit() function:
await specialToken.permit(
owner.address, // owner who signed the message
user1.address, // spender who gets the allowance
value, // number of tokens approved
deadline, // expiration time of the permit
sig.v, sig.r, sig.s // signature components
);
The smart contract uses these components to:
Verify that the owner signed the message.
Prevent replay attacks by checking the nonce.
Enforce expiration via deadline.
Set the token allowance accordingly.
This enables gasless approvals, where the spender can trigger a permit() transaction without the owner needing to pay gas.
Deployment Tests
These tests verify that the token initializes correctly.
- Name and Symbol Checks:
expect(await specialToken.name()).to.equal("SpecialToken");
expect(await specialToken.symbol()).to.equal("SPT");
Ensures that the token's name and symbol match what was defined in the constructor.
- Minting to Owner:
const maxSupply = await specialToken.maxSupply();
const ownerBalance = await specialToken.balanceOf(owner.address);
expect(ownerBalance).to.equal(maxSupply);
Confirms that the full supply is minted to the owner upon deployment.
- Supply Check:
expect(await specialToken.maxSupply()).to.equal(ethers.parseEther("1000000000"));
Verifies that the constant MAX_TOTAL_SUPPLY
is correctly configured.
Transfer Tests
These test token transfers under various scenarios.
Successful Transfer: Tokens are transferred from owner to another user.
Insufficient Balance: Prevents transfers if the sender lacks tokens.
Zero Address Transfer: Rejects transfers to the 0x0 address.
Approval Tests
These validate standard ERC20 approve and transferFrom behaviors.
Valid Approval and Transfer: The Owner approves the spender, and the spender calls transferFrom to move tokens.
No Allowance: If allowance is not set, transferFrom is rejected.
Zero Address Approval: Rejects approvals to the zero address for security.
Permit Tests (EIP-2612)
These are the most important tests, verifying permit-based off-chain approvals.
Permit-Based Approval and Transfer:
Owner signs a message.
Spender calls permit().
Spender uses transferFrom().
Replay Attack Prevention: Reusing the same nonce fails the second time, protecting from replay attacks.
Deadline Expiry: It fails if the transaction occurs after the deadline timestamp.
Invalid Signature: If the signer is not the owner, the permit fails.
All these tests ensure that the contract follows ERC20 + Permit standards safely and effectively.
Permit Test Highlights
const signature = await owner.signTypedData(
domainSeparator,
types,
message
);
const sig = Signature.from(signature);
await specialToken.permit(owner.address, spender.address, value, deadline, sig.v, sig.r, sig.s);
This simulates a user signing an approval off-chain and submitting it on-chain.
Note: signTypedData(domain, types, message) uses the EIP-712 standard to sign structured data. The contract then verifies this signature on-chain inside permit()
to authorize gasless approvals.
Understanding the Process of permit()
Parameters
The permit()
function typically requires the following parameters:
owner: The address of the token holder who is approving.
spender: The address that is being approved to spend the tokens.
value: The amount of tokens the spender is allowed to transfer.
deadline: A timestamp until which the permit is valid. After this time, the permit cannot be used.
v, r, s: Components of the signature. These are derived from the owner's signature of the permit data.
Domain Separator
The domain separator is part of the EIP-712 standard, which is used to prevent replay attacks across different domains (e.g., different smart contracts). It includes:
name: The name of the token.
version: The version of the contract.
chainId: The ID of the blockchain network.
verifyingContract: The address of the contract that will verify the signature.
Typed Data
The permit()
function uses EIP-712 typed data to create a structured message that the owner signs. This message includes:
- owner, spender, value, nonce, deadline: These are the same as the parameters passed to the
permit()
function.
Signature Verification
The signature (v
, r
, s
) is used to verify that the owner has indeed signed the message. This is done using the signTypedData
function, which creates a hash of the domain separator and the typed data, and then verifies the signature against this hash.
Nonce
Each permit includes a nonce, which is a unique number that ensures each permit can only be used once. This prevents replay attacks where the same permit could be used multiple times.
Execution
Once the signature is verified, the allowance is set for the spender to transfer the specified amount of tokens on behalf of the owner. This is done without the owner having to send a transaction themselves, thus saving gas fees.
Why are these parameters needed?
Owner and Spender: These are essential to define who is granting the permission and who is receiving it.
Value: Specifies the amount of tokens that can be transferred, ensuring that the spender cannot transfer more than intended.
Deadline: Provides a time limit for the permit's validity, adding a layer of security by ensuring that permits cannot be used indefinitely.
Signature Components (v, r, s): These are crucial for verifying the authenticity of the permit. They ensure that the permit was indeed authorized by the owner.
Nonce: Ensures that each permit is unique and can only be used once, preventing replay attacks.
Overall, the permit()
function is a powerful feature that enhances the usability of tokens by allowing off-chain approvals, reducing the need for gas-consuming on-chain transactions.
Step 5: Deploying the Contract
The deploy.ts
script automates the deployment of your SpecialToken smart contract to the Rootstock testnet.
import { ethers } from "hardhat";
async function main() {
const [deployer] = await ethers.getSigners();
console.log("Deploying contracts with the account:", deployer.address);
const SpecialToken = await ethers.getContractFactory("SpecialToken");
const erc20 = await SpecialToken.deploy(deployer.address);
await erc20.waitForDeployment();
console.log("SpecialToken deployed to:", erc20.target);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Breakdown
Import Hardhat and dotenv: Loads Hardhat environment and .env file to access private keys.
Get signer: Retrieves the account used to deploy the contract (should match the PRIVATE_KEY in .env).
Get contract factory: Loads the compiled contract artifacts.
Deploy contract: Calls the constructor with the deployer’s address as the owner.
Wait for deployment: Ensures deployment is mined before continuing.
Log address: Prints the deployed contract address for reference.
Run the Script
npx hardhat run scripts/deploy.ts --network rskTestnet
Ensure that your Hardhat config includes the rskTestnet network using the RPC URL
and PRIVATE_KEY
from your .env
file.
Output:
Step 6: Permit Interaction Script
This script simulates a real-world use case of permit()
+ transferFrom()
interaction. Here's what it does, step by step:
This script demonstrates how to use the permit function (EIP-2612) for gasless token approvals and token transfers. It simulates a real-world scenario where:
The token owner signs an approval message off-chain.
The spender submits that signature on-chain via
permit()
.The spender uses
transferFrom()
to move the tokens to a third party.
Detailed Explanation
Make sure hardhat.config.ts
and .env
files are properly configured
import "@nomicfoundation/hardhat-toolbox";
import "dotenv/config";
import { HardhatUserConfig } from "hardhat/config";
const config: HardhatUserConfig = {
solidity: "0.8.29",
networks: {
rskTestnet: {
url: `https://public-node.testnet.rsk.co`,
chainId: 31,
gasPrice: "auto",
accounts: [
`0x${process.env.ROOTSTOCK_TESTNET_OWNER_PRIVATE_KEY}`,
`0x${process.env.ROOTSTOCK_TESTNET_RECEIVER_PRIVATE_KEY}`,
],
},
},
};
export default config;
.env
file:
# Replace with your Private key
ROOTSTOCK_TESTNET_OWNER_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000000"
ROOTSTOCK_TESTNET_RECEIVER_PRIVATE_KEY="0000000000000000000000000000000000000000000000000000000000000000"
Let’s use permit()
to approve tokens for the receiver
- Initialize Signers
const [owner, receiver] = await ethers.getSigners();
owner is the token holder who wants to approve a transfer.
receiver will act as the spender who receives the signed permission.
Note: There should be two private keys in the
.env
file, and also under the Rootstock network definition in hardhat.config.ts
- Attach to Deployed Contract
const SpecialToken = await ethers.getContractFactory("SpecialToken");
const ERC20 = SpecialToken.attach("<DEPLOYED_CONTRACT_ADDRESS>");
- Loads the compiled contract and attaches it to the already deployed contract address.
- Define Permit Parameters
const value = ethers.parseEther("1000");
const nonce = await ERC20.nonces(owner.address);
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
value: Number of tokens to approve for transfer.
nonce: Must match what the contract expects to avoid replay attacks.
deadline: Expiry time for the permit (1 hour from now).
- EIP-712 Domain and Message
const domainSeparator = {
name: await ERC20.name(),
version: "1",
chainId: 31,
verifyingContract: ERC20.target.toString(),
};
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
const message = {
owner: owner.address,
spender: receiver.address,
value: value,
nonce: nonce,
deadline: deadline,
};
These elements are required for signTypedData to follow EIP-712 structure.
5. Sign and Submit the Permit
const signature = await owner.signTypedData(domainSeparator, types, message);
const sig = Signature.from(signature);
const permitTx = await ERC20.permit(
owner.address,
receiver.address,
value,
deadline,
sig.v,
sig.r,
sig.s
);
await permitTx.wait();
owner signs the permit off-chain.
receiver submits it on-chain using permit().
- Check Allowance and Transfer
const allowance = await ERC20.allowance(owner.address, receiver.address);
const receiver2 = "0x397a.....2f68827"; // replace with your receiver2 address
const transferTx = await ERC20.connect(receiver).transferFrom(
owner.address,
receiver2,
value
);
- Now that the allowance is set, receiver can move funds to a third address (receiver2).
- Verify Transfer
const receiver2_balance = await ERC20.balanceOf(receiver2);
- Confirms that transferFrom() worked using the permit-enabled allowance.
This flow allows approvals to happen off-chain (free for the user), and on-chain transfers to be submitted by dApps or third parties — ideal for gasless UX!
This is the complete script for PermitToken.ts
import { Signature } from "ethers";
import { ethers } from "hardhat";
async function main() {
const [owner, receiver] = await ethers.getSigners();
const SpecialToken = await ethers.getContractFactory("SpecialToken");
// Replace with your deployed contract address
const ERC20 = SpecialToken.attach(
"0x8463D0085D1f2020518d859d15e8B5c13661b38b"
);
const value = ethers.parseEther("1000");
console.log("Value to transfer:", value.toString());
const nonce = await ERC20.nonces(owner.address);
console.log("Current nonce:", nonce.toString());
const deadline = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
console.log("Deadline:", deadline);
const receiver2 = "0x39……………..827"; // replace with your receiver2 address
// Get the domain separator
const domainSeparator = {
name: await ERC20.name(),
version: "1",
chainId: 31,
verifyingContract: ERC20.target.toString(),
};
console.log("Domain separator:", domainSeparator);
// Define the types for EIP-712
const types = {
Permit: [
{ name: "owner", type: "address" },
{ name: "spender", type: "address" },
{ name: "value", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint256" },
],
};
// Prepare the message object
const message = {
owner: owner.address,
spender: receiver.address,
value: value,
nonce: nonce,
deadline: deadline,
};
console.log("Message to sign:", message);
// Sign the typed data
const signature = await owner.signTypedData(domainSeparator, types, message);
console.log("Signature:", signature);
// Split the signature
const sig = Signature.from(signature);
console.log("Split signature:", sig);
// Call permit with the signature components
const permitTx = await ERC20.permit(
owner.address,
receiver.address,
value,
deadline,
sig.v,
sig.r,
sig.s
);
console.log("Permit transaction hash:", permitTx.hash);
await permitTx.wait();
console.log("Permit transaction confirmed");
// Verify the allowance was set correctly
const allowance = await ERC20.allowance(owner.address, receiver.address);
console.log(`Receiver allowance: ${allowance.toString()}`);
const receiver2_balance_before = await ERC20.balanceOf(receiver2);
console.log(
`Receiver2 balance before transfer: ${receiver2_balance_before.toString()}`
);
// Use the allowance to transfer tokens
const transferTx = await ERC20.connect(receiver).transferFrom(
owner.address,
receiver2,
value
);
console.log("Transfer transaction hash:", transferTx.hash);
await transferTx.wait();
console.log("Transfer transaction confirmed");
// Check balances to ensure the transfer succeeded
const receiver2_balance = await ERC20.balanceOf(receiver2);
console.log(
`Receiver2 balance after transfer: ${receiver2_balance.toString()}`
);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Run the Script
npx hardhat run scripts/PermitTokens.ts --network rskTestnet
Output:
Key Takeaways
The owner never interacts on-chain — they only sign a message.
The receiver (spender) uses that signed message to call permit().
This sets the allowance, allowing the receiver to call transferFrom().
This interaction script is ideal for frontend dApps and wallets that want to offer users gasless approvals and seamless token transfer experiences.
This script demonstrates real-world usage:
Create a signed message
Call permit() on-chain
Use transferFrom() using the new allowance
Explanation
Sign Permit Off-Chain: The owner signs a typed message authorizing the spender.
Submit Permit On-Chain: The spender calls permit() with the signature.
Execute Transfer: The spender uses transferFrom() to transfer tokens from the owner to a recipient.
This showcases a full permit-based workflow with on-chain confirmation.
Conclusion
You've now:
Understood Rootstock and its smart contract ecosystem
Implemented an advanced ERC20 token with permit support
Written robust test cases covering all edge cases
Simulated real-world usage with scripts and manual
permit()
calls
This tutorial sets a solid foundation for building DeFi apps or integrating MetaMask signing flows for gasless approvals.
Ready to take this further? Try building a frontend that uses permit() behind the scenes to initiate transfers from a user wallet.
If you encounter any errors, feel free to join the Rootstock Discord and ask for help in the appropriate channel.
To dive deeper into Rootstock, explore the official documentation.
Subscribe to my newsletter
Read articles from Aapsi Khaira directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Aapsi Khaira
Aapsi Khaira
I am a Blockchain Developer with a core focus on Smart Contract development. My expertise is centered on building secure, efficient, and upgradeable smart contracts using Solidity, Hardhat, and Foundry. With hands-on experience in decentralized finance (DeFi) and tokenization, I have developed a range of blockchain solutions, including staking contracts, onchain games, RWA Tokenization, and NFT marketplaces. Currently, I am heavily involved in Real World Asset (RWA) tokenization and exploring advanced cryptographic techniques like Partially Homomorphic Encryption (PHE) and Fully Homomorphic Encryption( TFHE) to enhance data privacy in smart contracts. My development process prioritizes gas optimization, ensuring transactions are cost-effective and scalable. Additionally, I specialize in integrating smart contracts with decentralized applications (dApps) using ethers.js and have a strong track record of performing thorough security audits to ensure the integrity of blockchain protocols. I thrive in the evolving blockchain ecosystem, constantly refining my skills and contributing to the development of decentralized, transparent, and secure solutions for the future.