This tutorial contains a solidity contract that can be deployed onto many EVM chains to form a fully functioning cross-chain application with the ability for users to request, from one contract, that tokens are sent to an address on a different chain.
Here is an example of a cross-chain borrow lending application that uses the topics covered in this tutorial!
Included in this repository is:
- Example Solidity Code
- Example Forge local testing setup
- Testnet Deploy Scripts
- Example Testnet testing setup
- Node 16.14.1 or later, npm 8.5.0 or later: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
- forge 0.2.0 or later: https://book.getfoundry.sh/getting-started/installation
Clone down the repo, cd into it, then build and run unit tests:
git clone https://github.com/wormhole-foundation/hello-token.git
cd hello-token
npm run build
forge test
Expected output is
Running 1 test for test/HelloToken.t.sol:HelloTokenTest
[PASS] testCrossChainDeposit() (gas: 1338038)
Test result: ok. 1 passed; 0 failed; finished in 5.64s
You will need a wallet with at least 0.05 Testnet AVAX and 0.01 Testnet CELO.
EVM_PRIVATE_KEY=your_wallet_private_key npm run deploy
You will need a wallet with at least 0.02 Testnet AVAX. Obtain testnet AVAX here
You must have also deployed contracts onto testnet (as described in the above section).
To test sending and receiving a message on testnet, execute the test as such:
EVM_PRIVATE_KEY=your_wallet_private_key npm run test
Let's write a HelloToken contract that lets users send an arbitrary amount of an IERC20 token to an address of their choosing on another chain.
Before getting started, it is important to note that we use Wormhole's TokenBridge to transfer tokens between chains!
So, in order to send a token using the method in this example, the token must be attested onto the Token Bridge contract that lives on our desired target blockchain.
In the test above, when you run npm run deploy
, a mock token contract was both deployed and attested onto the target chain's Token Bridge contract.
If you wish to attest a token yourself for the TokenBridge, you may use the attestWorkflow function.
To check if a token already is attested onto a TokenBridge, call the wrappedAsset(uint16 tokenChainId, bytes32 tokenAddress)
function on the TokenBridge - this will return, if attested, the address of the wrapped token on this blockchain corresponding to the given token (from the source blockchain), and the 0 address if the input token hasn't been attested yet.
How attestWorkflow works
The 'attestWorkflow' function does the following:
- On the Source side: Calls the TokenBridge
attestToken
function with the token we're trying to send.This creates a payload containing the token details so that it may be created on the receiving side
- Off chain: Fetch the VAA using the Wormhole Chain ID, Emitter address (TokenBridge address) and sequence number from the
LogMessage
event.This is the VAA that contains the token details with signatures from the Guardians
- On the Receiving side: Calls the TokenBridge
createWrapped
function with the VAA from the previous stepThis allows the TokenBridge to create a wrapped version of the token we're sending so that it may mint the tokens to the receiver.
Once this is done, the TokenBridge on the receiving side can successfully mint the token sent.
To ease development, we'll make use of the Wormhole Solidity SDK.
Include this SDK in your own cross-chain application by running:
forge install wormhole-foundation/wormhole-solidity-sdk
and import it in your contract:
import "wormhole-solidity-sdk/WormholeRelayerSDK.sol";
This SDK provides helpers that make cross-chain development with Wormhole easier, and specifically provides us with the TokenSender and TokenReceiver abstract classes with useful functionality for sending and receiving tokens using TokenBridge
Lets start by writing a function to send some amount of a token to a specific recipient on a target chain.
function sendCrossChainDeposit(
uint16 targetChain, // A wormhole chain id
address targetHelloToken, // address of HelloToken contract on targetChain
address recipient,
uint256 amount,
address token
) public payable;
The body of this function will send the token as well as a payload to the HelloToken contract on the target chain. For our application, the payload will contain the intended recipient of the token, so that the target chain HelloToken contract can send the token to the intended recipient.
Note: TokenBridge only supports sending IERC20 tokens, and specifically only up to 8 decimals of a token. So, if your IERC20 token has 18 decimals, and you send
amount
of a token, you will receiveamount
rounded down to the nearest multiple of 10^10.
To send the token and payload to the HelloToken contract, we make use of the sendTokenWithPayloadToEvm
helper from the Wormhole Solidity SDK.
For a successful transfer, several things need to happen:
- The user (or contract) who calls
sendCrossChainDeposit
should approve theHelloToken
contract to useamount
of the user's tokens. See how that is done in the forge test here - We must transfer
amount
of the token from the user to theHelloToken
source contractIERC20(token).transferFrom(msg.sender, address(this), amount);
- We must encode the recipient address into a payload
bytes memory payload = abi.encode(recipient);
- We must ensure the correct amount of
msg.value
was passed in to send the token and payload.- The cost to send a token is provided by the value returned by
wormhole.messageFee()
Currently this is 0 but may change in the future, so don't assume it will always be 0. - The cost to request a relay depends on the gas amount and receiver value you will need.
(deliveryCost,) = wormholeRelayer.quoteEVMDeliveryPrice(targetChain, 0, GAS_LIMIT);
- The cost to send a token is provided by the value returned by
function sendCrossChainDeposit(
uint16 targetChain,
address targetHelloToken,
address recipient,
uint256 amount,
address token
) public payable {
uint256 cost = quoteCrossChainDeposit(targetChain);
require(msg.value == cost,
"msg.value != quoteCrossChainDeposit(targetChain)");
IERC20(token).transferFrom(msg.sender, address(this), amount);
bytes memory payload = abi.encode(recipient);
sendTokenWithPayloadToEvm(
targetChain,
targetHelloToken, // address (on targetChain) to send token and payload
payload,
0, // receiver value
GAS_LIMIT,
token, // address of IERC20 token contract
amount
);
}
function quoteCrossChainDeposit(uint16 targetChain)
public view returns (uint256 cost) {
// Cost of delivering token and payload to targetChain
uint256 deliveryCost;
(deliveryCost,) =
wormholeRelayer.quoteEVMDeliveryPrice(targetChain, 0, GAS_LIMIT);
// Total cost: delivery cost +
// cost of publishing the 'sending token' wormhole message
cost = deliveryCost + wormhole.messageFee();
}
Now, we'll implement the TokenReceiver
abstract class - which is also included in the Wormhole Solidity SDK
struct TokenReceived {
bytes32 tokenHomeAddress;
uint16 tokenHomeChain;
address tokenAddress;
uint256 amount;
uint256 amountNormalized;
}
function receivePayloadAndTokens(
bytes memory payload,
TokenReceived[] memory receivedTokens,
bytes32 sourceAddress,
uint16 sourceChain,
bytes32 deliveryHash
) internal virtual {}
After we call sendTokenWithPayloadToEvm
on the source chain, the message goes through the standard Wormhole message lifecycle. Once a VAA is available, the delivery provider will call receivePayloadAndTokens
on the target chain and target address specified, with the appropriate inputs.
The arguments payload
, sourceAddress
, sourceChain
, and deliveryHash
are all the same as on the normal receiveWormholeMessages
endpoint.
Let's delve into the fields that are provided to us in the TokenReceived
struct:
-
tokenHomeAddress The same as the
token
field in the call tosendTokenWithPayloadToEvm
, as that is the original address of the token unless the original token sent is a wormhole-wrapped token. In the case a wrapped token is sent, this will be the address of the original version of the token (on it’s native chain) in wormhole address format - i.e. left-padded with 12 zeros -
tokenHomeChain The chain (in wormhole chain ID format) corresponding to the home address above - this will be the source chain, unless if the original token sent is a wormhole-wrapped asset, in which case it will be the chain of the unwrapped version of the token.
-
tokenAddress This is the address of the IERC20 token on this chain (the target chain) that has been transferred to this contract. If tokenHomeChain == this chain, this will be the same as tokenHomeAddress; otherwise, it will be the wormhole-wrapped version of the token sent.
-
amount This is the amount of the token that has been sent to you - the units being the same as the original token. Note that since TokenBridge only sends with 8 decimals of precision, if your token had 18 decimals, this will be the ‘amount’ you sent, rounded down to the nearest multiple of 10^10.
-
amountNormalized This is the amount of token divided by (1 if decimals ≤ 8, else 10^(decimals - 8))
Since all we intend to do is send the received token to the recipient, our fields of interest are payload (containing recipient), receivedTokens[0].tokenAddress (token we received), and receivedTokens[0].amount (amount of token we received and that we must send)
We can complete the implementation as follows:
function receivePayloadAndTokens(
bytes memory payload,
TokenReceived[] memory receivedTokens,
bytes32, // sourceAddress
uint16,
bytes32 // deliveryHash
) internal override onlyWormholeRelayer {
require(receivedTokens.length == 1, "Expected 1 token transfers");
address recipient = abi.decode(payload, (address));
IERC20(receivedTokens[0].tokenAddress).transfer(recipient, receivedTokens[0].amount);
}
Note: In this case, we don't need to prevent duplicate deliveries using the delivery hash, because TokenBridge already provides a form of duplicate prevention when redeeming sent tokens
And voila! We have a complete working example of a cross-chain application that uses TokenBridge to send and receive tokens!
Try cloning and running HelloToken to see this example work for yourself!
Let’s walk through the details of sendTokenWithPayloadToEvm
and receivePayloadAndTokens
to see how they make use of the IWormholeRelayer interface and IWormholeReceiver interface to send and receive tokens.
To send a token, we make use of the EVM TokenBridge contract, specifically the transferTokensWithPayload
method (implementation)
Note: We leave the
payload
field here blank because we are using thepayload
field on the IWormholeRelayer interface instead
/*
* @notice Send ERC20 token through portal.
*
* @dev This type of transfer is called a "contract-controlled transfer".
* There are three differences from a regular token transfer:
* 1) Additional arbitrary payload can be attached to the message
* 2) Only the recipient (typically a contract) can redeem the transaction
* 3) The sender's address (msg.sender) is also included in the transaction payload
*
* With these three additional components, xDapps can implement cross-chain
* composable interactions.
*/
function transferTokensWithPayload(
address token,
uint256 amount,
uint16 recipientChain,
bytes32 recipient,
uint32 nonce,
bytes memory payload
) public payable nonReentrant returns (uint64 sequence)
TokenBridge implements this function by publishing a wormhole message to the blockchain logs that indicates that amount
of the token
was sent (with the intended address being recipient
on recipientChain
). TokenBridge then returns the sequence number of this published wormhole message.
The transferTokens
function in the Wormhole Solidity SDK makes use of this TokenBridge endpoint by
- approving the TokenBridge to spend
amount
of our ERC20token
- calling
transferTokensWithPayload
with the appropriate inputs - returning a
VaaKey
struct containing information about the published wormhole message for the token transfer
function transferTokens(
address token,
uint256 amount,
uint16 targetChain,
address targetAddress
) internal returns (VaaKey memory) {
IERC20(token).approve(address(tokenBridge), amount);
uint64 sequence = tokenBridge.transferTokensWithPayload
{value: wormhole.messageFee()}(
token, amount, targetChain, toWormholeFormat(targetAddress), 0, bytes("")
);
return VaaKey({
emitterAddress: toWormholeFormat(address(tokenBridge)),
chainId: wormhole.chainId(),
sequence: sequence
});
}
Now, it is our task to get the signed VAA corresponding to this published token bridge wormhole message to be delivered to our target chain HelloToken contract. To do this, we make use of the sendVaasToEvm endpoint in the IWormholeRelayer interface.
function sendVaasToEvm(
uint16 targetChain,
address targetAddress,
bytes memory payload,
uint256 receiverValue,
uint256 gasLimit,
VaaKey[] memory vaaKeys
) external payable returns (uint64 sequence);
This allows us to specify existing wormhole message(s) and get the signed VAA(s) corresponding to those messages delivered to the targetAddress (in the additionalVaas
field of receiveWormholeMessages
).
function sendTokenWithPayloadToEvm(
uint16 targetChain,
address targetAddress,
bytes memory payload,
uint256 receiverValue,
uint256 gasLimit,
address token,
uint256 amount
) internal returns (uint64) {
VaaKey[] memory vaaKeys = new VaaKey[](1);
vaaKeys[0] = transferTokens(token, amount, targetChain, targetAddress);
(uint256 cost,) =
wormholeRelayer.quoteEVMDeliveryPrice(targetChain, receiverValue, gasLimit);
return wormholeRelayer.sendVaasToEvm{value: cost}(
targetChain, targetAddress, payload, receiverValue, gasLimit, vaaKeys
);
}
Note: If you wish to send multiple different tokens along with the payload, the
sendTokenWithPayloadToEvm
helper as currently implemented will not help (as it sends only one token). However, you can still calltransferToken
twice and request delivery of both of those TokenBridge wormhole messages by providing twoVaaKey
structs in thevaaKeys
array. See an example of HelloToken with more than one token here.
We know that our sendVaasToEvm
call will cause receiveWormholeMessages
on targetAddress
to be called with
- The payload as the encoded
recipient
address - The
additionalVaas
field being an array of length 1, with the first element being the signed VAA corresponding to our token bridge transfer
Crucially, we don't have the transferred tokens yet! There are a few things that we need to do before gaining access to these tokens.
-
We parse the signed VAA, and check that
- The emitterAddress of the VAA is a valid token bridge - i.e. the message was published by one of the TokenBridge contracts
- The transfer was sent to this address
note: this step isn’t strictly necessary because the call to
completeTransferWithPayload
would fail if these were not true** -
We call
tokenBridge.completeTransferWithPayload
, passing the VAA - this completes the transfer of the tokens and causes us to receive the (potentially wormhole-wrapped) transferred token -
We return a
TokenReceived
struct containing useful information about the transfer -
We call
receivePayloadAndTokens
with the appropriate inputs
function receiveWormholeMessages(
bytes memory payload,
bytes[] memory additionalVaas,
bytes32 sourceAddress,
uint16 sourceChain,
bytes32 deliveryHash
) external payable {
TokenReceived[] memory receivedTokens =
new TokenReceived[](additionalVaas.length);
for (uint256 i = 0; i < additionalVaas.length; ++i) {
IWormhole.VM memory parsed = wormhole.parseVM(additionalVaas[i]);
require(
parsed.emitterAddress == tokenBridge.bridgeContracts(parsed.emitterChainId), "Not a Token Bridge VAA"
);
ITokenBridge.TransferWithPayload memory transfer = tokenBridge.parseTransferWithPayload(parsed.payload);
require(
transfer.to == toWormholeFormat(address(this)) && transfer.toChain == wormhole.chainId(),
"Token was not sent to this address"
);
tokenBridge.completeTransferWithPayload(additionalVaas[i]);
address thisChainTokenAddress = getTokenAddressOnThisChain(transfer.tokenChain, transfer.tokenAddress);
uint8 decimals = getDecimals(thisChainTokenAddress);
uint256 denormalizedAmount = transfer.amount;
if (decimals > 8) denormalizedAmount *= uint256(10) ** (decimals - 8);
receivedTokens[i] = TokenReceived({
tokenHomeAddress: transfer.tokenAddress,
tokenHomeChain: transfer.tokenChain,
tokenAddress: thisChainTokenAddress,
amount: denormalizedAmount,
amountNormalized: transfer.amount
});
}
// call into overriden method
receivePayloadAndTokens(payload, receivedTokens, sourceAddress, sourceChain, deliveryHash);
}
See the full implementation of the Wormhole Relayer SDK helpers here
Also, see a version of HelloToken implemented without any Wormhole Relayer SDK helpers here
as well as a version of HelloToken where native currency is deposited here