Token Standards & Best Practices on Rootstock
Tokens are the lifeblood of DeFi. This section covers ERC-20, ERC-721, and important extensions like ERC-20 Permit, ERC-4626, and rBTC wrapping.
1. ERC-20 Tokens
The ERC-20 standard is the foundation of fungible tokens on Ethereum-compatible blockchains like Rootstock. It defines a common interface that wallets, exchanges, and DeFi protocols can rely on.
Basic ERC-20 Implementation
OpenZeppelin provides battle-tested, audited implementations. Always use these instead of writing your own from scratch.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts@5.6.1/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts@5.6.1/access/Ownable.sol";
contract MyToken is ERC20, Ownable {
constructor() ERC20("MyToken", "MTK") Ownable(msg.sender) {
// Mint initial supply to the contract deployer
_mint(msg.sender, 1000000 * 10 ** decimals());
}
// Optional: allow owner to mint more tokens
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
Want to deploy and interact with MyToken without any local setup? Use the button below to open it directly in the Remix IDE. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
Key points:
-
decimals() defaults to 18; you can override if needed.
-
_mint is internal; you control minting logic through public functions.
-
Ownable restricts minting to the owner; you can use AccessControl for more granular permissions.
Important Extensions
OpenZeppelin provides several extensions that add functionality while maintaining security.
ERC20Permit (EIP-2612)
Allows users to approve token spending with a signature, enabling gasless transactions. This is essential for meta-transactions and improving user experience.
How it works: Users sign a message off-chain containing approval details (spender, amount, deadline, nonce). Anyone can submit that signature to the permit function, which sets the allowance without requiring the token holder to pay gas.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts@5.6.1/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts@5.6.1/token/ERC20/extensions/ERC20Permit.sol";
contract MyTokenPermit is ERC20, ERC20Permit {
constructor() ERC20("MyToken", "MTK") ERC20Permit("MyToken") {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
}
Want to deploy and interact with MyTokenPermit without any local setup? Use the button below to open it directly in the Remix IDE. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
Usage example (frontend):
// User signs a permit message
const domain = {
name: "MyToken",
version: "1",
chainId: 31, // Rootstock Testnet
verifyingContract: token.address,
};
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: user.address,
spender: dapp.address,
value: ethers.utils.parseEther("100"),
nonce: await token.nonces(user.address),
deadline: Math.floor(Date.now() / 1000) + 3600,
};
const signature = await user._signTypedData(domain, types, message);
// Someone else (or a relayer) submits the permit
await token.permit(
message.owner,
message.spender,
message.value,
message.deadline,
signature.v,
signature.r,
signature.s
);
ERC20Votes (historical balances)
OpenZeppelin v5 removed ERC20Snapshot. To track historical balances for governance (voting based on past balances) or dividend distribution, use ERC20Votes, which records checkpoints automatically on every transfer. Holders call delegate (often to themselves) to start accruing checkpoints.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts@5.6.1/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts@5.6.1/token/ERC20/extensions/ERC20Permit.sol";
import "@openzeppelin/contracts@5.6.1/token/ERC20/extensions/ERC20Votes.sol";
import "@openzeppelin/contracts@5.6.1/access/Ownable.sol";
import "@openzeppelin/contracts@5.6.1/utils/Nonces.sol";
contract MyTokenVotes is ERC20, ERC20Permit, ERC20Votes, Ownable {
constructor()
ERC20("MyToken", "MTK")
ERC20Permit("MyToken")
Ownable(msg.sender)
{}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
// The following overrides are required by Solidity for ERC20Votes
function _update(address from, address to, uint256 value)
internal
override(ERC20, ERC20Votes)
{
super._update(from, to, value);
}
function nonces(address owner)
public
view
override(ERC20Permit, Nonces)
returns (uint256)
{
return super.nonces(owner);
}
}
Want to deploy and interact with MyTokenVotes without any local setup? Use the button below to open it directly in the Remix IDE. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
ERC20Burnable
Allows token holders to burn their own tokens, reducing total supply.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts@5.6.1/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts@5.6.1/token/ERC20/extensions/ERC20Burnable.sol";
contract MyTokenBurnable is ERC20, ERC20Burnable {
constructor() ERC20("MyToken", "MTK") {
_mint(msg.sender, 1000000 * 10 ** decimals());
}
}
Want to deploy and interact with MyTokenBurnable without any local setup? Use the button below to open it directly in the Remix IDE. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
ERC20Capped
Enforces a maximum supply. Useful for creating capped tokens (like a capped sale).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts@5.6.1/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts@5.6.1/token/ERC20/extensions/ERC20Capped.sol";
import "@openzeppelin/contracts@5.6.1/access/Ownable.sol";
contract MyTokenCapped is ERC20, ERC20Capped, Ownable {
constructor(uint256 cap)
ERC20("MyToken", "MTK")
ERC20Capped(cap * 10 ** decimals())
Ownable(msg.sender)
{
_mint(msg.sender, 500000 * 10 ** decimals());
}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}
Want to deploy and interact with MyTokenCapped without any local setup? Use the button below to open it directly in the Remix IDE. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
Security Considerations for ERC-20
Reentrancy: While transfer and transferFrom are not typically vulnerable to reentrancy, if you call external contracts during a transfer (e.g., hooks), use ReentrancyGuard.
Approval Front-Running: The approve function can be front-run. Use increaseAllowance/decreaseAllowance instead, or use permit to avoid this.
Decimals: Always use decimals() when displaying token amounts; never assume 18.
Return Values: Some old tokens don't return a boolean. OpenZeppelin's SafeERC20 wrapper handles this.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.30;
import "@openzeppelin/contracts@5.6.1/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts@5.6.1/token/ERC20/IERC20.sol";
contract MyContract {
using SafeERC20 for IERC20;
function safeTransfer(IERC20 token, address to, uint256 amount) external {
token.safeTransfer(to, amount);
}
}
Want to deploy and interact with MyContract without any local setup? Use the button below to open it directly in the Remix IDE. You'll need MetaMask with Rootstock Testnet configured — see the full Remix + Rootstock guide for the exact steps.
2. Wrapping RBTC (rBTC)
Rootstock's native currency is RBTC, which is an ERC-20 compatible token? Actually, RBTC is the native coin, similar to ETH on Ethereum. It is not an ERC-20 token; it has no contract address. To use RBTC in DeFi protocols that expect ERC-20, you need wRBTC (Wrapped RBTC) – an ERC-20 token backed 1:1 by RBTC.
The official wrapped RBTC contract is deployed on Rootstock. You can interact with it to wrap and unwrap.
WRBTC Interface
interface IWRBTC {
// Deposit RBTC to get wRBTC
function deposit() external payable;
// Withdraw RBTC by burning wRBTC
function withdraw(uint256 amount) external;
// ERC-20 functions
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function totalSupply() external view returns (uint256);
}
Wrapping RBTC (Deposit)
// Assume we have the WRBTC contract address
IWRBTC wRBTC = IWRBTC(0x...);
function wrapRBTC() external payable {
require(msg.value > 0, "Send RBTC to wrap");
wRBTC.deposit{value: msg.value}();
// Now the caller has wRBTC in their wallet
}
Unwrapping RBTC (Withdraw)
function unwrapRBTC(uint256 amount) external {
// Ensure the contract has enough wRBTC (or use transferFrom to pull from user)
wRBTC.transferFrom(msg.sender, address(this), amount);
wRBTC.withdraw(amount);
payable(msg.sender).transfer(amount);
}
Important: When unwrapping, the withdraw function burns the wRBTC and sends RBTC to the caller (or to the contract, depending on implementation). Always check the specific WRBTC contract behavior.