Skip to main content

Trusted Forwarder (ERC-2771 Forwarder)

The trusted forwarder is the on-chain component that makes ERC-2771 work. It sits between the relayer and your recipient contract and enforces the core security properties:

  • The request was signed by the user (from)
  • The request is not replayed (nonce)
  • The request is still valid (deadline)
  • The recipient contract trusts this forwarder

After verification, the forwarder calls the target contract and appends the original sender address to calldata, allowing the recipient to recover it via ERC2771Context.

What the forwarder verifies

In OpenZeppelin’s ERC-2771 implementation, the request contains:

  • from: original user address
  • to: recipient contract address
  • value: native token value to forward (often 0)
  • gas: gas limit for the forwarded call
  • nonce: anti-replay counter for from
  • deadline: request expiry
  • data: encoded function call for the recipient

The forwarder verifies the signature over these fields using EIP-712.

Concrete request shape (typed data)

From the working Watr demo in this repo, the EIP-712 typed data ForwardRequest fields are:

export const FORWARD_REQUEST_TYPES = {
ForwardRequest: [
{ name: "from", type: "address" },
{ name: "to", type: "address" },
{ name: "value", type: "uint256" },
{ name: "gas", type: "uint256" },
{ name: "nonce", type: "uint256" },
{ name: "deadline", type: "uint48" },
{ name: "data", type: "bytes" }
]
};

How the “real sender” is propagated

When the forwarder calls the recipient, it uses:

  • The original call data (request.data)
  • Plus the original sender (request.from) appended to the end of calldata

On the recipient side, ERC2771Context only trusts and decodes this extra sender data when msg.sender is the configured trusted forwarder.

Repo-specific note: forwarder name must match EIP-712 domain

In this codebase, the forwarder is deployed as a thin wrapper around OpenZeppelin’s ERC2771Forwarder. The forwarder name is part of the EIP-712 domain, so the frontend/backends signing logic must use the same name as the deployed forwarder.

If the name does not match, signature verification will fail even if all other fields are correct.

Example from the reference relay script in watr-gasless-transactions/backend/scripts/relay.ts:

const domain = {
name: "MyForwarder",
version: "1",
chainId,
verifyingContract: forwarderAddress
};

See also