Using the Generic Multicaller Handler Contract

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 typescript:

// Ethers V6
import { ethers } from "ethers";

function generateMessageForMulticallHandler(
  userAddress, 
  aaveAddress, 
  depositAmount, 
  depositCurrency, 
  aaveReferralCode
) {
  const abiCoder = ethers.AbiCoder.defaultAbiCoder();

  // Define the ABI of the functions
  const approveFunction = "function approve(address spender, uint256 value)";
  const depositFunction = "function deposit(address asset, uint256 amount, address onBehalfOf, uint16 referralCode)";

  // Create Interface instances
  const erc20Interface = new ethers.Interface([approveFunction]);
  const aaveInterface = new ethers.Interface([depositFunction]);

  // Encode the function calls with selectors
  const approveCalldata = erc20Interface.encodeFunctionData("approve", [aaveAddress, depositAmount]);
  const depositCalldata = aaveInterface.encodeFunctionData("deposit", [depositCurrency, depositAmount, userAddress, aaveReferralCode]);

  // Encode the Instructions object
  return abiCoder.encode(
    [
      "tuple(" +
        "tuple(" +
          "address target," +
          "bytes callData," +
          "uint256 value" +
        ")[]," +
        "address fallbackRecipient" +
      ")"
    ],
    [
      [
        [
          [depositCurrency, approveCalldata, 0],
          [aaveAddress, depositCalldata, 0], 
        ],
        userAddress
      ]
    ]
  );
}

Example in solidity:

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";

interface AavePool {
  function deposit(
    address asset,
    uint256 amount,
    address onBehalfOf,
    uint16 referralCode
  ) external;
}

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;
  }
  
  function generateMessageForMulticallHandler(
    userAddress: address,
    aavePool: AavePool,
    depositAmount: uint256,
    depositCurrency: address,
    aaveReferralCode: uint16,
  ) returns (bytes memory) {
    bytes memory approveCalldata = abi.encodeWithSelector(
      IERC20.safeIncreaseAllowance.selector, 
      aaveAddress, 
      depositAmount
    );
    bytes memory depositCalldata = abi.encodeWithSelector(
      AavePool.deposit.selector, 
      depositCurrency
      depositAmount,
      userAddress,
      aaveReferralCode
    );
    Call[] calls;
    calls.push(Call({ target: depositCurrency, callData: approveCalldata, value: 0 });
    calls.push(Call({ target: address(aavePool), callData: depositCalldata, value: 0 });
    Instructions memory instructions = Instructions({
      calls: calls,
      fallbackRecipient: userAddress
    });
    return abi.encode(instructions);
  }
  
  function generateMessageForCustomhandler(address userAddress) returns (bytes memory) {
    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.

  1. When getting a quote, two additional query parameters need to be added.

    1. recipient: the recipient for the deposit. In this use case, this is not the end-user. It is the the multicall handler contract.

    2. 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:

      1. 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.

      2. 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.

  2. When calling depositV3, you'll need to make a slight tweak to the parameters.

    1. The recipient should be set to the multicall handler contract

    2. 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.

    3. 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:

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/contracts/token/ERC20/utils/SafeERC20.sol";

interface WETHInterface {
  function withdraw(uint 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();
  
  function generateMessageForMulticallHandler(
    userAddress: address,
    outputToken: address,
    outputAmount: uint256,
    sendWeth: bool
  ) returns (bytes memory) {
    if (outputToken != weth) revert NotWETH();
    Call[] calls;
    if (sendWETH) {
      // Transfer WETH directly to user address.
      bytes memory transferCalldata = abi.encodeWithSelector(
        IERC20.safeTransfer.selector, 
        userAddress, 
        outputAmount
      );
      calls.push(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
      );
      calls.push(Call({ target: outputToken, callData: withdrawCalldata, value: 0 });
      calls.push(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 typescript:

// 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:

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/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;
  }
  
  function generateMessageForMulticallHandler(
    userAddress: address,
    outputAmount: uint256,
    outputCurrency: address,
    feeRecipientAddress: address,
    feePercentage: uint256,
  ) returns (bytes memory) {
    // 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;
    bytes memory userTransferCalldata = abi.encodeWithSelector(
      IERC20.safeTransfer.selector, 
      userAddress, 
      outputAmountPostFees
    );
    bytes memory feeTransferCalldata = abi.encodeWithSelector(
      IERC20.safeTransfer.selector, 
      feeRecipientAddress, 
      feeAmount
    );
    Call[] calls;
    calls.push(Call({ target: outputCurrency, callData: userTransferCalldata, value: 0 });
    calls.push(Call({ target: outputCurrency, callData: feeTransferCalldata, value: 0 });
    Instructions memory instructions = Instructions({
      calls: calls,
      fallbackRecipient: userAddress
    });
    return abi.encode(instructions);
  }
  
  function generateMessageForCustomhandler(address userAddress) returns (bytes memory) {
    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.

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:

import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "openzeppelin-contracts/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;
  }
  
  function generateMessageForMulticallHandler(
    userAddress: address,
    uniswapSwapRouter: ISwapRouter,
    swapParams: ISwapRouter.ExactInputSingleParams
  ) returns (bytes memory) {
    bytes memory approveCalldata = abi.encodeWithSelector(
      IERC20.safeIncreaseAllowance.selector, 
      aaveAddress, 
      depositAmount
    );
    bytes memory swapCalldata = abi.encodeWithSelector(
      ISwapRouter.exactInputSingle.selector, 
      swapParams
    );
    Call[] calls;
    calls.push(Call({ target: depositCurrency, callData: approveCalldata, value: 0 });
    calls.push(Call({ target: address(uniswapSwapRouter), callData: swapCalldata, value: 0 });
    Instructions memory instructions = Instructions({
      calls: calls,
      fallbackRecipient: userAddress
    });
    return abi.encode(instructions);
  }
  
  function generateMessageForCustomhandler(address userAddress) returns (bytes memory) {
    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:

  1. 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.

  2. tokenOut will be the token that we want to swap into via the Uniswap swap router.

  3. 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.

  4. recipient should be the userAddress

  5. deadline The Uniswap docs provides details about this parameter, which is the unix time after which the swap fails.

  6. 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:

    1. 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.

    2. 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.

  7. amountOutMinimum should be based on the amountIn value used above and the minimum price ratio you're willing to accept

  8. 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.

Last updated