Meta Transactions and Their Implementation

Summary
In simple terms, a meta transaction is a transaction submitted by a third party on behalf of the user.

What Is a Meta Transaction?

In simple terms, a meta transaction is a transaction submitted by a third party on behalf of the user.

The usual flow is:

  1. The user constructs the transaction parameters and signs them.
  2. A third party sends the signed payload to a Relay or Forwarder contract.
  3. The Relay or Forwarder contract verifies the user’s signature.
  4. The Relay or Forwarder contract calls the target contract.

A normal transaction looks like this:

A meta transaction looks like this:

Note: the Relayer shown in the diagram is the Forwarder contract in this article.

What Can Meta Transactions Be Used For?

Meta transactions are mainly useful in two areas:

  1. User experience

    The barrier to entry for decentralized applications is still high. To use a dapp, an ordinary user often has to: a. register on an exchange, deposit funds, complete KYC, and buy ETH b. install MetaMask, store a seed phrase safely, and learn how addresses work c. withdraw assets from the exchange d. understand how decentralized dapps work and how to use them

    Any one of these steps is inconvenient. Going through the whole process can easily take hours.

    Meta transactions let users interact with a dapp without holding ETH themselves.

  2. Security

    Suppose you keep most of your assets in a cold wallet and do not want that wallet to connect directly to the network. With meta transactions, a Relayer or Forwarder contract can submit transactions on behalf of that wallet.

How to Implement Meta Transactions

We can define a data structure like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
    struct ForwardRequest {
        // User address
        address from;
        // Target contract address
        address to;
        // ETH value sent with the transaction
        uint256 value;
        // Gas limit, optional depending on the design
        uint256 gas;
        // User nonce tracked in the Forwarder to prevent replay
        uint256 nonce;
        // Encoded function selector and parameters
        bytes data;
    }

User Signature

The signed payload is built with EIP-712:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    /// @notice Returns a hash of the given data, prepared using EIP712 typed data hashing rules.
    /// @param from origin sender
    /// @param to contract to call
    /// @param value send ETH value
    /// @param nonce from's nonce
    /// @param data encodewithselector contract call params
    /// @return digest hash digest
    function getDigest(
        address from,
        address to,
        uint256 value,
        uint256 nonce,
        bytes memory data
        ) public view returns (bytes32 digest) {
        digest = _hashTypedDataV4(
            keccak256(abi.encode(_TYPE_HASH, from, to, value, nonce, keccak256(data))));
    }

The helper _hashTypedDataV4 is implemented according to EIP-712:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
    function _hashTypedDataV4(bytes32 structHash) internal view virtual returns (bytes32) {
        return ECDSAUpgradeable.toTypedDataHash(_domainSeparatorV4(), structHash);
    }

    function _domainSeparatorV4() internal view returns (bytes32) {
        return _buildDomainSeparator(_TYPE_HASH, _EIP712NameHash(), _EIP712VersionHash());
    }

    function _buildDomainSeparator(
        bytes32 typeHash,
        bytes32 nameHash,
        bytes32 versionHash
    ) private view returns (bytes32) {
        return keccak256(abi.encode(typeHash, nameHash, versionHash, block.chainid, address(this)));
    }

Here _EIP712NameHash and _EIP712VersionHash are configured when the contract is deployed.

Then the user signs the resulting digest with their private key:

1
2
3
4
5
    import { utils } from 'ethers'

    const userkey = new utils.SigningKey(privateKey)
        , sig = userkey.signDigest(digest)
        , sigs = utils.joinSignature(sig)

Verifying the Signature On-Chain

Signature validation depends on two checks:

  1. ecrecover The recovered signer from digest and signature must match the user’s address.

  2. nonce The nonce in ForwardRequest must match the nonce recorded in the contract.

Verification code:

1
2
3
4
5
6
7
    using ECDSAUpgradeable for bytes32;
    function verify(ForwardRequest calldata req, bytes calldata signature) public view returns (bool) {
        bytes32 digest = getDigest(req.from, req.to, req.value, req.nonce, req.data);
        address signer = digest.recover(signature);

        return _nonces[req.from] == req.nonce && signer == req.from;
    }

The msg.sender Problem

When the Forwarder contract eventually calls the target contract, msg.sender inside the target contract is the Forwarder, not the user. If the target contract needs to know the original user, you need an adjustment.

The usual trick is that the Forwarder appends the user’s address to the calldata it forwards. The called function in the target contract does not need to declare that extra parameter explicitly. The compiler has already fixed the parameter layout for the original function, so appending one more value at the end does not break decoding of the existing arguments.

Forwarder execution:

1
2
3
        (bool success, bytes memory returndata) = req.to.call{value: req.value}(
            abi.encodePacked(req.data, req.from)
        );

That passes the user address through to the target contract. The remaining question is how the target contract reads it.

First, look at OpenZeppelin’s Context contract:

1
2
3
4
5
6
7
8
9
abstract contract Context {
    function _msgSender() internal view virtual returns (address) {
        return msg.sender;
    }

    function _msgData() internal view virtual returns (bytes calldata) {
        return msg.data;
    }
}

This is a very fundamental base contract. When I first saw these two functions, they looked pointless. _msgSender() just returns msg.sender, so why not use msg.sender directly? In most cases, that is exactly what people do.

Meta transactions are where _msgSender() becomes useful. We can override _msgSender() so it returns the original user address, as long as the target contract knows which Forwarder is trusted.

In the target contract, the override looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    address private _trustedForwarder;
    
    function isTrustedForwarder(address forwarder) public view virtual returns (bool) {
        return forwarder == _trustedForwarder;
    }

    function _msgSender() internal view virtual override(ContextUpgradeable) returns (address sender) {
        if (isTrustedForwarder(msg.sender)) {
            // The assembly code is more direct than the Solidity version using `abi.decode`.
            assembly {
                sender := shr(96, calldataload(sub(calldatasize(), 20)))
            }
        } else {
            return msg.sender;
        }
    }

    function _msgData() internal view virtual override(ContextUpgradeable) returns (bytes calldata) {
        if (isTrustedForwarder(msg.sender)) {
            return msg.data[:msg.data.length - 20];
        } else {
            return msg.data;
        }
    }

With that change, the target contract supports forwarded calls. Because OpenZeppelin contracts generally use _msgSender() internally, inherited logic will also work with meta transactions once this override is in place.

There is another possible approach: the target contract could expose a function to temporarily set the user address, the Forwarder could call that setter before the real call, and then reset it afterward. That design is clearly less elegant. It also requires extra storage and therefore more gas.