EVM: Transferring a Token

WARNING: To be able to successfully transfer a token from Chain A to Chain B, make sure you attest it first. Otherwise the Token may be transferred, but you won't be able to claim it til it's attested.

One big gotcha new EVM developers usually run into when working with ERC20 tokens is that because EVM uses unsigned integers, there's no concept of decimals. Therefore, tokens usually have up to 18 zeros behind them to denote up to 18 decimal places. Wormhole normalizes this to eight zeros, with transfer amounts rounded down to the nearest 8the decimal.

To wrap the Token Bridge functions in your contract, you can use the Token Bridge interfaces provided under projects/evm-tokenbridge/chains/evm/src/Wormhole folder of the xDapp Book repository.

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/presets/ERC20PresetMinterPauser.sol";
import "./Wormhole/ITokenBridge.sol";
import "./Wormhole/PortalWrappedToken.sol";

contract Treasury {

    address private token_bridge_address = address(0x0290FB167208Af455bB137780163b7B7a9a10C16);
    ITokenBridge token_bridge = ITokenBridge(token_bridge_address);
    address private TKN_address = address(0x2D8BE6BF0baA74e0A907016679CaE9190e80dD0A); 
    ERC20PresetMinterPauser TKN = ERC20PresetMinterPauser(TKN_address);

    uint32 nonce = 0;

    function bridgeToken(uint256 amt, uint16 receipientChainId, bytes32 recipient) public returns (uint64 sequence) {
        nonce += 1;
        return token_bridge.transferTokens(TKN_address, amt, receipientChainId, recipient, 0, nonce);

    function approveTokenBridge(uint256 amt) public returns (bool) {
        return TKN.approve(token_bridge_address, amt);

To transfer a token, first we have to approve the Token Bridge to be able to spend that Token on our behalf (so it can transfer tokens form our contract to tself). Make sure the bridgeAmt properly takes into account decimals for the ERC20 token.

// Here we are approving and transfering 50 tokens. The ERC20 token we are transfering has 18 decimal places.
const bridgeAmt = ethers.utils.parseUnits("50", "18");

await treasury.approveTokenBridge(bridgeAmt, {
    gasLimit: 2000000,

Then we simply call transfer to create the transfer VAA and fetch it from the guardians when it's ready. Note that the target receipient is a Wormhole normalized hex address left-padded to 32 bytes.

const targetRecepient = Buffer.from(tryNativeToHexString(targetDeployment.deployedAddress, "ethereum"), 'hex');

const tx = await (await treasury.bridgeToken(
const emitterAddr = getEmitterAddressEth(network.tokenBridgeAddress);
const seq = parseSequenceFromLogEth(tx, network.bridgeAddress);
const vaaURL =  `${config.wormhole.restAddress}/v1/signed_vaa/${network.wormholeChainId}/${emitterAddr}/${seq}`;
let vaaBytes = await (await fetch(vaaURL)).json();
    console.log("VAA not found, retrying in 5s!");
    await new Promise((r) => setTimeout(r, 5000)); //Timeout to let Guardiand pick up log and have VAA ready
    vaaBytes = await (await fetch(vaaURL)).json();

After we've fetched the VAA, we can call the completeTransfer() function on the target chain if it's an EVM.

const completeTransferTx = await targetTokenBridge.completeTransfer(Buffer.from(vaaBytes.vaaBytes, "base64"));