Overview

The WERC20 precompile provides a standard ERC20 interface to native Cosmos tokens through Cosmos EVM’s Single Token Representation architecture. Unlike traditional wrapped tokens that are functionally two separate tokens with unique individual properties and behaviors, Cosmos EVM’s WERC20 logic gives smart contracts direct access to native bank module balances through familiar ERC20 methods.

Key Concept: ATOM and WATOM are not separate tokens—they are two different interfaces to the same token stored in the bank module. Native Cosmos tokens (including ATOM and all IBC tokens) exist in both wrapped and unwrapped states at all times, allowing developers to choose the interaction method that best fits their use case:

  • Use it normally through Cosmos bank send (unwrapped state)
  • Use it like you would normally use ether or ‘wei’ on the EVM (native value transfers)
  • Use it as ERC20 WATOM with the contract address below (wrapped state)

WATOM Contract Address: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE Precompile Type: Dynamic (unique address per wrapped token) Related Module: x/bank (via ERC20 module integration)

For a comprehensive understanding of how single token representation works and its benefits over traditional wrapping, see the Single Token Representation documentation.

Technical Implementation

Architecture Deep Dive

The ERC20 module creates a unified token representation that bridges native Cosmos tokens with ERC20 interfaces:

// Simplified conceptual flow (not actual implementation)
func (k Keeper) ERC20Transfer(from, to common.Address, amount *big.Int) error {
    // Convert EVM addresses to Cosmos addresses
    cosmosFrom := sdk.AccAddress(from.Bytes())
    cosmosTo := sdk.AccAddress(to.Bytes())

    // Use bank module directly - no separate ERC20 state
    coin := sdk.NewCoin(k.denom, sdk.NewIntFromBigInt(amount))
    return k.bankKeeper.SendCoins(ctx, cosmosFrom, cosmosTo, sdk.Coins{coin})
}

func (k Keeper) ERC20BalanceOf(account common.Address) *big.Int {
    // Query bank module directly
    cosmosAddr := sdk.AccAddress(account.Bytes())
    balance := k.bankKeeper.GetBalance(ctx, cosmosAddr, k.denom)
    return balance.Amount.BigInt()
}

Why Deposit/Withdraw are No-Ops

Since ATOM and WATOM represent the same underlying token, traditional deposit/withdraw operations are meaningless:

// These functions exist for interface compatibility but do nothing
function deposit() external payable {
    // No-op: msg.value automatically increases your bank balance
    // Your WATOM balance is identical to your ATOM balance
}

function withdraw(uint256 amount) external {
    // No-op: your ATOM balance is already accessible
    // No conversion needed as they're the same token
}

Real-World Example

// User starts with 100 ATOM in bank module
const atomBalance = await bankPrecompile.balances(userAddress);
// Returns: [{denom: "uatom", amount: "100000000"}] // 100 ATOM (6 decimals)

const watomBalance = await watom.balanceOf(userAddress);
// Returns: "100000000" // Same 100 ATOM, accessed via ERC20 interface

// User transfers 50 WATOM via ERC20
await watom.transfer(recipientAddress, "50000000");

// Check balances again
const newAtomBalance = await bankPrecompile.balances(userAddress);
// Returns: [{denom: "uatom", amount: "50000000"}] // 50 ATOM remaining

const newWatomBalance = await watom.balanceOf(userAddress);
// Returns: "50000000" // Same 50 ATOM, both queries return identical values

Methods

Standard ERC20 Interface

All standard ERC20 methods are available and operate on the underlying bank balance:

balanceOf

Returns the native token balance for a specific account (same as bank module balance).

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("<RPC_URL>");
const watomAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const werc20Abi = ["function balanceOf(address account) view returns (uint256)"];

const watom = new ethers.Contract(watomAddress, werc20Abi, provider);

async function getBalance() {
  try {
    const userAddress = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
    const balance = await watom.balanceOf(userAddress);
    console.log("Balance (both ATOM and WATOM):", balance.toString());
  } catch (error) {
    console.error("Error:", error);
  }
}

transfer

Transfers tokens using the bank module (identical to native Cosmos transfer).

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("<RPC_URL>");
const signer = new ethers.Wallet("<PRIVATE_KEY>", provider);
const watomAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const werc20Abi = ["function transfer(address to, uint256 amount) returns (bool)"];

const watom = new ethers.Contract(watomAddress, werc20Abi, signer);

async function transferTokens() {
  try {
    const recipientAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f5b899";
    const amount = ethers.parseUnits("10.0", 6); // 10 ATOM (6 decimals)
    
    const tx = await watom.transfer(recipientAddress, amount);
    const receipt = await tx.wait();
    
    console.log("Transfer successful:", receipt.hash);
  } catch (error) {
    console.error("Error:", error);
  }
}

totalSupply

Returns the total supply from the bank module.

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("<RPC_URL>");
const watomAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const werc20Abi = ["function totalSupply() view returns (uint256)"];

const watom = new ethers.Contract(watomAddress, werc20Abi, provider);

async function getTotalSupply() {
  try {
    const supply = await watom.totalSupply();
    console.log("Total Supply:", supply.toString());
  } catch (error) {
    console.error("Error:", error);
  }
}

approve / allowance / transferFrom

Standard ERC20 approval mechanisms for delegated transfers.

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("<RPC_URL>");
const signer = new ethers.Wallet("<PRIVATE_KEY>", provider);
const watomAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const werc20Abi = [
  "function approve(address spender, uint256 amount) returns (bool)",
  "function allowance(address owner, address spender) view returns (uint256)",
  "function transferFrom(address from, address to, uint256 amount) returns (bool)"
];

const watom = new ethers.Contract(watomAddress, werc20Abi, signer);

async function approveAndTransfer() {
  try {
    const spenderAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f5b899";
    const amount = ethers.parseUnits("50.0", 6); // 50 ATOM
    
    // Approve spending
    const approveTx = await watom.approve(spenderAddress, amount);
    await approveTx.wait();
    
    // Check allowance
    const allowance = await watom.allowance(signer.address, spenderAddress);
    console.log("Allowance:", allowance.toString());
    
    // Transfer from (would be called by spender)
    // const transferTx = await watom.transferFrom(ownerAddress, recipientAddress, amount);
  } catch (error) {
    console.error("Error:", error);
  }
}

name / symbol / decimals

Token metadata (e.g., “Wrapped ATOM”, “WATOM”, 6).

import { ethers } from "ethers";

const provider = new ethers.JsonRpcProvider("<RPC_URL>");
const watomAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const werc20Abi = [
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)"
];

const watom = new ethers.Contract(watomAddress, werc20Abi, provider);

async function getTokenInfo() {
  try {
    const [name, symbol, decimals] = await Promise.all([
      watom.name(),
      watom.symbol(),
      watom.decimals()
    ]);
    console.log(`Token: ${name} (${symbol}) - ${decimals} decimals`);
  } catch (error) {
    console.error("Error:", error);
  }
}

Legacy Methods (Interface Compatibility)

These methods exist for WETH compatibility but are no-ops:

deposit

No-op function - Included for interface compatibility with WETH contracts.

This function does nothing because ATOM and WATOM are the same token. Sending native tokens to this function will increase your balance, but the function itself performs no conversion logic.

withdraw

No-op function - Included for interface compatibility with WETH contracts.

This function does nothing because ATOM and WATOM are the same token. Your native token balance is always directly accessible without any unwrapping process.

Usage Examples

DeFi Integration Example

contract LiquidityPool {
    IERC20 public immutable WATOM;

    constructor(address _watom) {
        WATOM = IERC20(_watom);
    }

    function addLiquidity(uint256 amount) external {
        // This transfers from the user's bank balance
        WATOM.transferFrom(msg.sender, address(this), amount);

        // Pool now has tokens in its bank balance
        // No wrapping/unwrapping needed - it's all the same token!
    }

    function removeLiquidity(uint256 amount) external {
        // This transfers back to user's bank balance
        WATOM.transfer(msg.sender, amount);

        // User can now use these tokens as native ATOM
        // or continue using WATOM interface - both access same balance
    }
}

Cross-Interface Balance Verification

// Verify that both interfaces show the same balance
async function verifyBalanceConsistency(userAddress) {
    // Query via bank precompile (native interface)
    const bankBalance = await bankContract.balances(userAddress);
    const atomAmount = bankBalance.find(b => b.denom === "uatom")?.amount || "0";

    // Query via WERC20 precompile (ERC20 interface)
    const watomAmount = await watom.balanceOf(userAddress);

    // These will always be equal since the ERC20 balance is just
    // an abstracted bank module balance query
    console.log(`Consistent balance: ${atomAmount} (both ATOM and WATOM)`);
}

Working with IBC Tokens

// IBC tokens work exactly the same way
const ibcTokenAddress = "0x..."; // Each IBC token gets its own WERC20 address
const ibcToken = new ethers.Contract(ibcTokenAddress, werc20Abi, signer);

// Check balance (same as bank module balance)
const balance = await ibcToken.balanceOf(userAddress);

// Transfer IBC tokens via ERC20 interface
await ibcToken.transfer(recipientAddress, amount);

// Use in DeFi protocols just like any ERC20 token
await defiProtocol.stake(ibcTokenAddress, amount);

Solidity Interface & ABI

WERC20 Solidity Interface
// SPDX-License-Identifier: LGPL-3.0-only
pragma solidity >=0.8.18;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

/// @title WERC20 Precompile Contract
/// @dev Provides ERC20 interface to native Cosmos tokens via bank module
/// @notice This is NOT a traditional wrapped token - both native and ERC20 interfaces access the same balance
interface IWERC20 is IERC20 {
    /// @dev Emitted when deposit() is called (no-op for compatibility)
    /// @param dst The address that called deposit
    /// @param wad The amount specified (though no conversion occurs)
    event Deposit(address indexed dst, uint256 wad);

    /// @dev Emitted when withdraw() is called (no-op for compatibility)
    /// @param src The address that called withdraw
    /// @param wad The amount specified (though no conversion occurs)
    event Withdrawal(address indexed src, uint256 wad);

    /// @dev No-op function for WETH compatibility - native tokens automatically update balance
    /// @notice This function exists for interface compatibility but performs no conversion
    function deposit() external payable;

    /// @dev No-op function for WETH compatibility - native tokens always accessible
    /// @param wad Amount to "withdraw" (no conversion performed)
    /// @notice This function exists for interface compatibility but performs no conversion
    function withdraw(uint256 wad) external;
}
WERC20 ABI
{
  "_format": "hh-sol-artifact-1",
  "contractName": "IWERC20",
  "sourceName": "solidity/precompiles/werc20/IWERC20.sol",
  "abi": [
    { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "owner", "type": "address" }, { "indexed": true, "internalType": "address", "name": "spender", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" } ], "name": "Approval", "type": "event" },
    { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "dst", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "wad", "type": "uint256" } ], "name": "Deposit", "type": "event" },
    { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "from", "type": "address" }, { "indexed": true, "internalType": "address", "name": "to", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "value", "type": "uint256" } ], "name": "Transfer", "type": "event" },
    { "anonymous": false, "inputs": [ { "indexed": true, "internalType": "address", "name": "src", "type": "address" }, { "indexed": false, "internalType": "uint256", "name": "wad", "type": "uint256" } ], "name": "Withdrawal", "type": "event" },
    { "inputs": [ { "internalType": "address", "name": "owner", "type": "address" }, { "internalType": "address", "name": "spender", "type": "address" } ], "name": "allowance", "outputs": [ { "internalType": "uint256", "name": "", "type": "uint256" } ], "stateMutability": "view", "type": "function" },
    { "inputs": [ { "internalType": "address", "name": "spender", "type": "address" }, { "internalType": "uint256", "name": "amount", "type": "uint256" } ], "name": "approve", "outputs": [ { "internalType": "bool", "name": "", "type": "bool" } ], "stateMutability": "nonpayable", "type": "function" },
    { "inputs": [ { "internalType": "address", "name": "account", "type": "address" } ], "name": "balanceOf", "outputs": [ { "internalType": "uint256", "name": "", "type": "uint256" } ], "stateMutability": "view", "type": "function" },
    { "inputs": [], "name": "deposit", "outputs": [], "stateMutability": "payable", "type": "function" },
    { "inputs": [], "name": "totalSupply", "outputs": [ { "internalType": "uint256", "name": "", "type": "uint256" } ], "stateMutability": "view", "type": "function" },
    { "inputs": [ { "internalType": "address", "name": "to", "type": "address" }, { "internalType": "uint256", "name": "amount", "type": "uint256" } ], "name": "transfer", "outputs": [ { "internalType": "bool", "name": "", "type": "bool" } ], "stateMutability": "nonpayable", "type": "function" },
    { "inputs": [ { "internalType": "address", "name": "from", "type": "address" }, { "internalType": "address", "name": "to", "type": "address" }, { "internalType": "uint256", "name": "amount", "type": "uint256" } ], "name": "transferFrom", "outputs": [ { "internalType": "bool", "name": "", "type": "bool" } ], "stateMutability": "nonpayable", "type": "function" },
    { "inputs": [ { "internalType": "uint256", "name": "wad", "type": "uint256" } ], "name": "withdraw", "outputs": [], "stateMutability": "nonpayable", "type": "function" }
  ]
}