Using the Generic Multicaller Handler Contract

Creating an Across+ Transaction for an Aave Deposit (Example #1)

In this example we'll be walking through how to use Across+ 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

Across+ requires 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 for initiating a deposit (Initiating a Deposit (User Intent)). However, there are a few tweaks to that process to include a message.

  1. When getting a quote (Getting a Quote), two additional query parameters need to be added.

    1. recipient: the recipient for the deposit. For Across+ transactions, this is not the end-user. It is the the multicall handler contract.

    2. message: the message you crafted above.

  2. When calling deposit (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.

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

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 an Across+ 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 exmaple, 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 an Across+ 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.

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 relayerFeePct you should set for your message and recipient combination

  • Call depositV3() passing in your message

  • 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 deposit's relayerFeePct

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