Advanced ABI Decoding

Starting with Lattice firmware v0.16.0, your device has the ability to automatically decode more complex ABI-encoded data, such as multicall patterns, commonly used in crypto dApps like Gnosis Safe and Uniswap V3.

Let's go through a more advanced example to highlight how the information on your Lattice screen can be used to get a high degree of confidence over what you are signing. Consider the following payload, which comes from Uniswap V3 (this is the unfiltered version, pure hex):

If you send this to your Lattice, you will see the following decoded data:

Let's take a dive into this example by first looking at the contract being called. In this case, the user has tagged the address Uniswap V3, which corresponds to this contract. If this had not been tagged, you would see the contract address 0x68b3465833fb72a70ecdf485e0e4c7bd8665fc45.

Reading Contract Source Code On Etherscan

You can look up the contract code on Etherscan here, where you should see a series of 63 contract source files - use the search bar to look for functions and definitions you want to inspect throughout this exercise.

Reading the Entry Point

Now that we have the source code pulled up, we're ready to verify what's going on. The Lattice screens above show that the entry point being called is multicall, since that's the first function listed on the screen.

NOTE: On Lattice decoding screens, function calls are closed with two brackets, [[ ]], while parameters are closed with single brackets, [ ].

Indeed, this function can be found in the contract source in MulticallExtended.sol:

function multicall(uint256 deadline, bytes[] calldata data)
    external
    payable
    override
    checkDeadline(deadline)
    returns (bytes[] memory)
{
    return multicall(data);
}

Interestingly, that function is just performing some sanity checks on deadline and passing the data to another instance of multicall. We can search through the code again and we find the next instance of multicall, this time in Multicall.sol.

function multicall(bytes[] calldata data) public payable override returns (bytes[] memory results) {
    results = new bytes[](data.length);
    for (uint256 i = 0; i < data.length; i++) {
        (bool success, bytes memory result) = address(this).delegatecall(data[i]);

        if (!success) {
            // Next 5 lines from https://ethereum.stackexchange.com/a/83577
            if (result.length < 68) revert();
            assembly {
                result := add(result, 0x04)
            }
            revert(abi.decode(result, (string)));
        }

        results[i] = result;
    }
}

This function is taking an array of bytes and using each one to perform a delegatecall on some function in the same contract's source code. This last part is important because it means no external contracts are being called -- everything you need to know about what's happening is on the Etherscan source page we've been already looking at.

Back to the Lattice screens. You should see that multicall has two parameters specified on the screen: deadline and data. These appear to match the first multicall function definition we found on Etherscan. So far so good.

Now take a look at data. You will notice it contains an array of more function calls. That's because your Lattice is actually decoding the individual bytes items that are getting looped through in the outer multicall request. This is what we refer to as "nested ABI decoding" because there are two layers of data being decoded: the original payload, and now each one of the bytes fields in the data param.

Nested Function #1:

The first nested function call is exactOutputSingle. Let's go back to the source code and look this function up. You can find it in V3SwapRouter.sol:

function exactOutputSingle(ExactOutputSingleParams calldata params)
    external
    payable
    override
    returns (uint256 amountIn)
{
    // avoid an SLOAD by using the swap return data
    amountIn = exactOutputInternal(
        params.amountOut,
        params.recipient,
        params.sqrtPriceLimitX96,
        SwapCallbackData({path: abi.encodePacked(params.tokenOut, params.fee, params.tokenIn), payer: msg.sender})
    );

    require(amountIn <= params.amountInMaximum, 'Too much requested');
    // has to be reset even though we don't use it in the single hop case
    amountInCached = DEFAULT_AMOUNT_IN_CACHED;
}

No comments. We should search again and see if there is a definition. Looks like there is one in IV3SwapRouter.sol (I usually denotes "interface", which is where function comments usually live):

/// @notice Swaps as little as possible of one token for `amountOut` of another token
/// that may remain in the router after the swap.
/// @param params The parameters necessary for the swap, encoded as `ExactOutputSingleParams` in calldata
/// @return amountIn The amount of the input token
function exactOutputSingle(ExactOutputSingleParams calldata params) external payable returns (uint256 amountIn);

So based on the comments (and basic inference), it appears this function is defining the boundaries for the swap function we would like to make. Namely, it specifies the recipient, tokens, and amounts that are within scope of the desired swap.

One important thing to point out with this function is that there is only one parameter and it is of type ExactOutputSingleParams. This is not a native ABI type (e.g. addresss, uint256, etc) - it is a defined type. We can do another search on the Etherscan source page and find a definition in IV3SwapRouter.sol:

struct ExactOutputSingleParams {
    address tokenIn;
    address tokenOut;
    uint24 fee;
    address recipient;
    uint256 amountOut;
    uint256 amountInMaximum;
    uint160 sqrtPriceLimitX96;
}

Defined types are often used by smart contract writers when a particular set of parameters is reused across functions - doing so can prevent errors and bugs.

Displaying parameter names

Your Lattice should display named parameters, such as [data], if it is able to fetch the contract data from Etherscan (or similar explorer, e.g. Arbiscan). This is only possible if the author uploads the verified contract source code, i.e. "open sources" the contract. This is the norm in Ethereum, but may not be as common in other ecosystems. If no source code is found, or if the Etherscan request fails, the requester (MetaMask or Frame) should fallback to searching 4byte for the definition. If this happens, your Lattice will end up displaying generic parameter names like [#1], [#2], etc. This is because 4byte does not hold source code - it is only a repository for raw function definitions, which do not include parameter names. In our current example, the nested function definition is:

exactOutputSingle(

(address,address,uint24,address,uint256,uint256,uint160)

)

This is still enough information to decode the parameters (based on type), but the requester can't tell your Lattice what they are named.

You probably noticed from the above screens that nested functions do not display named parameters. Instead, you see [#1], [#1-1], [#1-2], etc. This is because while our current example only makes calls internal to the same contract, more advanced examples might call out to external contracts, such as proxies. This becomes very tricky very fast because there is no specific way to know the address(es) of the external contract(s) being called - there are an infinite number of possible patterns and even covering common ones would introduce a lot of complexity and require continual maintenance. As such, we cannot request nested parameter names from Etherscan (which requires an address) and must instead rely on 4byte. In future updates we may add display of real param names in specific cases where nested calls are being made to functions inside the same contract, such as our current example.

This article talks more about how your Lattice utilizes the ABI spec and its limitations.

You can use the definition above to determine which parameters have which values; in this case, tokenIn (#1-1) is WETH, which is another address that has been tagged by this Lattice's owner. fee (#1-3) is 500. It's unclear what unit this is, but as you may have noticed by now, the entire contract is open source, so you can figure it out if you dig deep enough into the code. We will leave that as an exercise for the reader 😄.

Nested Function #2

The next nested function is refundETH. Here's the info on that:

/// @notice Refunds any ETH balance held by this contract to the `msg.sender`
/// @dev Useful for bundling with mint or increase liquidity that uses ether, or exact output swaps
/// that use ether for the input amount
function refundETH() external payable;
function refundETH() external payable override {
    if (address(this).balance > 0) TransferHelper.safeTransferETH(msg.sender, address(this).balance);
}

This function takes no arguments and refunds any ETH that is not spent in the swap. This is necessary because you call the swap with the "worst acceptable price", and usually you will get a better price than that, meaning you will likely have extra ETH that does not get swapped and should be refunded.

Putting it Together

In summary, there are three separate functions getting called:

  1. multicall - accepts an array of calldata bytes arrays plus a deadline integer

  2. exactOutputSingle - nested function, makes the swap

  3. refundETH - nested function, refunds any unused ETH

After this exercise you should be quite confident that you are signing what you think you are. Although it is unlikely that all of your transactions will demand this level of scrutiny, remember that your Lattice should give you enough information to perform as much due diligence as you see fit.

The above exercise is only possible because of published contract code on Etherscan. If the contract you are using does not have published source code, this should make you skeptical of it. And if your Lattice does not decode the calldata at all, that should make you extra skeptical, as this means the contract was not published and the function you're calling is not defined on 4byte.

Last updated