Skip to content

MinimalForwarder Contract Reference

The MinimalForwarder is an ERC-2771 trusted forwarder that enables gasless meta-transactions for the Agent Registry. It verifies EIP-712 signatures from agents and creators, then forwards their calls to the registry contract while appending the real sender address.

Source: contracts/MinimalForwarder.sol Solidity: 0.8.24 (optimizer: 200 runs) License: MIT


Overview

The MinimalForwarder sits between agents (who hold no ETH) and the Agent Registry contract:

Agent (no ETH)          Relayer (has ETH)          MinimalForwarder          Agentenregister
     |                        |                           |                       |
     | 1. Sign EIP-712        |                           |                       |
     |----------------------->|                           |                       |
     |                        | 2. execute(req, sig)      |                       |
     |                        |-------------------------->|                       |
     |                        |                           | 3. Verify signature   |
     |                        |                           | 4. Check nonce        |
     |                        |                           | 5. call(data + from)  |
     |                        |                           |---------------------->|
     |                        |                           |   _msgSender() = agent|
     |                        |                           |<----------------------|
     |                        | 6. Return result          |                       |
     |                        |<--------------------------|                       |

The agent never touches ETH. The relayer pays gas. The Agent Registry sees the agent's real address as the sender via _msgSender().


EIP-712 Domain

The MinimalForwarder uses EIP-712 typed data for signature verification with the following domain:

Parameter Value
name "MinimalForwarder"
version "1"
chainId Set at deployment (e.g. 84532 for Base Sepolia)
verifyingContract The MinimalForwarder contract address

The domain separator is computed at construction time and cached as an immutable value. It is recalculated on-the-fly if the chain ID changes (fork protection).


Structs

ForwardRequest

The core request struct that agents sign off-chain.

struct ForwardRequest {
    address from;       // Real signer (agent or creator)
    address to;         // Target contract (Agentenregister)
    uint256 value;      // ETH to forward (usually 0)
    uint256 gas;        // Gas limit for the inner call
    uint256 nonce;      // Replay protection (auto-incrementing)
    uint48  deadline;   // Expiry timestamp (0 = no expiry)
    bytes   data;       // Encoded function call
}
Field Type Description
from address The real signer. Must match the recovered signer from the EIP-712 signature
to address Target contract. The relayer typically restricts this to the Agent Registry
value uint256 ETH to forward with the call. Usually 0 for registry operations
gas uint256 Gas limit for the inner call (e.g. 800000 for registration)
nonce uint256 Must equal the current nonce for the from address. Incremented after each execution
deadline uint48 Unix timestamp after which the request is invalid. 0 means no expiry
data bytes ABI-encoded function call data for the target contract

EIP-712 Type Hash

bytes32 private constant _TYPEHASH = keccak256(
    "ForwardRequest(address from,address to,uint256 value,uint256 gas,uint256 nonce,uint48 deadline,bytes data)"
);

Functions

getNonce

function getNonce(address from) external view returns (uint256)

Get the current nonce for a signer address. This value must be used in the next ForwardRequest for that signer.

Parameters:

Parameter Type Description
from address The signer's address

Returns: The current nonce (starts at 0, increments by 1 after each successful execution).


domainSeparator

function domainSeparator() external view returns (bytes32)

Get the EIP-712 domain separator. Recalculated if the chain ID has changed (fork protection).

Returns: The bytes32 domain separator hash.


verify

function verify(ForwardRequest calldata req, bytes calldata signature)
    public view returns (bool)

Verify that a signature is valid for a given forward request. Checks:

  1. Deadline: If req.deadline != 0, the current block timestamp must not exceed the deadline
  2. Nonce: req.nonce must equal the stored nonce for req.from
  3. Signature: The EIP-712 signature must recover to req.from

Parameters:

Parameter Type Description
req ForwardRequest The forward request to verify
signature bytes 65-byte EIP-712 signature (r + s + v)

Returns: true if the signature is valid, nonce matches, and deadline has not passed.


execute

function execute(ForwardRequest calldata req, bytes calldata signature)
    external payable returns (bool success, bytes memory returndata)

Execute a meta-transaction: verify the signature, increment the nonce, and forward the call to the target contract.

Parameters:

Parameter Type Description
req ForwardRequest The forward request to execute
signature bytes 65-byte EIP-712 signature

Returns:

Return Type Description
success bool Whether the inner call succeeded
returndata bytes Return data from the inner call

Reverts if:

  • Signature verification fails ("MinimalForwarder: invalid signature")
  • The inner call reverts (the revert reason is bubbled up)

ERC-2771 Sender Appending

The execute function appends the real sender address (req.from) to the calldata before calling the target contract:

req.to.call{gas: req.gas, value: req.value}(
    abi.encodePacked(req.data, req.from)
);
The target contract's _msgSender() function reads the last 20 bytes of calldata to extract the real sender.


Events

Forwarded

event Forwarded(
    address indexed from,    // The real signer
    address indexed to,      // The target contract
    uint256 nonce,           // The nonce used
    bool success             // Whether the inner call succeeded
);

Emitted after every execute() call, regardless of whether the inner call succeeded.


Security Properties

Replay Protection

Each signer has an auto-incrementing nonce. After a successful execute(), the nonce is incremented:

_nonces[req.from] = req.nonce + 1;

A request with a stale nonce (already used) will fail the verify() check.

Deadline Support

Requests can include a deadline timestamp. If req.deadline != 0 and block.timestamp > req.deadline, the request is rejected. Setting deadline = 0 means the request never expires.

Signature Verification

The contract uses EIP-712 typed data hashing and ecrecover to verify signatures:

  1. The struct hash is computed from the request fields (with keccak256(req.data) for the bytes field)
  2. The digest is computed as keccak256("\x19\x01" || domainSeparator || structHash)
  3. ecrecover(digest, v, r, s) must return req.from

Revert Reason Bubbling

If the inner call to the target contract fails, the revert reason is bubbled up using inline assembly:

if (!success) {
    assembly {
        revert(add(returndata, 32), mload(returndata))
    }
}

This means callers see the original revert reason from the Agentenregister contract (e.g. "Agent not found", "Only owner").

Signature Format

Signatures must be exactly 65 bytes in the standard r || s || v format:

Bytes Content
0-31 r component
32-63 s component
64 v component (27 or 28)

Receive Function

The contract includes a receive() function to accept ETH, enabling value-forwarding use cases (though the Agent Registry does not use this).

receive() external payable {}

Gas Recommendations

Operation Recommended gas Field
registerAgent 800,000
attestCompliance 200,000
reportRevenue 300,000
updateCapability 200,000
updateConstitution 200,000
terminateSelf 200,000

Gas Buffer

The relayer adds a buffer (typically +100,000) on top of the gas field to account for the forwarder's own overhead when calling execute().