Building on Rootstock: The Ultimate Guide to Token Integration and OFT Bridging
A comprehensive integration handbook for wRBTC, Stablecoins, and LayerZero OFTs

March 2026 | Version 1.0.0
Introduction: From Static Lists to Actionable Code
If you have visited the Rootstock Contract Addresses page, you have already seen the essentials: a neat table listing wRBTC, RIF, DOC, USDT0, and their cross-chain OFT counterparts. That list is a great starting point, but it leaves the most important question unanswered: how do I actually use these contracts?
This guide exists to fill that gap. Think of it as the missing manual, the practical integration handbook that turns a static reference page into a working developer toolkit. By the end of it, you will know exactly how to wrap and unwrap RBTC programmatically, interact with the Dollar on Chain (DOC) and USDT0 stablecoins via correct ERC20 interfaces, and send RIF and RBTC cross-chain to Arbitrum or Solana using LayerZero's Omnichain Fungible Token (OFT) standard.
Rootstock is Bitcoin's EVM-compatible smart contract platform. Every block is merge-mined by Bitcoin miners, meaning Rootstock inherits Bitcoin's proof-of-work security. The native gas token, RBTC, is pegged 1:1 with BTC through PowPeg, Rootstock's two-way bridge mechanism. This architecture makes Rootstock the ideal home for Bitcoin-native DeFi: you get real Bitcoin exposure with real smart-contract programmability.
What This Guide Covers
Section 1 - wRBTC Integration: RBTC is Rootstock's native currency, much like ETH is on Ethereum. Because it is a native coin rather than a token, it cannot be directly traded on DeFi protocols that expect ERC20 tokens. wRBTC solves this by wrapping RBTC into a WETH9-equivalent ERC20 contract. We will cover every detail of the wrapping and unwrapping lifecycle.
Section 2 - Core Stablecoins (DOC & USDT0): Rootstock hosts two primary stablecoins: DOC (Dollar on Chain), a BTC-collateralized stablecoin from Money on Chain, and USDT0, a LayerZero-native Tether stablecoin. We will cover the correct IERC20 interface, decimal handling, and safe transfer patterns for both.
Section 3 - LayerZero OFT Bridging: The OFT standard allows RIF and RBTC to travel cross-chain without wrapping or middlechains. We will walk through the IOFT interface, the send() function, fee estimation, and end-to-end TypeScript examples for bridging to both EVM (Arbitrum) and non-EVM (Solana) destinations.
Section 4 - The Developer Cheat Sheet: A single-page reference containing all mainnet and testnet addresses, verified minimal ABIs, and a troubleshooting FAQ, everything you need to ship fast.
Prerequisites
This guide assumes you are comfortable with Solidity smart contracts and TypeScript. You should have a working Node.js environment and either Hardhat or Foundry installed. Specific environment setup commands are provided where relevant, but detailed scaffolding tutorials are outside this guide's scope.
For connecting to Rootstock, you will need an RPC endpoint. The public mainnet RPC is https://public-node.rsk.co and the public testnet RPC is https://public-node.testnet.rsk.co. For production use, consider a private provider for better reliability.
All contract addresses in this guide are verified directly from the official Rootstock developer portal (dev.rootstock.io) and LayerZero documentation. Always verify addresses on-chain before deploying to production. |
Section 1: Mastering the Core Asset: wRBTC Integration
1.1 The Concept: Why Wrap RBTC?
To understand why wRBTC exists, you need to understand a fundamental distinction in EVM chains: native currency versus ERC20 tokens. RBTC is Rootstock's native currency, it is what you send with msg.value in a transaction, it pays for gas, and it cannot be held inside a contract as a simple token balance using standard ERC20 calls.
Decentralized exchanges, lending protocols, and automated market makers on Rootstock are built around the ERC20 interface. They use functions like transfer(), transferFrom(), balanceOf(), and approve() to move value around. RBTC speaks none of these natively. The solution is identical to the one Ethereum solved with WETH: deploy a smart contract that accepts RBTC, mints an equivalent ERC20 token (wRBTC), and allows the reverse at any time. This is the WETH9 pattern, and Rootstock's wRBTC contract is a direct port of it.
The key guarantee is the 1:1 peg maintained entirely by the smart contract itself, no oracle, no custodian. One wei of RBTC deposited always yields exactly one wei of wRBTC, and the reverse is always true. The contract holds the RBTC in escrow and mints/burns wRBTC accordingly.
wRBTC Mainnet Address: 0x542FDA317318eBf1d3DeAF76E0B632741a7e677dVerified on Rootstock Blockscout. |
1.2 Smart Contract Integration
The IWRBTC Interface
The wRBTC contract exposes a minimal interface on top of standard ERC20. The two critical functions for wrapping and unwrapping are deposit() and withdraw(). Understanding them precisely is essential before writing any code that touches them.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title IWRBTC
/// @notice Interface for the Wrapped RBTC (wRBTC) contract on Rootstock.
/// @dev Identical in function signature to WETH9 on Ethereum.
interface IWRBTC {
// -----------------------------------------------------------------------
// ERC20 Standard Functions
// -----------------------------------------------------------------------
function name() external view returns (string memory);
function symbol() external view returns (string memory);
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 amount) external returns (bool);
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
// -----------------------------------------------------------------------
// wRBTC-specific Functions
// -----------------------------------------------------------------------
/// @notice Wraps RBTC sent as msg.value into wRBTC.
/// @dev Mints msg.value wRBTC to msg.sender. Emits a Transfer event.
function deposit() external payable;
/// @notice Unwraps wRBTC back to RBTC.
/// @param wad The amount of wRBTC to burn and convert to RBTC.
/// @dev Burns wad wRBTC from msg.sender and transfers wad RBTC back.
function withdraw(uint256 wad) external;
// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
event Approval(address indexed src, address indexed guy, uint256 wad);
event Transfer(address indexed src, address indexed dst, uint256 wad);
event Deposit(address indexed dst, uint256 wad);
event Withdrawal(address indexed src, uint256 wad);
}
Writing the Solidity Integration Contract
Below is a production-grade example contract that demonstrates how to safely integrate wRBTC into your own DeFi protocol. It shows secure deposit (wrapping), withdrawal (unwrapping), and how to handle incoming RBTC via the receive() fallback.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./IWRBTC.sol";
/// @title WRBTCVault
/// @notice Example vault demonstrating safe wRBTC integration on Rootstock.
contract WRBTCVault {
// -----------------------------------------------------------------------
// State
// -----------------------------------------------------------------------
/// @notice The wRBTC contract address on Rootstock Mainnet.
IWRBTC public immutable wRBTC;
/// @notice Tracks wRBTC deposited per user.
mapping(address => uint256) public balances;
// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
event Wrapped(address indexed user, uint256 rbtcAmount, uint256 wrbtcReceived);
event Unwrapped(address indexed user, uint256 wrbtcAmount, uint256 rbtcReceived);
// -----------------------------------------------------------------------
// Constructor
// -----------------------------------------------------------------------
/// @param _wRBTC The wRBTC contract address.
/// @dev On Rootstock Mainnet: 0x542FDA317318eBf1d3DeAF76E0B632741a7e677d
constructor(address _wRBTC) {
require(_wRBTC != address(0), 'WRBTCVault: zero address');
wRBTC = IWRBTC(_wRBTC);
}
// -----------------------------------------------------------------------
// Wrapping: RBTC -> wRBTC
// -----------------------------------------------------------------------
/// @notice Wrap the RBTC sent with this call into wRBTC.
/// @dev Calls wRBTC.deposit() with msg.value. The vault keeps the wRBTC.
function wrapRBTC() external payable {
require(msg.value > 0, 'WRBTCVault: zero value');
uint256 before = wRBTC.balanceOf(address(this));
wRBTC.deposit{value: msg.value}();
uint256 received = wRBTC.balanceOf(address(this)) - before;
// Credit the user with the exact amount of wRBTC received
balances[msg.sender] += received;
emit Wrapped(msg.sender, msg.value, received);
}
// -----------------------------------------------------------------------
// Unwrapping: wRBTC -> RBTC
// -----------------------------------------------------------------------
/// @notice Unwrap wRBTC back to RBTC and return it to the caller.
/// @param amount The amount of wRBTC to unwrap (in wei).
function unwrapRBTC(uint256 amount) external {
require(amount > 0, 'WRBTCVault: zero amount');
require(balances[msg.sender] >= amount, 'WRBTCVault: insufficient balance');
// Deduct before external calls (checks-effects-interactions pattern)
balances[msg.sender] -= amount;
// This burns wRBTC from this contract and credits it with RBTC
wRBTC.withdraw(amount);
// Forward the RBTC to the original caller
(bool ok, ) = msg.sender.call{value: amount}('');
require(ok, 'WRBTCVault: RBTC transfer failed');
emit Unwrapped(msg.sender, amount, amount);
}
// -----------------------------------------------------------------------
// Fallback - accept RBTC from wRBTC.withdraw()
// -----------------------------------------------------------------------
/// @notice Required to receive RBTC from wRBTC.withdraw().
/// @dev Only accept RBTC from the wRBTC contract itself for safety.
receive() external payable {
require(
msg.sender == address(wRBTC),
'WRBTCVault: only wRBTC contract may send RBTC'
);
}
}
The Checks-Effects-Interactions Pattern
Notice that in unwrapRBTC(), we deduct the user's balance before making any external calls. This is the industry-standard Checks-Effects-Interactions (CEI) pattern and is essential for preventing reentrancy attacks. The wRBTC contract will call your receive() function during withdraw(), and if you had not already updated state, a malicious contract could re-enter and drain funds.
1.3 Frontend/Backend Integration (TypeScript)
Environment Setup
# Install dependencies
npm install ethers@6 viem
# .env file
RPC_URL=https://public-node.rsk.co
PRIVATE_KEY=0xYourPrivateKeyHere
Wrapping RBTC Using Ethers.js v6
The following TypeScript snippet demonstrates how to wrap RBTC into wRBTC from a backend script or Node.js service. It includes gas estimation, nonce management, and receipt confirmation.
import { ethers } from "ethers";
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
const RPC_URL = process.env.RPC_URL || 'https://public-node.rsk.co';
const PRIVATE_KEY = process.env.PRIVATE_KEY!;
// wRBTC Mainnet address (verified from Rootstock official docs)
const WRBTC_ADDRESS = '0x542FDA317318eBf1d3DeAF76E0B632741a7e677d';
// Minimal ABI - only what we need for wrapping/unwrapping
const WRBTC_ABI = [
'function deposit() payable',
'function withdraw(uint256 wad)',
'function balanceOf(address) view returns (uint256)',
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'event Deposit(address indexed dst, uint256 wad)',
'event Withdrawal(address indexed src, uint256 wad)',
];
// -----------------------------------------------------------------------
// Wrap RBTC -> wRBTC
// -----------------------------------------------------------------------
async function wrapRBTC(amountInRBTC: string): Promise<void> {
const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const wRBTC = new ethers.Contract(WRBTC_ADDRESS, WRBTC_ABI, signer);
const amount = ethers.parseEther(amountInRBTC); // Convert to wei (18 decimals)
// Check current wRBTC balance before
const balBefore = await wRBTC.balanceOf(signer.address);
console.log(`wRBTC balance before: ${ethers.formatEther(balBefore)} wRBTC`);
// --- IMPORTANT: Rootstock gas note ---
// Rootstock's gas price is significantly lower than Ethereum.
// Always fetch the current gas price from the network rather than hardcoding.
const feeData = await provider.getFeeData();
const gasPrice = feeData.gasPrice!;
console.log(`Current gas price: ${ethers.formatUnits(gasPrice, 'gwei')} gwei`);
// Estimate gas for the deposit call
const gasEstimate = await wRBTC.deposit.estimateGas({ value: amount });
console.log(`Estimated gas: ${gasEstimate.toString()}`);
// Add 20% buffer on gas limit for safety
const gasLimit = (gasEstimate * 120n) / 100n;
// Execute the deposit (wrapping) transaction
const tx = await wRBTC.deposit({
value: amount,
gasPrice,
gasLimit,
});
console.log(`Wrap tx submitted: ${tx.hash}`);
console.log(`Track on explorer: https://explorer.rootstock.io/tx/${tx.hash}`);
// Wait for confirmation
const receipt = await tx.wait(2); // 2 confirmations recommended
console.log(`Confirmed in block: ${receipt.blockNumber}`);
// Verify the balance change
const balAfter = await wRBTC.balanceOf(signer.address);
console.log(`wRBTC balance after: ${ethers.formatEther(balAfter)} wRBTC`);
console.log(`Received: ${ethers.formatEther(balAfter - balBefore)} wRBTC`);
}
// -----------------------------------------------------------------------
// Unwrap wRBTC -> RBTC
// -----------------------------------------------------------------------
async function unwrapWRBTC(amountInWRBTC: string): Promise<void> {
const provider = new ethers.JsonRpcProvider(RPC_URL);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const wRBTC = new ethers.Contract(WRBTC_ADDRESS, WRBTC_ABI, signer);
const amount = ethers.parseEther(amountInWRBTC);
const feeData = await provider.getFeeData();
const gasEstimate = await wRBTC.withdraw.estimateGas(amount);
const gasLimit = (gasEstimate * 120n) / 100n;
const tx = await wRBTC.withdraw(amount, {
gasPrice: feeData.gasPrice!,
gasLimit,
});
console.log(`Unwrap tx submitted: ${tx.hash}`);
const receipt = await tx.wait(2);
console.log(`Confirmed in block: ${receipt.blockNumber}`);
}
// -----------------------------------------------------------------------
// Entry point - wrap 0.001 RBTC
// -----------------------------------------------------------------------
wrapRBTC('0.001').catch(console.error);
Gas Estimation Notes for Rootstock
Rootstock has some gas quirks that differ from Ethereum Mainnet. The block gas limit is lower (approximately 6.8M gas), and the minimum gas price (minimum gas price in the network) can fluctuate. Never hardcode a gas price. Always fetch it dynamically using provider.getFeeData() and apply a small buffer (10–20%) to your gas limit estimate to avoid out-of-gas failures.
Rootstock Network Details:
• Chain ID (Mainnet): 30
• Chain ID (Testnet): 31
• Block Time: ~30 seconds
• Block Gas Limit: ~6.8M gas
• Public Mainnet RPC: https://public-node.rsk.co
• Public Testnet RPC: https://public-node.testnet.rsk.co
Section 2: Navigating Core Stablecoins: DOC & USDT0
2.1 The Landscape: Money on Rootstock
Rootstock is home to two distinct stablecoin architectures that serve different use cases and come from different issuers. Understanding the difference is critical before writing any integration code.
Dollar on Chain (DOC)
DOC is a Bitcoin-collateralized stablecoin issued by Money on Chain, one of the oldest DeFi protocols on Rootstock. Unlike USDT which is backed by fiat reserves, DOC is minted against locked RBTC collateral on-chain, making it fully transparent and non-custodial. DOC uses 18 decimal places and implements the standard ERC20 interface. Its mainnet address is 0xe700691dA7b9851F2F35f8b8182c69c53CCaD9Db.
USDT0 (Tether via LayerZero)
USDT0 is a LayerZero-native version of Tether's USDT. It is fundamentally different from the wrapped USDT tokens that existed on Rootstock previously. USDT0 implements LayerZero's OFT standard natively, meaning it can travel cross-chain without a bridge. The mainnet address on Rootstock is 0x779ded0c9e1022225f8e0630b35a9b54be713736. For cross-chain transfers specifically involving USDT0's OFT adapter, see the OFT contract address 0x1a594d5d5d1c426281C1064B07f23F57B2716B61.
2.2 Standardizing the Interface: Why Standard IERC20 Isn't Always Enough
The core ERC20 standard defines transfer() and approve() as returning bool. However, not every real-world token implementation follows this strictly. Tether's USDT on Ethereum, for example, famously does not return a bool from transfer(). To write code that works safely across multiple tokens, you must use OpenZeppelin's SafeERC20 library, which wraps raw calls and handles tokens that either return nothing or return false.
For Rootstock specifically, DOC behaves like a standard ERC20. USDT0 also behaves correctly. Nevertheless, adopting SafeERC20 as a universal pattern is considered best practice and protects you against future token integrations that may be less well-behaved.
The Verified IERC20 Interface for Rootstock Stablecoins
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title IERC20Rootstock
/// @notice Full ERC20 interface suitable for DOC and USDT0 on Rootstock.
/// @dev Use with OpenZeppelin SafeERC20 for maximum safety.
interface IERC20Rootstock {
// -----------------------------------------------------------------------
// Core ERC20 View Functions
// -----------------------------------------------------------------------
function name() external view returns (string memory);
function symbol() external view returns (string memory);
/// @notice CRITICAL: DOC uses 18 decimals, USDT0 uses 6 decimals.
function decimals() external view returns (uint8);
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function allowance(address owner, address spender) external view returns (uint256);
// -----------------------------------------------------------------------
// State-Changing Functions
// -----------------------------------------------------------------------
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);
// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(address indexed owner, address indexed spender, uint256 value);
}
2.3 Programmatic Interactions
Safe ERC20 Transfer Patterns in Solidity
The following contract demonstrates production-ready stablecoin interaction patterns. It integrates DOC and USDT0 using OpenZeppelin's SafeERC20 for bulletproof transfers and includes decimal normalization utilities.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
/// @title RootstockStablecoinRouter
/// @notice Demonstrates safe interaction with DOC and USDT0 on Rootstock.
contract RootstockStablecoinRouter is Ownable {
using SafeERC20 for IERC20;
// -----------------------------------------------------------------------
// Addresses (Rootstock Mainnet)
// -----------------------------------------------------------------------
address public constant DOC = 0xe700691dA7b9851F2F35f8b8182c69c53CCaD9Db; // 18 decimals
address public constant USDT0 = 0x779ded0c9e1022225f8e0630b35a9b54be713736; // 6 decimals
// -----------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------
event TokensDeposited(address indexed token, address indexed from, uint256 amount);
event TokensWithdrawn(address indexed token, address indexed to, uint256 amount);
constructor() Ownable(msg.sender) {}
// -----------------------------------------------------------------------
// Deposit: pull tokens from user into this contract
// -----------------------------------------------------------------------
/// @notice Deposits a stablecoin from the caller into this contract.
/// @param token The stablecoin address (DOC or USDT0).
/// @param amount The raw token amount (already in the token's native decimals).
/// @dev Caller must have called token.approve(address(this), amount) first.
function deposit(address token, uint256 amount) external {
require(token == DOC || token == USDT0, 'Router: unsupported token');
require(amount > 0, 'Router: zero amount');
// SafeERC20.safeTransferFrom handles missing return values gracefully
IERC20(token).safeTransferFrom(msg.sender, address(this), amount);
emit TokensDeposited(token, msg.sender, amount);
}
// -----------------------------------------------------------------------
// Withdraw: owner pushes tokens out of this contract
// -----------------------------------------------------------------------
/// @notice Withdraws a stablecoin from this contract to a recipient.
/// @param token The stablecoin address.
/// @param recipient The address to receive the tokens.
/// @param amount The raw token amount.
function withdraw(address token, address recipient, uint256 amount) external onlyOwner {
require(recipient != address(0), 'Router: zero recipient');
IERC20(token).safeTransfer(recipient, amount);
emit TokensWithdrawn(token, recipient, amount);
}
// -----------------------------------------------------------------------
// Decimal Helper
// -----------------------------------------------------------------------
/// @notice Converts a human-readable amount to raw token units.
/// @param token The token address.
/// @param humanAmount e.g., 100 for 100 DOC or 100 USDT0.
/// @return rawAmount The amount in the token's base unit.
function toRawAmount(address token, uint256 humanAmount) external view returns (uint256) {
uint8 dec = IERC20Metadata(token).decimals();
return humanAmount * (10 ** uint256(dec));
}
}
TypeScript: Checking Allowances and Executing Approvals
The most common mistake developers make when integrating ERC20 tokens is forgetting to check and set allowances before calling transferFrom. The following TypeScript pattern handles this correctly for both DOC and USDT0.
import { ethers } from "ethers";
// -----------------------------------------------------------------------
// Token Addresses (Rootstock Mainnet)
// -----------------------------------------------------------------------
const TOKENS = {
DOC: { address: '0xe700691dA7b9851F2F35f8b8182c69c53CCaD9Db', decimals: 18 },
USDT0: { address: '0x779ded0c9e1022225f8e0630b35a9b54be713736', decimals: 6 },
};
// Minimal ERC20 ABI
const ERC20_ABI = [
'function balanceOf(address) view returns (uint256)',
'function decimals() view returns (uint8)',
'function allowance(address owner, address spender) view returns (uint256)',
'function approve(address spender, uint256 amount) returns (bool)',
'function transfer(address to, uint256 amount) returns (bool)',
'function transferFrom(address from, address to, uint256 amount) returns (bool)',
];
// -----------------------------------------------------------------------
// Helper: Ensure allowance is sufficient, approve if not
// -----------------------------------------------------------------------
async function ensureAllowance(
tokenAddress: string,
spender: string,
amount: bigint,
signer: ethers.Signer,
): Promise<void> {
const token = new ethers.Contract(tokenAddress, ERC20_ABI, signer);
const owner = await signer.getAddress();
const current = await token.allowance(owner, spender);
if (current >= amount) {
console.log(`Allowance sufficient: ${current.toString()}`);
return;
}
console.log(`Approving \({amount.toString()} tokens for spender \){spender}...`);
const feeData = await signer.provider!.getFeeData();
const tx = await token.approve(spender, amount, { gasPrice: feeData.gasPrice });
await tx.wait(2);
console.log(`Approval confirmed: ${tx.hash}`);
}
// -----------------------------------------------------------------------
// Helper: Format token amount correctly based on decimals
// -----------------------------------------------------------------------
function parseTokenAmount(tokenKey: 'DOC' | 'USDT0', humanAmount: string): bigint {
const { decimals } = TOKENS[tokenKey];
return ethers.parseUnits(humanAmount, decimals);
}
// -----------------------------------------------------------------------
// Example: Check balance and transfer 50 DOC to a recipient
// -----------------------------------------------------------------------
async function transferDOC(recipient: string, amount: string, signer: ethers.Signer) {
const { address, decimals } = TOKENS.DOC;
const token = new ethers.Contract(address, ERC20_ABI, signer);
const owner = await signer.getAddress();
const raw = parseTokenAmount('DOC', amount);
// Sanity check: do we have enough?
const bal = await token.balanceOf(owner);
if (bal < raw) {
throw new Error(`Insufficient DOC balance. Have: \({ethers.formatUnits(bal, decimals)}, need: \){amount}`);
}
const feeData = await signer.provider!.getFeeData();
const tx = await token.transfer(recipient, raw, { gasPrice: feeData.gasPrice });
await tx.wait(2);
console.log(`Transferred \({amount} DOC to \){recipient}. Tx: ${tx.hash}`);
}
// -----------------------------------------------------------------------
// Example: Approve a DeFi protocol to spend USDT0
// -----------------------------------------------------------------------
async function approveProtocolForUSDT0(
protocolAddress: string,
amount: string,
signer: ethers.Signer,
) {
const raw = parseTokenAmount('USDT0', amount);
await ensureAllowance(TOKENS.USDT0.address, protocolAddress, raw, signer);
}
Section 3: The LayerZero OFT Frontier - Bridging Rootstock
3.1 The Architecture: What is an Omnichain Fungible Token?
Traditional token bridges work by locking a token on the source chain and minting a synthetic wrapped version on the destination. This creates fragmentation: you end up with 'Rootstock USDT' versus 'Ethereum USDT' versus 'Arbitrum USDT', each of which is distinct and requires trusted bridge operators to manage.
LayerZero's Omnichain Fungible Token (OFT) standard eliminates this fragmentation entirely. An OFT is a token whose supply is managed across multiple chains simultaneously through a burn-and-mint mechanism orchestrated by LayerZero's messaging protocol. When you send RIF from Rootstock to Arbitrum, the RIF is burned on Rootstock and an equivalent amount is minted on Arbitrum, there is one unified supply, no wrapped versions, and no custodians.
The security of this mechanism depends on LayerZero's Decentralized Verifier Networks (DVNs), which are independent entities that verify cross-chain messages before they are executed. Multiple DVNs must agree that a message is valid before tokens are minted on the destination. This eliminates single points of failure present in traditional bridge designs.
OFT Variants Relevant to Rootstock
OFT (Native): The OFT contract itself is the token. It can mint and burn on any supported chain. RIF and RBTC use this pattern, they have OFT contracts deployed on Ethereum, Arbitrum, Base, and Solana that share a unified supply.
OFT Adapter (Lockbox): Used when an existing token cannot be modified. An Adapter contract sits alongside the original token, locks it when sending, and signals the destination to mint an OFT representation. USDT0 uses a variant of this pattern on its source chain.
3.2 Targeting Ecosystems: Cross-Chain Addresses
All cross-chain OFT contract addresses below are verified from the official Rootstock developer portal and can be explored using the linked blockchain explorers.
Token | Chain | OFT Contract Address |
RBTC | Ethereum Mainnet | 0x1e44f98cC78d505A61F63b26D13b116CF51dbB87 |
RBTC | Arbitrum One | 0x441Fcb23dFe8289cf572126FEDCf450974ADc891 |
RBTC | Base | 0x441Fcb23dFe8289cf572126FEDCf450974ADc891 |
RBTC | Solana | 8yev7nLen2PFN2uYGhzsUbu243wMa9z4ZrCwuXs6DEQw |
RIF | Ethereum Mainnet | 0x01b603be3D545F096015741e6503440282BF45fb |
RIF | Arbitrum One | 0xe5e851b01DD3Eda24FDe709a407dB44555B6d1E0 |
RIF | Base | 0xe5e851b01DD3Eda24FDe709a407dB44555B6d1E0 |
RIF | Solana | AAeENcfHbTExuTvs4q7r9Bjax98Dg6BGX3aMph4bTLdK |
USDT0 | Rootstock (OFT) | 0x1a594d5d5d1c426281C1064B07f23F57B2716B61 |
3.3 Understanding the IOFT Interface
LayerZero V2 provides a clean, standardized interface for all OFT operations. The two functions you will interact with most frequently are quoteSend() (for fee estimation) and send() (for execution). Understanding their parameter structures precisely is the foundation of all OFT integration work.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title IOFT
/// @notice LayerZero V2 Omnichain Fungible Token interface.
/// @dev This is the key interface for all cross-chain token operations.
interface IOFT {
// -----------------------------------------------------------------------
// Structs
// -----------------------------------------------------------------------
/// @notice Parameters for a cross-chain send operation.
struct SendParam {
uint32 dstEid; // Destination chain's LayerZero Endpoint ID.
// e.g., 30110 for Arbitrum, 30333 for Rootstock
bytes32 to; // Recipient address in bytes32 format.
// For EVM: pad 20-byte address to 32 bytes
// For Solana: the base58-decoded public key
uint256 amountLD; // Amount to send in local decimals (LD).
// 'Local' means the token's decimals on the source chain.
uint256 minAmountLD; // Minimum acceptable amount on destination.
// Set equal to amountLD for no slippage tolerance,
// or lower to allow for any OFT fee deductions.
bytes extraOptions; // ABI-encoded executor options (gas, value).
// Built using the OptionsBuilder library.
bytes composeMsg; // Composed message for destination contract calls.
// Leave as 0x (empty) for simple transfers.
bytes oftCmd; // OFT command - unused in default OFT contracts.
// Leave as 0x (empty) for standard transfers.
}
/// @notice Fee quote returned by quoteSend().
struct MessagingFee {
uint256 nativeFee; // Fee in the source chain's native currency (RBTC).
uint256 lzTokenFee; // Fee in LayerZero token (usually 0 - pay in native).
}
/// @notice Receipt data from a completed send() call.
struct MessagingReceipt {
bytes32 guid; // Globally unique message ID - use for LayerZero Scan.
uint64 nonce; // Message nonce.
MessagingFee fee; // Actual fee paid.
}
/// @notice Summary of amounts sent vs received.
struct OFTReceipt {
uint256 amountSentLD; // Exact amount deducted from sender.
uint256 amountReceivedLD; // Amount that will be minted on destination.
}
// -----------------------------------------------------------------------
// Core Functions
// -----------------------------------------------------------------------
/// @notice Quote the LayerZero messaging fee for a send operation.
/// @param _sendParam The send parameters (same as used in send()).
/// @param _payInLzToken Whether to pay the fee in LZ token (false = native).
/// @return fee The fee breakdown - use fee.nativeFee as msg.value in send().
function quoteSend(
SendParam calldata _sendParam,
bool _payInLzToken
) external view returns (MessagingFee memory fee);
/// @notice Execute a cross-chain token transfer.
/// @param _sendParam The send parameters.
/// @param _fee The fee (obtained from quoteSend).
/// @param _refundAddress Address to receive any excess native fee refund.
/// @return msgReceipt Use msgReceipt.guid to track on LayerZero Scan.
/// @return oftReceipt Confirms exact amounts sent and to be received.
function send(
SendParam calldata _sendParam,
MessagingFee calldata _fee,
address _refundAddress
) external payable returns (MessagingReceipt memory msgReceipt, OFTReceipt memory oftReceipt);
}
3.4 The send() Function Unpacked
The send() function has four mandatory inputs that work together. Let's break each one down in plain terms so you understand what to pass before writing any code.
Parameter: dstEid (Destination Endpoint ID)
This is LayerZero's internal chain identifier - completely different from EVM Chain IDs. You must use the LayerZero EID, not the RPC chain ID. For example, Arbitrum One's EVM chain ID is 42161, but its LayerZero EID is 30110. Rootstock Mainnet's EID is 30333. These values are constants in the @layerzerolabs/lz-definitions package.
Parameter: to (Recipient in bytes32)
OFT uses bytes32 for recipient addresses to support both EVM (20-byte addresses) and non-EVM chains (Solana's 32-byte public keys). For EVM destinations, left-pad a 20-byte address: bytes32(uint256(uint160(address))). LayerZero provides a utility function addressToBytes32() to do this automatically.
Parameter: extraOptions (Executor Gas Settings)
This bytes blob tells the LayerZero executor how much gas to allocate on the destination chain. You build it using the OptionsBuilder library with addExecutorLzReceiveOption(gasLimit, nativeValue). For a standard token transfer to an EVM chain, 80,000 gas is generally sufficient. For Solana, the gas parameter is interpreted differently - LayerZero handles the translation internally.
Parameter: _fee (from quoteSend())
Never hardcode the LayerZero messaging fee. Always call quoteSend() with the exact same SendParam you intend to use in send(), then pass the returned MessagingFee as msg.value. The fee compensates the DVNs for verification work and the executor for gas on the destination.
3.5 Smart Contract Integration: Bridging RIF from Rootstock
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./IOFT.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title RootstockOFTBridge
/// @notice Demonstrates sending RIF cross-chain via LayerZero OFT from Rootstock.
contract RootstockOFTBridge {
using SafeERC20 for IERC20;
// -----------------------------------------------------------------------
// Rootstock Mainnet - OFT Contract Addresses
// -----------------------------------------------------------------------
// Note: RIF's OFT contract on Rootstock acts as the origin for bridging.
// The RIF token contract itself is: 0x2acc95758f8b5f583470ba265eb685a8f45fc9d5
// For OFT bridging, interact with the OFT-enabled version or adapter.
// Always consult dev.rootstock.io for the current OFT adapter address.
// -----------------------------------------------------------------------
// LayerZero Endpoint IDs
// -----------------------------------------------------------------------
uint32 public constant ROOTSTOCK_EID = 30333; // Rootstock Mainnet
uint32 public constant ARBITRUM_EID = 30110; // Arbitrum One Mainnet
uint32 public constant SOLANA_EID = 30168; // Solana Mainnet
// -----------------------------------------------------------------------
// Bridge to an EVM Chain (e.g., Arbitrum)
// -----------------------------------------------------------------------
/// @notice Bridges tokens to an EVM destination chain.
/// @param oftContract The OFT contract to call send() on.
/// @param dstEid Destination LayerZero Endpoint ID.
/// @param recipient The recipient address on the destination chain.
/// @param amount Amount of tokens to bridge (in token's local decimals).
function bridgeToEVM(
address oftContract,
uint32 dstEid,
address recipient,
uint256 amount
) external payable {
require(amount > 0, 'Bridge: zero amount');
require(recipient != address(0), 'Bridge: zero recipient');
IOFT oft = IOFT(oftContract);
// Build executor options: 80,000 gas for standard EVM transfer
// In production, encode this with the OptionsBuilder library:
// bytes memory opts = Options.newOptions().addExecutorLzReceiveOption(80000, 0).toBytes();
// For simplicity, using a pre-encoded bytes constant here.
bytes memory extraOptions = hex'0003010011010000000000000000000000000000ea60';
// ^ This encodes: LZ_RECEIVE option, type 1, gas = 60000 (example)
// In production ALWAYS use the OptionsBuilder library for correctness.
// Construct the SendParam
IOFT.SendParam memory sendParam = IOFT.SendParam({
dstEid: dstEid,
to: bytes32(uint256(uint160(recipient))), // EVM address -> bytes32
amountLD: amount,
minAmountLD: amount, // No slippage: must receive exactly amount
extraOptions: extraOptions,
composeMsg: bytes(''), // No composed message
oftCmd: bytes('') // Default OFT behavior
});
// Get the fee quote - NEVER skip this step
IOFT.MessagingFee memory fee = oft.quoteSend(sendParam, false);
// Validate the caller sent enough RBTC to cover fees
require(msg.value >= fee.nativeFee, 'Bridge: insufficient fee');
// Execute the send - pass fee.nativeFee as msg.value
// The refund address receives any excess RBTC back
oft.send{value: fee.nativeFee}(sendParam, fee, msg.sender);
}
}
3.6 Cross-Chain Execution (TypeScript)
Full TypeScript: Bridging RIF from Rootstock to Arbitrum
import { ethers } from "ethers";
import { Options } from "@layerzerolabs/lz-v2-utilities";
import { EndpointId } from "@layerzerolabs/lz-definitions";
// -----------------------------------------------------------------------
// Configuration
// -----------------------------------------------------------------------
const ROOTSTOCK_RPC = 'https://mycrypto.rsk.co';
const PRIVATE_KEY = process.env.PRIVATE_KEY!;
// RIF Token on Rootstock Mainnet (ERC677, but ERC20-compatible for reads)
const RIF_ADDRESS = '0x2acc95758f8b5f583470ba265eb685a8f45fc9d5';
// IMPORTANT: For OFT bridging, you interact with the OFT-capable contract.
// Consult dev.rootstock.io/developers/smart-contracts/contract-addresses/
// for the current OFT adapter address for RIF on Rootstock.
// The RIF OFT contract on Arbitrum One is: 0xe5e851b01DD3Eda24FDe709a407dB44555B6d1E0
const RIF_OFT_ADDRESS = '<RIF_OFT_CONTRACT_ON_ROOTSTOCK>'; // Update from official docs
// LayerZero EIDs
const ARBITRUM_EID = EndpointId.ARBITRUM_V2_MAINNET; // 30110
// Minimal OFT ABI for TypeScript interaction
const OFT_ABI = [
'function quoteSend(tuple(uint32,bytes32,uint256,uint256,bytes,bytes,bytes) sendParam, bool payInLzToken) view returns (tuple(uint256 nativeFee, uint256 lzTokenFee))',
'function send(tuple(uint32,bytes32,uint256,uint256,bytes,bytes,bytes) sendParam, tuple(uint256 nativeFee, uint256 lzTokenFee) fee, address refundAddress) payable returns (tuple(bytes32,uint64,tuple(uint256,uint256)), tuple(uint256,uint256))',
'function approvalRequired() view returns (bool)',
];
const ERC20_ABI = [
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function balanceOf(address) view returns (uint256)',
'function decimals() view returns (uint8)',
];
// -----------------------------------------------------------------------
// Helper: Convert EVM address to bytes32
// -----------------------------------------------------------------------
function addressToBytes32(address: string): string {
return ethers.zeroPadValue(address, 32);
}
// -----------------------------------------------------------------------
// Main: Bridge RIF from Rootstock -> Arbitrum
// -----------------------------------------------------------------------
async function bridgeRIFToArbitrum(
recipientOnArbitrum: string,
amountRIF: string,
) {
const provider = new ethers.JsonRpcProvider(ROOTSTOCK_RPC);
const signer = new ethers.Wallet(PRIVATE_KEY, provider);
const address = await signer.getAddress();
const rif = new ethers.Contract(RIF_ADDRESS, ERC20_ABI, signer);
const oft = new ethers.Contract(RIF_OFT_ADDRESS, OFT_ABI, signer);
const decimals = await rif.decimals(); // RIF uses 18 decimals
const amount = ethers.parseUnits(amountRIF, decimals);
// --- Step 1: Build extraOptions ---
// 80,000 gas is a safe default for EVM destinations.
// Increase if destination contract logic requires more.
const extraOptions = Options.newOptions()
.addExecutorLzReceiveOption(80_000, 0)
.toBytes();
// --- Step 2: Construct SendParam ---
const sendParam = {
dstEid: ARBITRUM_EID,
to: addressToBytes32(recipientOnArbitrum),
amountLD: amount,
minAmountLD: amount, // Require exact amount (no slippage)
extraOptions: extraOptions,
composeMsg: '0x',
oftCmd: '0x',
};
// --- Step 3: Get fee estimate ---
console.log('Estimating LayerZero fee...');
const feeQuote = await oft.quoteSend(sendParam, false);
const nativeFee = feeQuote.nativeFee;
console.log(`Required fee: ${ethers.formatEther(nativeFee)} RBTC`);
// --- Step 4: Check RBTC balance for fee ---
const rbtcBalance = await provider.getBalance(address);
if (rbtcBalance < nativeFee) {
throw new Error(`Insufficient RBTC for fee. Have: \({ethers.formatEther(rbtcBalance)}, need: \){ethers.formatEther(nativeFee)}`);
}
// --- Step 5: Approve OFT contract to spend RIF (if required) ---
const allowance = await rif.allowance(address, RIF_OFT_ADDRESS);
if (allowance < amount) {
console.log('Approving RIF for OFT contract...');
const approveTx = await rif.approve(RIF_OFT_ADDRESS, amount);
await approveTx.wait(2);
console.log(`Approval confirmed: ${approveTx.hash}`);
}
// --- Step 6: Execute the cross-chain send ---
console.log(`Sending \({amountRIF} RIF to \){recipientOnArbitrum} on Arbitrum...`);
const feeData = await provider.getFeeData();
const sendTx = await oft.send(
sendParam,
{ nativeFee, lzTokenFee: 0n },
address, // refund address: excess RBTC returns to sender
{ value: nativeFee, gasPrice: feeData.gasPrice! }
);
console.log(`Send tx submitted: ${sendTx.hash}`);
const receipt = await sendTx.wait(2);
console.log(`Confirmed in block: ${receipt.blockNumber}`);
console.log(`Track cross-chain delivery: https://layerzeroscan.com/tx/${sendTx.hash}`);
}
// Run
bridgeRIFToArbitrum(
'0xYourArbitrumRecipientAddress',
'100', // 100 RIF
).catch(console.error);
Bridging to Solana: Key Differences
Bridging to Solana requires special consideration because Solana uses 32-byte public keys (not 20-byte EVM addresses) and has a completely different account model. The key changes from the Arbitrum example are:
1. The 'to' field: A Solana public key is already 32 bytes, so it maps directly to bytes32 without padding. Use ethers.encodeBytes32String() or base58-decode the Solana public key, then cast to bytes32.
2. The dstEid: Use Solana Mainnet's EID: 30168 (EndpointId.SOLANA_V2_MAINNET in the @layerzerolabs/lz-definitions package).
3. The extraOptions: Solana's execution model is different from EVM. LayerZero handles the translation internally, but you should consult LayerZero's Solana documentation for the correct executor option gas parameters appropriate for Solana-bound messages.
// Solana-specific changes - replace in the bridgeRIFToArbitrum function:
import bs58 from 'bs58'; // npm install bs58
// 1. Convert a Solana base58 public key to bytes32
function solanaAddressToBytes32(base58Address: string): Uint8Array {
const decoded = bs58.decode(base58Address); // Returns 32-byte Uint8Array
if (decoded.length !== 32) throw new Error('Invalid Solana address length');
return decoded;
}
// 2. Use Solana EID
const SOLANA_EID = 30168; // EndpointId.SOLANA_V2_MAINNET
// 3. Build the SendParam for Solana
const solanaRecipient = 'YourSolanaBase58PublicKeyHere';
const sendParam = {
dstEid: SOLANA_EID,
to: solanaAddressToBytes32(solanaRecipient), // bytes32, not padded EVM address
amountLD: amount,
minAmountLD: amount,
extraOptions: extraOptions, // Build appropriately for Solana destination
composeMsg: '0x',
oftCmd: '0x',
};
// Rest of the flow (quoteSend, send) is identical.
Tracking Your Transaction via LayerZero Scan
After calling send(), you receive a transaction hash on Rootstock. But the cross-chain delivery takes additional time - typically 20–40 seconds for EVM destinations and a few minutes for Solana. To track the full lifecycle, use LayerZero Scan:
LayerZero Scan URL: https://layerzeroscan.com/tx/<YOUR_TX_HASH>
The scan page shows you the message status: INFLIGHT (being verified by DVNs), DELIVERED (minted on destination), or FAILED (reverted on destination, can be retried). Always log the transaction hash from your send() receipt and provide this URL to your users for cross-chain transparency.
Section 4: The Developer's Toolkit - The Missing Manual Cheat Sheet
4.1 Mainnet & Testnet Address Matrix
Core Token Addresses
Symbol | Name | Standard | Mainnet Address | Testnet Address |
RIF | RIF Token | ERC677 | 0x2acc95758f8b5f583470ba265eb685a8f45fc9d5 | 0x19f64674d8a5b4e652319f5e239efd3bc969a1fe |
wRBTC | Wrapped RBTC | ERC20 | 0x542FDA317318eBf1d3DeAF76E0B632741a7e677d | (see testnet explorer) |
DOC | Dollar on Chain | ERC20 | 0xe700691dA7b9851F2F35f8b8182c69c53CCaD9Db | - |
USDT0 | USDT0 | ERC20 | 0x779ded0c9e1022225f8e0630b35a9b54be713736 | 0x5a2256dd0dfbc8ce121d923ac7d6e7a3fc7f9922 |
USDRIF | RIF US Dollar | ERC20 | 0x3a15461d8ae0f0fb5fa2629e9da7d66a794a6e37 | 0x8dbf326e12a9fF37ED6DDF75adA548C2640A6482 |
stRIF | Staked RIF | ERC20 | 0x5db91e24BD32059584bbDb831A901f1199f3d459 | 0xe7039717c51c44652fb47be1794884a82634f08f |
Cross-Chain OFT Addresses
Token | Destination Chain | EID | OFT Contract Address |
RBTC | Ethereum | 30101 | 0x1e44f98cC78d505A61F63b26D13b116CF51dbB87 |
RBTC | Arbitrum One | 30110 | 0x441Fcb23dFe8289cf572126FEDCf450974ADc891 |
RBTC | Base | 30184 | 0x441Fcb23dFe8289cf572126FEDCf450974ADc891 |
RBTC | Solana | 30168 | 8yev7nLen2PFN2uYGhzsUbu243wMa9z4ZrCwuXs6DEQw |
RIF | Ethereum | 30101 | 0x01b603be3D545F096015741e6503440282BF45fb |
RIF | Arbitrum One | 30110 | 0xe5e851b01DD3Eda24FDe709a407dB44555B6d1E0 |
RIF | Base | 30184 | 0xe5e851b01DD3Eda24FDe709a407dB44555B6d1E0 |
RIF | Solana | 30168 | AAeENcfHbTExuTvs4q7r9Bjax98Dg6BGX3aMph4bTLdK |
USDT0 | Rootstock OFT | 30333 | 0x1a594d5d5d1c426281C1064B07f23F57B2716B61 |
Network Configuration
Network | Chain ID | LayerZero EID | Public RPC |
Rootstock Mainnet | 30 | 30333 | |
Rootstock Testnet | 31 | 40333 | |
Arbitrum One | 42161 | 30110 | |
Ethereum Mainnet | 1 | 30101 | |
Base Mainnet | 8453 | 30184 | |
Solana Mainnet | N/A | 30168 |
4.2 Verified Minimal ABIs
The following ABI snippets are the minimum necessary for the operations covered in this guide. Copy them directly into your TypeScript integration without modification.
wRBTC ABI
const WRBTC_ABI = [
// Read
'function balanceOf(address account) view returns (uint256)',
'function allowance(address owner, address spender) view returns (uint256)',
'function decimals() view returns (uint8)',
'function name() view returns (string)',
'function symbol() view returns (string)',
'function totalSupply() view returns (uint256)',
// Write
'function deposit() payable',
'function withdraw(uint256 wad)',
'function approve(address spender, uint256 amount) returns (bool)',
'function transfer(address to, uint256 amount) returns (bool)',
'function transferFrom(address from, address to, uint256 amount) returns (bool)',
// Events
'event Deposit(address indexed dst, uint256 wad)',
'event Withdrawal(address indexed src, uint256 wad)',
'event Transfer(address indexed from, address indexed to, uint256 value)',
'event Approval(address indexed owner, address indexed spender, uint256 value)',
];
Standard ERC20 ABI (for DOC and USDT0)
const ERC20_ABI = [
// Read
'function name() view returns (string)',
'function symbol() view returns (string)',
'function decimals() view returns (uint8)',
'function totalSupply() view returns (uint256)',
'function balanceOf(address account) view returns (uint256)',
'function allowance(address owner, address spender) view returns (uint256)',
// Write
'function approve(address spender, uint256 amount) returns (bool)',
'function transfer(address to, uint256 amount) returns (bool)',
'function transferFrom(address from, address to, uint256 amount) returns (bool)',
// Events
'event Transfer(address indexed from, address indexed to, uint256 value)',
'event Approval(address indexed owner, address indexed spender, uint256 value)',
];
LayerZero OFT ABI (V2)
const OFT_ABI = [
// Fee estimation - always call this before send()
'function quoteSend(
tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam,
bool payInLzToken
) view returns (tuple(uint256 nativeFee, uint256 lzTokenFee) fee)',
// Execute cross-chain transfer
'function send(
tuple(uint32 dstEid, bytes32 to, uint256 amountLD, uint256 minAmountLD, bytes extraOptions, bytes composeMsg, bytes oftCmd) sendParam,
tuple(uint256 nativeFee, uint256 lzTokenFee) fee,
address refundAddress
) payable returns (
tuple(bytes32 guid, uint64 nonce, tuple(uint256 nativeFee, uint256 lzTokenFee) fee) msgReceipt,
tuple(uint256 amountSentLD, uint256 amountReceivedLD) oftReceipt
)',
// Included from ERC20 for allowance management
'function approve(address spender, uint256 amount) returns (bool)',
'function allowance(address owner, address spender) view returns (uint256)',
'function balanceOf(address) view returns (uint256)',
'function decimals() view returns (uint8)',
];
4.3 Troubleshooting Common Errors
Error: LayerZero quoteSend() Reverts
This is the most common OFT integration error. It almost always means one of three things:
1. Wiring not complete: The OFT contract has not been properly wired (peered) to the destination chain. Run npx hardhat lz:oapp:config:get --oapp-config layerzero.config.ts to inspect the current on-chain configuration and verify both peer addresses and DVN settings are correctly set.
2. LzDeadDVN in configuration: Some pathways lack a default DVN. Use the LayerZero Scan Default Config Checker at https://layerzeroscan.com to identify missing DVN configurations and add explicit DVN providers to your layerzero.config.ts.
3. Missing extraOptions: At least one of extraOptions or enforced options (set during wiring) must be configured. An empty extraOptions with no enforced options will cause the quote to revert. Always pass at minimum the LZ_RECEIVE gas option.
Error: Gas Estimation Failures on Rootstock
Rootstock's lower block gas limit (approximately 6.8M gas) means that large transactions that would succeed on Ethereum might fail here. If you are calling a complex contract that internally interacts with OFT or wRBTC, use a higher gas limit buffer. Also check that you are not inadvertently estimating gas at the wrong gas price, Rootstock uses its own minimum gas price, which can be queried from the network.
// Always use dynamic gas pricing on Rootstock:
const feeData = await provider.getFeeData();
const estimate = await contract.someFunction.estimateGas(...args);
const gasLimit = (estimate * 130n) / 100n; // 30% buffer recommended
// If still failing, check the minimum gas price:
const minGasPrice = await provider.send('eth_gasPrice', []);
console.log('Min gas price:', ethers.formatUnits(minGasPrice, 'gwei'), 'gwei');
Error: 'insufficient fee' when calling send()
This means the RBTC you sent as msg.value is less than the fee returned by quoteSend(). This can happen if you called quoteSend() at a different network state than when you call send(). Always call quoteSend() immediately before send() in the same transaction flow, and always use the returned nativeFee value without modification as your msg.value.
Error: Tokens transferred but not received on destination
If the send transaction confirmed on Rootstock but tokens have not appeared on the destination after several minutes, check LayerZero Scan. The most common causes are: the destination received the message but the lzReceive call reverted (check for 'FAILED' status), or the destination OFT contract has not been correctly peered. A failed message can be retried, LayerZero stores the verified message on-chain and the recipient can trigger delivery manually.
⚠ Never assume a Rootstock-confirmed send() means tokens have been delivered cross-chain. Always track the full lifecycle on LayerZero Scan and implement appropriate UX to inform users of the pending state. |
Error: Wrong decimal precision
// WRONG - using parseEther for USDT0 (6 decimals), sends 1e12x too much
const amount = ethers.parseEther('100'); // ❌ gives 100 * 10^18
// CORRECT - use parseUnits with the correct decimal count
const amount = ethers.parseUnits('100', 6); // ✅ gives 100 * 10^6 for USDT0
const amount = ethers.parseUnits('100', 18); // ✅ gives 100 * 10^18 for DOC or RIF
// BEST PRACTICE - always read decimals from the contract
const decimals = await token.decimals();
const amount = ethers.parseUnits('100', decimals);
Conclusion: Shipping Your dApp
You have now covered the full spectrum of integration patterns for Rootstock's core token ecosystem. Let's recap what this guide equipped you with:
wRBTC: You can now programmatically wrap RBTC into the ERC20-compatible wRBTC using the deposit() function, unwrap it back using withdraw(), and handle the RBTC receive() callback safely. You know the WETH9 interface pattern, the Checks-Effects-Interactions pattern, and how to estimate gas dynamically on Rootstock.
Stablecoins (DOC & USDT0): You understand the decimal difference (18 vs 6), the correct IERC20 interface to use, how SafeERC20 protects your integrations, and how to check allowances and execute approvals efficiently in TypeScript.
LayerZero OFTs: You understand the OFT architecture (burn-and-mint, DVNs, messaging fees), the IOFT interface's SendParam struct, and how to bridge RIF and RBTC to Arbitrum or Solana from Rootstock. You know how to estimate fees with quoteSend(), encode extraOptions, convert addresses to bytes32, and track delivery on LayerZero Scan.
The Cheat Sheet: Section 4 gives you everything in one place: all mainnet and testnet addresses, minimal copy-paste ABIs, and a troubleshooting FAQ for the errors you are most likely to encounter in production.
What to Build Next
With these integration primitives, you have everything you need to build:
A DeFi lending protocol that accepts wRBTC as collateral and mints DOC against it, similar to what Money on Chain pioneered but with your own twists.
A cross-chain yield optimizer that moves RIF to Arbitrum to participate in higher-yield DeFi opportunities, bridging rewards back to Rootstock via OFT.
A payment gateway that accepts USDT0 (with LayerZero bridging built in), giving merchants on any chain instant access to Rootstock's Bitcoin-native liquidity pool.
A hackathon project that demonstrates Bitcoin-native DeFi to judges - using these battle-tested integration patterns means you can focus on product innovation rather than infrastructure debugging.
Further Resources
Official Rootstock Developer Portal: https://dev.rootstock.io
Contract Addresses Reference: https://dev.rootstock.io/developers/smart-contracts/contract-addresses/
LayerZero V2 OFT Quickstart: https://docs.layerzero.network/v2/developers/evm/oft/quickstart
Rootstock Mainnet OFT Quickstart: https://docs.layerzero.network/v2/deployments/evm-chains/rootstock-mainnet-oft-quickstart
LayerZero Scan (Transaction Tracker): https://layerzeroscan.com
Rootstock Discord Community: https://discord.gg/rootstock
Rootstock Contract Metadata (GitHub): https://github.com/rsksmart/rsk-contract-metadata
This guide is based on contract addresses and API interfaces as of March 2026. Always verify contract addresses against the official Rootstock developer portal before deploying to production. Cross-chain OFT configurations are managed by each token's issuer and may change. When in doubt, join the Rootstock Discord and ask in the #developers channel. |
Happy building and may your transactions always confirm. 🧡