Using a Custom 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 a custom handler contract.

It is reccommended for most use cases to use the Using the Generic Multicaller Handler Contract, but if your use case requires more complex logic, you'll need to implement a custom handleV3AcrossMessage function and deploy a new contract, but creating the deposit message is trivial.

Crafting the Message

// Ethers V6
import ethers from "ethers";

function generateMessageForCustomHandler(userAddress: string) {
  const abiCoder = ethers.AbiCoder.defaultAbiCoder();
  return abiCoder.encode(["address"], [userAddress]);
}

Example in solidity:

function generateMessageForCustomhandler(address userAddress) returns (bytes memory) {
  return abi.encode(userAddress);
}

Implementing the Handler Contract

You will need to implement a function matching the following interface in your handler contract to receive the message:

function handleV3AcrossMessage(
    address tokenSent,
    uint256 amount,
    address relayer,
    bytes memory message
) external;

For this example, we're depositing the funds the user sent into AAVE on the user's behalf. Here's how that full contract implementation might look.

This contract has not been vetted whatsoever, so use this sample code at your own risk.

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 AaveDepositor {
  using SafeERC20 for IERC20;
  
  error Unauthorized();

  address public immutable aavePool;
  uint16 public immutable referralCode;
  address public immutable acrossSpokePool;
  
  constructor(address _aavePool, uint16 _referralCode, address _acrossSpokePool) {
    aavePool = _aavePool;
    referralCode = _referralCode;
    acrossSpokePool = _acrossSpokePool;
  }
  
  function handleV3AcrossMessage(
    address tokenSent,
    uint256 amount,
    address relayer, // relayer is unused
    bytes memory message
  ) external {
    // Verify that this call came from the Across SpokePool.
    if (msg.sender != acrossSpokePool) revert Unauthorized();
  
    // Decodes the user address from the message.
    address user = abi.decode(message, (address));
    
    // Approve and deposit the funds into AAVE on behalf of the user.
    IERC20(tokenSent).safeIncreaseAllowance(aavePool, amount);
    aavePool.deposit(tokenSent, amount, user, referralCode);
  }
}

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 contract that implements the handler you would like to call.

    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 your custom handler contract.

    2. The message field should be set to the message you generated above instead of 0x.

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

You can find this interface definition in the codebase here.

What happens after the deposit is received on the destination chain?

When the relay is filled, the destination SpokePool calls handleV3AcrossMessage on the recipient contract (your custom handler contract) with the message (and a few other fields). You can define any arbitrary logic in handleV3AcrossMessage to fit your use case.

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, and you have a handler contract deployed on the destination chain, all you need to do is send the deposit to have the 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. This example uses a custom handler contract. In this example, the message should be contain an encoded address and bool value only:

function generateMessageForCustomHandler(userAddress: string, sendWeth: boolean) {
  const abiCoder = ethers.AbiCoder.defaultAbiCoder();
  return abiCoder.encode(["address", "bool"], [userAddress, sendWeth]);
}
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 WrapChoice {
    using SafeERC20 for IERC20;

    error Unauthorized();
    error NotWETH();
    error FailedETHTransfer()

    address public immutable weth;
    address public immutable acrossSpokePool;
    
    constructor(address _weth, address _acrossSpokePool) {
      weth = _weth;
      acrossSpokePool = _acrossSpokePool;
    }
    
    function handleV3AcrossMessage(
        address tokenSent, 
        uint256 amount, 
        address relayer, // relayer is unused 
        bytes memory message
    ) external {
        if (msg.sender != acrossSpokePool) revert Unauthorized();
        if (tokenSent != weth) revert NotWETH();
        
        (address payable user, bool sendWETH) = abi.decode(message, (address, bool));
        
        if (sendWETH) {
          // Transfer WETH directly to user address.
          IERC20(weth).safeTransfer(user, amount);
        } else {
          weth.withdraw(amount);
          // Transfer the funds to the user via low-level call.
          (bool success,) = user.call{value: amount}("");
          if (!success) revert FailedETHTransfer();
        }
    }
    
    // Required to receive ETH from the WETH contract.
    receive() external payable {}
}

The deposit recipient should be set to your custom handler contract's address on the destination chain. Generating the Deposit is identical to process described above.

Reverting Transactions

  • If the recipient contract's handleV3AcrossMessage function reverts when message , tokenSent, amount, and relayer are passed to it, then 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.

  • It is possible to update the message using speedUpDepositV3but it requires the original depositor address to create a signature, which may not be possible if the depositor is not an EOA or a smart contract capable of creating ERC1271 signatures.

  • If the recipient address is not a contract on the destination chain, then the fillRelay transaction will not attempt to pass calldata to it.

  • Consider implementing fallback logic in your custom handler contract to transfer funds in the case of a transaction revert based on your use case.

Summarized Requirements

  • The deposit message is not empty

  • The recipient address is your custom handler contract on the destinationChainId

  • Construct your message

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