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: TEST and WTEST are not separate tokens—they are two different interfaces to the same token stored in the bank module. Native Cosmos tokens (including TEST 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 WTEST with the contract address below (wrapped state)
WTEST Contract Address: 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE Precompile Type: Dynamic (unique address per wrapped token) Related Module: x/bank (via ERC20 module integration)

Gas Costs

Gas costs are approximated and may vary based on token complexity and chain settings.
MethodGas Cost
name()~3,000 gas
symbol()~3,000 gas
decimals()~2,000 gas
totalSupply()~2,500 gas
balanceOf(address)~2,900 gas
allowance(address,address)~3,000 gas
transfer(address,uint256)~35,000 gas
transferFrom(address,address,uint256)~40,000 gas
approve(address,uint256)~30,000 gas
deposit()~23,000 gas (no-op)
withdraw(uint256)~9,000 gas (no-op)
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()
}

Deposit/Withdraw Implementation Details

Since TEST and WTEST provide different interfaces to the same bank module token, deposit/withdraw functions exist for WETH interface compatibility:
// These functions exist for WETH interface compatibility
function deposit() external payable {
    // Handles msg.value by sending received coins back to the caller
    // Emits Deposit event for interface compatibility
    // Your bank balance reflects the same amount accessible via ERC20 interface
}

function withdraw(uint256 amount) external {
    // No-op implementation that only emits Withdrawal event
    // No actual token movement since bank balance is directly accessible
    // Exists purely for WETH interface compatibility
}
Understanding the Deposit/Withdraw PatternUnlike traditional WETH implementations where the contract holds wrapped tokens:
  • Traditional WETH: Contract receives ETH and mints WETH tokens that it holds
  • WERC20: Contract never holds tokens - all balances remain in the bank module
  • Result: The precompile contract address has no balance; tokens stay with users
This is why deposit() and withdraw() are no-ops - there’s no separate wrapped token state to manage.

Real-World Example

// User starts with 100 TEST in bank module
const testBalance = await bankPrecompile.balances(userAddress);
// Returns: [{denom: "atest", amount: "100000000000000000000"}] // 100 TEST (18 decimals)

const wtestBalance = await wtest.balanceOf(userAddress);
// Returns: "100000000000000000000" // Same 100 TEST, accessed via ERC20 interface

// User transfers 50 WTEST via ERC20
await wtest.transfer(recipientAddress, "50000000000000000000");

// Check balances again
const newTestBalance = await bankPrecompile.balances(userAddress);
// Returns: [{denom: "atest", amount: "50000000000000000000"}] // 50 TEST (18 decimals) remaining

const newWtestBalance = await wtest.balanceOf(userAddress);
// Returns: "50000000000000000000" // Same 50 TEST, 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 wtestAddress = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE";
const werc20Abi = ["function balanceOf(address account) view returns (uint256)"];

const wtest = new ethers.Contract(wtestAddress, werc20Abi, provider);

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

transfer

Transfers tokens using the bank module (identical to native Cosmos transfer).
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

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

contract WERC20Example {
    // WTEST contract address
    address constant WTEST = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    IERC20 public immutable wtest;
    
    event TokensTransferred(address indexed from, address indexed to, uint256 amount);
    
    constructor() {
        wtest = IERC20(WTEST);
    }
    
    function transferWTEST(address to, uint256 amount) external returns (bool) {
        require(to != address(0), "Invalid recipient");
        require(amount > 0, "Amount must be greater than 0");
        
        // This directly moves tokens in the bank module
        // No wrapping/unwrapping - same underlying token balance
        bool success = wtest.transfer(to, amount);
        require(success, "Transfer failed");
        
        emit TokensTransferred(msg.sender, to, amount);
        return true;
    }
    
    function transferFromWTEST(address from, address to, uint256 amount) external returns (bool) {
        require(from != address(0) && to != address(0), "Invalid addresses");
        require(amount > 0, "Amount must be greater than 0");
        
        bool success = wtest.transferFrom(from, to, amount);
        require(success, "Transfer from failed");
        
        emit TokensTransferred(from, to, amount);
        return true;
    }
    
    // Batch transfer example
    function batchTransfer(address[] calldata recipients, uint256[] calldata amounts) external {
        require(recipients.length == amounts.length, "Arrays length mismatch");
        
        for (uint256 i = 0; i < recipients.length; i++) {
            wtest.transferFrom(msg.sender, recipients[i], amounts[i]);
            emit TokensTransferred(msg.sender, recipients[i], amounts[i]);
        }
    }
}

totalSupply

Returns the total supply from the bank module.
import { ethers } from "ethers";

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

const wtest = new ethers.Contract(wtestAddress, werc20Abi, provider);

async function getTotalSupply() {
  try {
    const supply = await wtest.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 wtestAddress = "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 wtest = new ethers.Contract(wtestAddress, werc20Abi, signer);

async function approveAndTransfer() {
  try {
    const spenderAddress = "0x742d35Cc6634C0532925a3b844Bc9e7595f5b899";
    const amount = ethers.parseUnits("50.0", 18); // 50 TEST (18 decimals)

    // Approve spending
    const approveTx = await wtest.approve(spenderAddress, amount);
    await approveTx.wait();

    // Check allowance
    const allowance = await wtest.allowance(signer.address, spenderAddress);
    console.log("Allowance:", allowance.toString());

    // Transfer from (would be called by spender)
    // const transferTx = await wtest.transferFrom(ownerAddress, recipientAddress, amount);
  } catch (error) {
    console.error("Error:", error);
  }
}

name / symbol / decimals

Token metadata (e.g., “Wrapped Test”, “WTEST”, 18).
import { ethers } from "ethers";

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

const wtest = new ethers.Contract(wtestAddress, werc20Abi, provider);

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

WETH Compatibility Methods

These methods exist for WETH interface compatibility:

deposit

WETH compatibility function - Handles payable deposits for interface compatibility.
This function receives msg.value and immediately sends the coins back to the caller via the bank module, then emits a Deposit event. Since WTEST and TEST are the same underlying bank module token, no actual wrapping occurs - your balance is simply accessible through both native and ERC20 interfaces.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// Interface for WERC20 precompile
interface IWERC20 {
    event Deposit(address indexed dst, uint256 wad);
    event Withdrawal(address indexed src, uint256 wad);
    
    function deposit() external payable;
    function withdraw(uint256 wad) external;
    function balanceOf(address account) external view returns (uint256);
}

contract WERC20Example {
    address constant WTEST = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE;
    
    IWERC20 public immutable wtest;
    
    constructor() {
        wtest = IWERC20(WTEST);
    }
    
    function depositToWTEST() external payable {
        require(msg.value > 0, "Must send tokens to deposit");
        
        // Get balance before deposit
        uint256 balanceBefore = wtest.balanceOf(msg.sender);
        
        // WERC20 deposit is a no-op for compatibility
        // Your native token balance is immediately accessible as WTEST
        wtest.deposit{value: msg.value}();
        
        // Verify balance is now accessible via WTEST interface
        uint256 balanceAfter = wtest.balanceOf(msg.sender);
        
        // Both TEST and WTEST balances reflect the same bank module amount
        // No actual wrapping occurred - same token, different interface
        require(balanceAfter >= balanceBefore, "Deposit processed");
    }
    
    function withdrawFromWTEST(uint256 amount) external {
        require(amount > 0, "Amount must be greater than 0");
        require(wtest.balanceOf(msg.sender) >= amount, "Insufficient balance");
        
        // WERC20 withdraw is a no-op that emits event for compatibility
        // Your bank balance remains accessible as both native TEST and WTEST
        wtest.withdraw(amount);
        
        // Tokens are still in bank module and accessible both ways
    }
    
    // Helper function to demonstrate balance consistency
    function checkBalanceConsistency(address user) external view returns (
        uint256 wtestBalance,
        string memory explanation
    ) {
        wtestBalance = wtest.balanceOf(user);
        explanation = "This WTEST balance equals the user's native TEST balance in bank module";
        
        return (wtestBalance, explanation);
    }
    
    // Example DeFi integration showing no wrapping needed
    function addLiquidityWithDeposit() external payable {
        require(msg.value > 0, "Must send tokens");
        
        // Deposit via WERC20 interface (compatibility no-op)
        wtest.deposit{value: msg.value}();
        
        // Your tokens are now accessible as WTEST for DeFi protocols
        // No additional steps needed - same token, ERC20 interface available
        uint256 availableForDeFi = wtest.balanceOf(msg.sender);
        
        // Use in DeFi protocols immediately
        // wtest.transfer(defiProtocolAddress, availableForDeFi);
    }
}

withdraw

No-op function - Included for interface compatibility with WETH contracts.
This function only emits a Withdrawal event but performs no actual token movement. Since WTEST and TEST are the same underlying bank module token, your native token balance is always directly accessible without any unwrapping process.

Usage Examples

DeFi Integration Example

contract LiquidityPool {
    IERC20 public immutable WTEST;

    constructor(address _wtest) {
        WTEST = IERC20(_wtest);
    }

    function addLiquidity(uint256 amount) external {
        // This transfers from the user's bank balance
        WTEST.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
        WTEST.transfer(msg.sender, amount);

        // User can now use these tokens as native TEST
        // or continue using WTEST 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 testAmount = bankBalance.find(b => b.denom === "test")?.amount || "0";

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

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

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"
    }
  ]
}