How Do Cross-Chain Bridges Work? Wormhole (Part 3)
Following Part 1 and Part 2, this article focuses on explaining how Wormhole ensures the bridged tokens are correct.
Here, “correct” means that both the token type and transferred amount are the same or equivalent to the token transferred on the source chain.
How to Ensure a Bridged Token Is Correct?
In the VAA, the token information is specified in the VAA payload by tokenChain and tokenAddress:
// Address of the token. Left-zero-padded if shorter than 32 bytes
bytes32 tokenAddress;
// Chain ID of the token
uint16 tokenChain;
Note that the token is not necessarily a source chain or target chain token, but can be a token address on any chain. For example, Alice may transfer a USDC from Solana (tokenChain 1) to Polygon (tokenChain 5), and the token specified in the VAA could be USD Coin (USDC) on Ethereum (tokenAddress 0xa0b8, tokenChain 2).
To translate the token specified in the VAA to a correct token on the target chain, Wormhole has a feature called token attestation that allows users to register tokens. Say if Alice wants to register a new token on Solana which corresponds to an existing Token X on Ethereum, the process is as follows:
- Alice can call the
attestTokenfunction on the Wormhole Token Bridge contract of the origin chain (Ethereum in this case) with thetokenAddressof Token X and anonce:
/*
* @dev Produce a AssetMeta message for a given token
*/
function attestToken(address tokenAddress, uint32 nonce) public payable returns (uint64 sequence) {
// decimals, symbol & token are not part of the core ERC20 token standard, so we need to support contracts that do
(,bytes memory queriedDecimals) = tokenAddress.staticcall(abi.encodeWithSignature("decimals()"));
(,bytes memory queriedSymbol) = tokenAddress.staticcall(abi.encodeWithSignature("symbol()"));
(,bytes memory queriedName) = tokenAddress.staticcall(abi.encodeWithSignature("name()"));
uint8 decimals = abi.decode(queriedDecimals, (uint8));
string memory symbolString = abi.decode(queriedSymbol, (string));
string memory nameString = abi.decode(queriedName, (string));
bytes32 symbol;
bytes32 name;
assembly {
// first 32 bytes hold string length
symbol := mload(add(symbolString, 32))
name := mload(add(nameString, 32))
The attestToken function will publish a message containing the token metadata, including tokenAddress, tokenChain (2 for Ethereum), token decimals, symbol and name:
BridgeStructs.AssetMeta memory meta = BridgeStructs.AssetMeta({
payloadID : 2,
tokenAddress : bytes32(uint256(uint160(tokenAddress))), // Address of the token
tokenChain : chainId(), // Chain ID of the token
decimals : decimals, // Number of decimals of the token (big-endian uint8)
symbol : symbol, // Symbol of the token (UTF-8)
name : name // Name of the token (UTF-8)
});
bytes memory encoded = encodeAssetMeta(meta);
sequence = wormhole().publishMessage{
value : msg.value
}(nonce, encoded, finality());
-
The message will be observed by the guardians, which will then produce an attestation VAA, which can be retrieved using the
sequencenumber returned by theattestTokenfunction. -
Alice can then submit the attestation VAA to every other chain (referred to as foreign chains for this token) by the
CreateWrappedfunction of the Wormhole Token Bridge on the foreign chain (e.g.,create_wrappedon Solana):
pub fn create_wrapped(
ctx: &ExecutionContext,
accs: &mut CreateWrapped,
data: CreateWrappedData,
) -> Result<()> {
// Do not process attestations sourced from the current chain.
if accs.vaa.token_chain == CHAIN_ID_SOLANA {
return Err(InvalidChain.into());
}
let derivation_data: WrappedDerivationData = (&*accs).into();
accs.mint Mut<Data<SplMint, _>>
.verify_derivation(ctx.program_id, accs: &derivation_data)?;
let meta_derivation_data: WrappedMetaDerivationData = (&*accs).into();
accs.meta Mut<Data<WrappedMeta, _>>
.verify_derivation(ctx.program_id, accs: &meta_derivation_data)?;
let derivation_data: EndpointDerivationData = (&*accs).into();
accs.chain_registration Data<EndpointRegistration, ...>
.verify_derivation(ctx.program_id, accs: &derivation_data)?;
if INVALID_VAAS.contains(&&*accs.vaa.info().key.to_string()) {
return Err(InvalidVAA.into());
In the above, calling create_wrapped with the attestation VAA and a token mint will establish a correspondence between the attested token and the token mint.
Note that the token mint must be unique: two different attested tokens must correspond to different mints. In Solana, this is achieved by creating a PDA from the attested token metadata (chain, token_address, original_decimals):
pub struct WrappedMeta {
pub chain: ChainID,
pub token_address: Address,
pub original_decimals: u8,
}
The PDA is the mint address corresponding to the source token. When redeeming the token on Solana, the mint can be verified by verify_derivation on the PDA (wrapped_meta):
// Verify mint
accs.wrapped_meta.verify_derivation(
ctx.program_id,
accs: &WrappedMetaDerivationData {
mint_key: *accs.mint.info().key,
},
)?;
if accs.wrapped_meta.token_address != accs.vaa.token_address
|| accs.wrapped_meta.chain != accs.vaa.token_chain
{
return Err(InvalidMint.into());
Because the mint address is PDA, only the Wormhole Token Bridge program has authority to mint the corresponding tokens. This ensures that anyone can call create_wrapped with the attestation VAA (if the mint has not been registered), but the attacker could not fake the mint.
If an attacker calls
create_wrappedbefore Alice and supply a wrong mint, e.g., Dogecoin (D56d on Solana) registered instead of ETH — Ether (Portal) for WETH on Ethereum, then the check inaccs.mint.verify_derivation(ctx.program_id, &derivation_data)will fail.
The process is similar on the other chains. For example, on Ethereum, the function createWrapped will deploy a new contract for the token (creating a Wormhole-Wrapped Token), and it invokes setWrappedAsset to update a map wrappedAsset storing (tokenChainId, tokenAddress) and a wrapped address wrapper:
function createWrapped(bytes memory encodedVm) external returns (address token) {
(IWormhole.VM memory vm, bool valid, string memory reason) = wormhole().parseAndVerifyVM(encodedVm);
require(valid, reason);
require(verifyBridgeVM(vm), "invalid emitter");
BridgeStructs.AssetMeta memory meta = parseAssetMeta(vm.payload);
return _createWrapped(meta, vm.sequence);
}
// Creates a wrapped asset using AssetMeta
function _createWrapped(BridgeStructs.AssetMeta memory meta, uint64 sequence) internal returns (address token) {
require(meta.tokenChain != chainId(), "can only wrap tokens from foreign chains");
function setWrappedAsset(uint16 tokenChainId, bytes32 tokenAddress, address wrapper) internal {
_state.wrappedAssets[tokenChainId][tokenAddress] = wrapper;
_state.isWrappedAsset[wrapper] = true;
When redeeming a token on Ethereum, the wrappedAsset map is then used to retrieve the corresponding token from tokenChainId and tokenAddress:
if (transfer.tokenChain == chainId()) {
transferToken = IERC20(_truncateAddress(transfer.tokenAddress));
// track outstanding token amounts
bridgedIn(address(transferToken), transfer.amount);
} else {
address wrapped = wrappedAsset(transfer.tokenChain, transfer.tokenAddress);
require(wrapped != address(0), "no wrapper for this token created yet");
transferToken = IERC20(wrapped);
The complete transfer functions (CompleteNative and CompleteWrapped) both check the mint equality as shown below:
// Verify mints
if *accs.mint.info().key != accs.to.mint {
return Err(InvalidMint.into());
}
if *accs.mint.info().key != accs.to_fees.mint {
return Err(InvalidMint.into());
One caveat is that the recipient to provided by the user must be a valid token recipient. For example, on Solana, this means that the recipient must be a token account with the same mint as the PDA created in create_wrapped.
If (due to a user error) the recipient address is not a token account, or its token mint does not match the PDA with seeds (
chain,token_address,original_decimals), then the user would never be able to redeem the token. The token could be locked on the bridge forever, unless the guardian network issues a new VAA with the correct recipient address.
How to Ensure the Bridged Token Amount Is Correct?
The VAA contains the transfer amount uint256 amount, which ensures the amount of minted token on the target chain should be consistent with the amount. However, a technical problem here is that the bridged tokens may have different decimals. For instance, Ether has 18 decimals on Ethereum, but Wormhole limits token decimals to 8 (line 147 in the code below):
// Initialize mint
let init_ix: Instruction = spl_token::instruction::initialize_mint(
token_program_id: &spl_token::id(),
mint_pubkey: accs.mint.info().key,
mint_authority_pubkey: accs.mint_authority.key,
freeze_authority_pubkey: None,
decimals: min(v1: 8, v2: accs.vaa.decimals), // Limit to 8 decimals, truncation is handled on the other side
)?;
invoke_signed(instruction: &init_ix, account_infos: ctx.accounts, signers_seeds: &[])?;
To address this problem, Wormhole must make sure the token decimals are correctly handled. If a token has more than 8 decimals (e.g., Ether), then Wormhole has to normalize its transferred amount wrt. to the 8 decimals limit.
More specifically, when encoding amount in the VAA payload, Wormhole denormalizes the transfer amount by calling deNormalizeAmount and deNormalizeAmount:
// don't deposit dust that can not be bridged due to the decimal shift
amount = deNormalizeAmount(normalizeAmount(amount, decimals), decimals);
function normalizeAmount(uint256 amount, uint8 decimals) internal pure returns(uint256){
if (decimals > 8) {
amount /= 10 ** (decimals - 8);
}
return amount;
}
function deNormalizeAmount(uint256 amount, uint8 decimals) internal pure returns(uint256){
if (decimals > 8) {
amount *= 10 ** (decimals - 8);
}
return amount;
In the code above (lines 278 and 285), if decimals is larger than 8, the amount is shifted by decimals - 8 decimals (right and left respectively).
Note that due to the decimal shift, deposit dust will not be transferred to the target chain, and Wormhole will refund the dust (if any) to the sender on the source chain:
// refund dust
uint dust = amount - deNormalizeAmount(normalizedAmount, 18);
if (dust > 0) {
payable(msg.sender).transfer(dust);
In the next article, we will continue to discuss how Wormhole avoids message double delivery, i.e., the VAA replay attacks.
About sec3 (Formerly Soteria)
sec3 is a security research firm that prepares Solana projects for millions of users. sec3’s Launch Audit is a rigorous, researcher-led code examination that investigates and certifies mainnet-grade smart contracts; sec3’s continuous auditing software platform, X-ray, integrates with Github to progressively scan pull requests, helping projects fortify code before deployment; and sec3’s post-deployment security solution, WatchTower, ensures funds stay safe. sec3 is building technology-based scalable solutions for Web3 projects to ensure protocols stay safe as they scale.
To learn more about sec3, please visit https://www.sec3.dev