Creating a Transaction for an Aave Deposit (Example #1)
In this example we'll be walking through how to use this functionality to perform an Aave deposit on behalf of the user on the destination chain, using our audited generic multicall handler. Deployments of the audited multicall handler contract be found here.
Crafting the Message
To execute a destination action, it is required that you send some nonempty message to the handler contract on the other side. This message allows you to pass arbitrary information to a recipient contract and it ensures that Across understands that you intend to trigger the handler function on the recipient contract (instead of just transferring tokens). A message is required if you want the handler to be called.
In this example, our message will include the user's address, the Aave contract address, and the calldata we want to execute to submit an Aave deposit as the minimum required information that our handler contract would need to know to generate an Aave deposit instruction. Here's an example for generating this in javascript:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/**
* @title AavePool Interface
* @dev Interface for interacting with Aave's Pool contract
*/
interface AavePool {
function deposit(
address asset,
uint256 amount,
address onBehalfOf,
uint16 referralCode
) external;
}
/**
* @title Generate Message Helper
* @dev Contract to generate encoded calldata for multichain handlers
*/
contract GenerateMessageHelper {
using SafeERC20 for IERC20;
struct Call {
address target;
bytes callData;
uint256 value;
}
struct Instructions {
// Calls that will be attempted
Call[] calls;
// Where the tokens go if any part of the call fails
// Leftover tokens are sent here as well if the action succeeds
address fallbackRecipient;
}
/**
* @notice Generates encoded message for Aave deposit via multicall handler
* @param userAddress Address of the user who will receive aTokens
* @param aavePool Address of the Aave Pool contract
* @param depositAmount Amount of tokens to deposit
* @param depositCurrency Address of the token to deposit
* @param aaveReferralCode Aave referral code
* @return bytes Encoded instructions for multicall handler
*/
function generateMessageForMulticallHandler(
address userAddress,
AavePool aavePool,
uint256 depositAmount,
address depositCurrency,
uint16 aaveReferralCode
) public pure returns (bytes memory) {
// Input validation
require(userAddress != address(0), "Invalid user address");
require(address(aavePool) != address(0), "Invalid Aave pool address");
require(depositCurrency != address(0), "Invalid currency address");
require(depositAmount > 0, "Deposit amount must be positive");
// Generate approve calldata
bytes memory approveCalldata = abi.encodeWithSelector(
IERC20.approve.selector,
address(aavePool),
depositAmount
);
// Generate deposit calldata
bytes memory depositCalldata = abi.encodeWithSelector(
AavePool.deposit.selector,
depositCurrency,
depositAmount,
userAddress,
aaveReferralCode
);
// Create calls array with exact size
Call[] memory calls = new Call[](2);
calls[0] = Call({
target: depositCurrency,
callData: approveCalldata,
value: 0
});
calls[1] = Call({
target: address(aavePool),
callData: depositCalldata,
value: 0
});
// Create and return encoded instructions
Instructions memory instructions = Instructions({
calls: calls,
fallbackRecipient: userAddress
});
return abi.encode(instructions);
}
/**
* @notice Generates encoded message for custom handler
* @param userAddress Address of the user
* @return bytes Encoded user address
*/
function generateMessageForCustomhandler(address userAddress)
public
pure
returns (bytes memory)
{
require(userAddress != address(0), "Invalid user address");
return abi.encode(userAddress);
}
}
Generating the Deposit
The deposit creation process is nearly identical to the process described in Bridge Integration Guide. However, there are a few tweaks to that process to include a message.
When getting a quote, two additional query parameters need to be added.
recipient: the recipient for the deposit. In this use case, this is not the end-user. It is the the multicall handler contract.
message: the message you crafted above. In the above Aave example, the outputAmount needs to be set equal to the depositAmount to ensure that the amount expected to be received on the destination chain by the handler contract is equal to the amount to be deposited into Aave. However, usually the suggested-fees endpoint in the API is queried to get the suggested outputAmount to set in order to make the inputAmount profitable for fillers to relay. We recommend doing the following in cases like this Aave example:
In the message you use as a parameter when querying the suggested-fees endpoint, set depositAmount = outputAmount = inputAmount which will return you a suggested output amount.
When you call depositV3, set outputAmount to the newly suggested output amount, and keep inputAmount equal to depositAmount. This will ensure that your deposit is profitable for fillers.
When calling depositV3, you'll need to make a slight tweak to the parameters.
The recipient should be set to the multicall handler contract
The message field should be set to the message you generated above instead of 0x. Make sure that you re-generate the message setting inputAmount = depositAmount and outputAmount equal to the suggested output amount.
In the Aave example, the inputAmount should be set equal to the depositAmount and the outputAmount should be set equal to the suggested-fees' suggested output amount. This suggested output amount will be received on the destination chain by the handler contract and be equal to the amount to be deposited into Aave on behalf of the user.
What happens after the deposit is received on the destination chain?
This contract, as mentioned above, is audited and works as a generic handler where you can submit a list of transactions to be executed on the destination chain after receiving the deposit. This contract's deployed addresses can be found here. All you need to do to get started using this contract to execute a destination action is to set the deployed address on the deposit's destination chain as the recipient address and construct the message appropriately.
The multicall handler implements a handleV3AcrossMessage that expects the message to be an encoded set of Instructions, which contains a list of transactions to execute. In the Aave example, the transactions to execute are (1) approve the Aave pool to transfer tokens from the handler contract and (2) submit a deposit to the Aave pool using the received tokens. In the example above, the function generateMessageForMulticallHandler can be used to create a message in the correct format.
As you can see above when generating the deposit message and below in building a custom handler contract, the tradeoff of using the multicall handler contract is that the complexity is encountered when crafting the message, which must be a correctly-encoded Instructions object. Once the message is formed correctly, you don't need to write any more code.
Message Constraints
Handler contracts only uses the funds that are sent to it. That means that the message is assumed to only have authority over those funds and, critically, no outside funds. This is important because relayers can send invalid relays. They will not be repaid if they attempt this, but if an invalid message could unlock other funds, then a relayer could spoof messages maliciously.
Conclusion
Now that you have a process for constructing a message, creating a deposit transaction, all you need to do is send the deposit to have the generic multicall handler get executed on the destination.
Creating a Transaction for WrapChoice (Example #2)
Here's another contract example using a slightly different message format to allow users to choose whether they want to receive WETH or ETH. In this example, a bool is passed along with the address of the user to allow the message to define the unwrapping behavior on the destination.
Here is an example of generating a message that can be used with an already-deployed Multicall handler contract:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
interface WETHInterface {
function withdraw(uint256 wad) external;
}
contract GenerateMessageHelper {
using SafeERC20 for IERC20;
// Optimism WETH contract hardcoded as an example.
address public constant WETH = 0x4200000000000000000000000000000000000006;
struct Call {
address target;
bytes callData;
uint256 value;
}
struct Instructions {
// Calls that will be attempted.
Call[] calls;
// Where the tokens go if any part of the call fails.
// Leftover tokens are sent here as well if the action succeeds.
address fallbackRecipient;
}
error NotWETH();
/**
* @notice Generates encoded message for WETH-related operations via multicall handler
* @param userAddress The address that will receive either WETH or ETH
* @param outputToken Must be WETH address or the function will revert
* @param outputAmount Amount of WETH/ETH to transfer
* @param sendWeth If true, sends WETH; if false, unwraps to ETH first
* @return bytes Encoded instructions for the multicall handler
*/
function generateMessageForMulticallHandler(
address userAddress,
address outputToken,
uint256 outputAmount,
bool sendWeth
) public pure returns (bytes memory) {
if (outputToken != WETH) revert NotWETH();
Call[] memory calls;
if (sendWeth) {
// Transfer WETH directly to user address.
bytes memory transferCalldata = abi.encodeWithSelector(
IERC20.transfer.selector, // Changed from safeTransfer to match IERC20
userAddress,
outputAmount
);
// Initialize memory array with size 1
calls = new Call[](1);
calls[0] = Call({
target: outputToken,
callData: transferCalldata,
value: 0
});
} else {
// Unwrap WETH and send ETH directly to user address
bytes memory withdrawCalldata = abi.encodeWithSelector(
WETHInterface.withdraw.selector,
outputAmount
);
// Initialize memory array with size 2
calls = new Call[](2);
calls[0] = Call({
target: outputToken,
callData: withdrawCalldata,
value: 0
});
calls[1] = Call({
target: userAddress,
callData: "",
value: outputAmount
});
}
Instructions memory instructions = Instructions({
calls: calls,
fallbackRecipient: userAddress
});
return abi.encode(instructions);
}
}
Using the above contract, generate a message by calling generateMessageForMulticallHandler and then use this message when submitting a deposit. Like in the Aave example, the outputAmount and outputToken used to form the message should match the deposit's outputAmount and outputToken.
The deposit recipient should be set to the deployed MulticallHandler address on the destination chain. Generating the Deposit is identical to process described above.
Charging Protocol Fees (Example #3)
In this example, our message will include the user's address, the fee percentage, and the fee recipient's address as the minimum required information that our handler contract would need to know. Here's an example for generating this in javascript:
// Ethers V6
import { ethers } from "ethers";
function generateMessageForMulticallHandler(
userAddress,
outputAmount,
outputCurrency,
feeRecipientAddress,
feePercentage
) {
const abiCoder = ethers.AbiCoder.defaultAbiCoder();
// Define the ABI of the functions
const transferFunction = "function transfer(address to, uint256 amount)";
// Create Interface instances
const erc20Interface = new ethers.Interface([transferFunction]);
// Compute output amount post fees that we'll transfer to user. We'll keep the fee
// in the handler contract, that the admin can withdraw.
const oneEther = ethers.utils.parseUnits("1.0");
// oneEther = { BigNumber: "1000000000000000000" }
const feeAmount = outputAmount.mul(feePercentage).div(oneEther);
const outputAmountPostFees = outputAmount.sub(feeAmount);
// Assumption #1: feePercentage is specified in "wei" units, same as outputAmount
// Assumption #2: outputCurrency has 18 decimals of precision, same as Ether
// Encode the function calls with selectors
const userTransferCalldata = erc20Interface.encodeFunctionData("transfer", [userAddress, outputAmountPostFees]);
const feeTransferCalldata = erc20Interface.encodeFunctionData("transfer", [feeRecipientAddress, feeAmount]);
// Encode the Instructions object
return abiCoder.encode(
[
"tuple(" +
"tuple(" +
"address target," +
"bytes callData," +
"uint256 value" +
")[]," +
"address fallbackRecipient" +
")"
],
[
[
[
[outputCurrency, userTransferCalldata, 0],
[outputCurrency, feeTransferCalldata, 0],
],
userAddress
]
]
);
}
Example in solidity:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
contract GenerateMessageHelper {
using SafeERC20 for IERC20;
struct Call {
address target;
bytes callData;
uint256 value;
}
struct Instructions {
// Calls that will be attempted.
Call[] calls;
// Where the tokens go if any part of the call fails.
// Leftover tokens are sent here as well if the action succeeds.
address fallbackRecipient;
}
/**
* @notice Generates encoded message for fee distribution via multicall handler
* @dev Calculates fee amount and creates transfer instructions for both user and fee recipient
* @param userAddress The address that will receive tokens (minus fees)
* @param outputAmount Total amount of tokens to distribute
* @param outputCurrency Token address to transfer
* @param feeRecipientAddress Address that will receive the fee
* @param feePercentage Fee percentage with 18 decimals (e.g., 0.01e18 = 1%)
* @return bytes Encoded instructions for multicall handler
*/
function generateMessageForMulticallHandler(
address userAddress,
uint256 outputAmount,
address outputCurrency,
address feeRecipientAddress,
uint256 feePercentage
) public pure returns (bytes memory) {
// Input validation
require(userAddress != address(0), "Invalid user address");
require(outputCurrency != address(0), "Invalid currency address");
require(feeRecipientAddress != address(0), "Invalid fee recipient address");
// Assume feePercentage and outputAmount are both specified in 18 decimals of precision.
// e.g. feePercentage = 0.01e18 = 1% fee
uint256 feeAmount = outputAmount * feePercentage / 1e18;
uint256 outputAmountPostFees = outputAmount - feeAmount;
// Using transfer instead of approve as we want to move tokens, not approve spending
bytes memory userTransferCalldata = abi.encodeWithSelector(
IERC20.transfer.selector,
userAddress,
outputAmountPostFees
);
bytes memory feeTransferCalldata = abi.encodeWithSelector(
IERC20.transfer.selector,
feeRecipientAddress,
feeAmount
);
// Create calls array with exact size
Call[] memory calls = new Call[](2);
calls[0] = Call({ target: outputCurrency, callData: userTransferCalldata, value: 0 });
calls[1] = Call({ target: outputCurrency, callData: feeTransferCalldata, value: 0 });
Instructions memory instructions = Instructions({
calls: calls,
fallbackRecipient: userAddress
});
return abi.encode(instructions);
}
/**
* @notice Generates encoded message for custom handler
* @param userAddress Address of the user
* @return bytes Encoded user address
*/
function generateMessageForCustomhandler(address userAddress) public pure returns (bytes memory) {
require(userAddress != address(0), "Invalid user address");
return abi.encode(userAddress);
}
}
Using the above contract, generate a message by calling generateMessageForMulticallHandler and then use this message when requesting a quote and submitting a deposit. In the above example, the outputAmount will be set equal to the outputAmount specified in the Across deposit.
We assume feePercentage is set to some fixed value, e.g. 0.01e18to charge 1% on the outputAmount. When querying GET /suggested-fees, the returned outputAmount is inputAmount less bridge fees only (so that the deposit is profitable to relay). Therefore, the user's expected output amount to receive post-fees will be GET /suggested-feesoutputAmount less feePercentage.
Creating a Transaction for a Uniswap Swap (Example #4)
In this example we'll be walking through how to use this functionality to perform a Uniswap swap on behalf of the user on the destination chain. Our message will include the user's address, the Uniswap pool contract address, and the calldata we want to execute to swap against the pool as the minimum required information that our handler contract would need to know to generate the Uniswap swap instruction.
Example in solidity:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
// source: https://github.com/Uniswap/v3-periphery/blob/0682387198a24c7cd63566a2c58398533860a5d1/contracts/interfaces/ISwapRouter.sol#L9
interface ISwapRouter {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 deadline;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
/// @notice Swaps `amountIn` of one token for as much as possible of another token
/// @param params The parameters necessary for the swap, encoded as `ExactInputSingleParams` in calldata
/// @return amountOut The amount of the received token
function exactInputSingle(ExactInputSingleParams calldata params) external payable returns (uint256 amountOut);
}
contract GenerateMessageHelper {
using SafeERC20 for IERC20;
struct Call {
address target;
bytes callData;
uint256 value;
}
struct Instructions {
// Calls that will be attempted.
Call[] calls;
// Where the tokens go if any part of the call fails.
// Leftover tokens are sent here as well if the action succeeds.
address fallbackRecipient;
}
/**
* @notice Generates encoded message for Uniswap swap via multicall handler
* @param userAddress Address of the user who will receive swap results
* @param uniswapSwapRouter Address of the Uniswap SwapRouter contract
* @param swapParams Parameters for the Uniswap swap
* @return bytes Encoded instructions for multicall handler
*/
function generateMessageForMulticallHandler(
address userAddress,
ISwapRouter uniswapSwapRouter,
ISwapRouter.ExactInputSingleParams memory swapParams
) public pure returns (bytes memory) {
require(userAddress != address(0), "Invalid user address");
require(address(uniswapSwapRouter) != address(0), "Invalid router address");
require(swapParams.tokenIn != address(0), "Invalid tokenIn address");
// Generate approve calldata for the token being swapped
bytes memory approveCalldata = abi.encodeWithSelector(
IERC20.approve.selector,
address(uniswapSwapRouter),
swapParams.amountIn
);
// Generate swap calldata
bytes memory swapCalldata = abi.encodeWithSelector(
ISwapRouter.exactInputSingle.selector,
swapParams
);
// Create calls array with exact size
Call[] memory calls = new Call[](2);
calls[0] = Call({
target: swapParams.tokenIn,
callData: approveCalldata,
value: 0
});
calls[1] = Call({
target: address(uniswapSwapRouter),
callData: swapCalldata,
value: 0
});
Instructions memory instructions = Instructions({
calls: calls,
fallbackRecipient: userAddress
});
return abi.encode(instructions);
}
/**
* @notice Generates encoded message for custom handler
* @param userAddress Address of the user
* @return bytes Encoded user address
*/
function generateMessageForCustomhandler(address userAddress) public pure returns (bytes memory) {
require(userAddress != address(0), "Invalid user address");
return abi.encode(userAddress);
}
}
Using the above contract, generate a message by calling generateMessageForMulticallHandler and then use this message when requesting a quote and submitting a deposit.
ExactInputSingleParams parameters:
tokenIn will be the token that we initiate the swap with so it will be the outputToken that we expect to receive on the destination chain following the Across transaction.
tokenOut will be the token that we want to swap into via the Uniswap swap router.
fee will be the fee of the pool we expect to swap through. The Uniswap docs will explain this parameter better but we'll know it at deposit time.
recipient should be the userAddress
deadline The Uniswap docs provides details about this parameter, which is the unix time after which the swap fails.
amountIn will be the amount to initiate the swap with so it will be the outputAmount in the Across transaction. When crafting an Across deposit, usually the suggested-fees endpoint in the API is queried to get the suggested outputAmount to set in order to make the inputAmount profitable for fillers to relay. We recommend doing the following in cases like this Uniswap example:
In the message you use as a parameter when querying the suggested-fees endpoint, set amountIn = outputAmount = inputAmount which will return you a suggested output amount.
When you call depositV3, set outputAmount to the newly suggested output amount, and keep inputAmount equal to the original amountIn from the prior step. This will ensure that your deposit is profitable for fillers.
amountOutMinimum should be based on the amountIn value used above and the minimum price ratio you're willing to accept
sqrtPriceLimitX96 similar to above, read the Uniswap docs for better details.
Reverting Transactions
If the message specifies a transaction that could revert when handled on the destination, it is recommended to set a fallbackRecipient
If a fallbackRecipient is set to some non-zero address and any instruction reverts, the tokens that are received in the fill as outputAmount will be sent to the fallbackRecipient on the destinationChainId
If no fallback is set, and any instructions fail, the fill on destination will fail and cannot occur. In this case the deposit will expire when the destination SpokePool timestamp exceeds the deposit fillDeadline timestamp, the depositor will be refunded on the originChainId. Ensure that the depositor address on the origin SpokePool is capable of receiving refunds.
Summarized Requirements
The deposit message is not empty. Create the message by encoding instructions correctly that will be executed by the MulticallHandler on the destination chain.
Multicall Handler contract deployments can be found here
Alongside the encoded instructions in the message, set a fallbackRecipient address to one that should receive any leftover tokens regardless of instruction execution success. In most cases, this should be the end user's address
The recipient address is the multicall handler contract on the destinationChainId
Use the Across API to get an estimate of the outputAmount you should set for your message and recipient combination.
Call depositV3() passing in your message and the suggested outputAmount.
Once the relayer calls fillV3Relay() on the destination, the recipient multicall handler contract's handleV3AcrossMessage will be executed
The additional gas cost to execute the above function is compensated for in the difference between the deposit's inputAmount and outputAmount.
Security & Safety Considerations
Avoid making unvalidated assumptions about the message data supplied to handleV3AcrossMessage(). Across does not guarantee message integrity, only that a relayer who spoofs a message will not be repaid by Across. If integrity is required, integrators should consider including a depositor signature in the message for additional verification. Message data should otherwise be treated as spoofable and untrusted for use beyond directing the funds passed along with it.
Avoid embedding assumptions about the transfer token or transfer amount into their messages. Instead use the tokenSent and amount variables supplied with to the handleV3AcrossMessage() function. These fields are enforced by the SpokePool contract and so can be assumed to be correct by the recipient contract.
The relayer(s) able to complete a fill can be restricted by storing an approved set of addresses in a mapping and validating the handleV3AcrossMessage()relayer parameter. This implies a trust relationship with one or more relayers.