Function Selector & Calldata Parameter Scoping for Autonomous Web3 Agents

When configuring smart accounts for autonomous Web3 agents, many development teams assume that adding a spending rule is sufficient: “No more than 50 USDC per transaction and 200 USDC per day.”

If security stops at monitoring native-token msg.value or simple transfer amounts, an attacker needs only a small prompt injection to compromise the entire treasury through this instruction:

// A destructive call an attacker induces the AI agent to make
IERC20(usdc).approve(attackerAddress, type(uint256).max);

What does a coarse-grained risk module see in this transaction?

  • The transaction’s native ETH transfer amount, msg.value, is 0.
  • The wallet’s token balance decreases by 0 USDC after execution.
  • The risk logic decides: “Transaction spending is 0 ≤ 50 USDC. Allow.”

Yet a second after confirmation, the attacker can call transferFrom and use that valid approval to drain tens of thousands of dollars of liquidity from the smart account.

This combination of zero transferred value and unlimited approval exposes a central problem in agent security: autonomous AI agents are threatened by unrestricted contract interactions as well as excessive transfers. Without static slicing and deterministic assertions over each transaction’s function selector, target contract, and calldata parameters at the EVM bytecode level, amount-based guardrails offer little protection against malicious protocols.

Using the open-source, production-grade architecture of Agent Wallet Guard, this article explains how to implement static calldata parameter slicing for transfer and approve in Solidity, prohibit unlimited approvals, and enforce separate allowlists for target contracts and recipients.


Threat Model: Why Coarse-Grained Spending Limits Fail

Function Selector & Calldata Parameter Scoping for Autonomous Web3 Agents

In conventional Web3 interactions, a MetaMask prompt presents human-readable information for a person to review. Autonomous agents operate unattended and make high-frequency calls, exposing a more complex calldata attack surface than ordinary wallets:

+-----------------------------------------------------------------------------------+
|                         Web3 AI Agent Calldata Attack Surface                      |
+-----------------------------------------------------------------------------------+
|  [Attack vector 1: Infinite Approve Trap]                                           |
|  Agent calls IERC20.approve(spender, 2^256 - 1)                                     |
|  -> Zero value transferred; bypasses every perTxLimit check; treasury control is lost permanently |
|  -------------------------------------------------------------------------------- |
|  [Attack vector 2: Target Contract Redirection]                                     |
|  Agent intends to use Uniswap but is induced to call an attacker's fake router with the same name |
|  -> Unless the target address is locked, the agent's signature authorizes theft     |
|  -------------------------------------------------------------------------------- |
|  [Attack vector 3: Phishing Recipient Injection]                                    |
|  Agent calls transfer(to, amount) on the legitimate USDC contract                    |
|  -> Amount stays within 10 USDC, but to is the attacker's collection address; funds leak continuously |
|  -------------------------------------------------------------------------------- |
|  [Attack vector 4: Calldata Padding Attack]                                         |
|  Append dirty bytes after valid 68-byte transfer arguments to exploit permissive decoding in older contracts |
+-----------------------------------------------------------------------------------+

Preventing all these risks requires precise calldata parameter inspection during both off-chain gateway prechecks and on-chain contract execution.


Comparing Three Levels of Agent Protection

Protection Native-value monitoring only (msg.value) Coarse token transfer limits Agent Wallet Guard deep calldata slicing
ERC-20 transfer amounts Invisible (EVM token transfers have value 0) Detects only per-transaction and daily amounts Precisely limits raw units per transaction and accumulated daily budget
Unlimited approval attacks Uncontrolled (allowed) Uncontrolled (approve transfers 0) Prohibits type(uint256).max; approvals strictly follow limits
Target contract allowlist Unsupported Limited to the token contract itself Requires target to belong to a static allowlist
Recipient tampering Unsupported Global restrictions only, without agent isolation Unpacks the calldata address offset and checks _allowedRecipients
Function selector restrictions Unsupported Cannot distinguish functions Extracts the first 4 bytes and permits only predefined functions
Malformed padding Unsupported Unsupported Requires calldata to be exactly 68 bytes

Core Implementation: Low-Level Solidity Parameter Slicing (PaymentDecoder.sol)

The agent-wallet-guard/contracts/src/libraries/PaymentDecoder.sol library applies strict static binary parsing to incoming calldata, without relying on permissive high-level ABI decoders.

1. Separating the Outer Call from the Inner Calldata

In ERC-4337 smart accounts, UserOperation.callData first wraps the smart account’s execution entry point, such as execute(address dest, uint256 value, bytes calldata func). The system strips that wrapper to obtain the actual inner target and payload:

// From agent-wallet-guard/contracts/src/AgentWalletGuard.sol
    function executeValidatedUserOp(PackedUserOperation calldata userOp, bytes32 userOpHash) external nonReentrant {
        // 1) Strictly validate and unwrap the outer call shape
        (bool shapeOk, address target, uint256 value, bytes calldata innerCallData) =
            _decodeOuterCallShape(userOp.callData);
        if (!shapeOk) revert InvalidOuterCallShape();

        // 2) Delegate detailed parameter parsing to PaymentDecoder
        PaymentDecoder.Decoded memory decoded =
            PaymentDecoder.decode(configuredUsdc, bytes32(uint256(0)), target, value, innerCallData);

        // ... Subsequent policy validation
    }

2. Extracting the Selector and Enforcing the 68-Byte Length

Standard ERC-20 transfer(address,uint256) and approve(address,uint256) calls have a strict ABI layout:

  • First 4 bytes: the function selector (bytes4 selector).
  • Bytes 4 through 36 (32 bytes): the to or spender address, aligned to 32 bytes with 12 leading zero bytes and the actual 20-byte address at the end.
  • Bytes 36 through 68 (32 bytes): the transfer or approval amount, an unsigned uint256 integer.

If the input is not exactly 4 + 32 + 32 = 68 bytes, it may contain injected attack data or have been maliciously truncated, and must revert at the low level:

// From agent-wallet-guard/contracts/src/libraries/PaymentDecoder.sol
library PaymentDecoder {
    // Standard ERC-20 function selector constants
    bytes4 internal constant TRANSFER_SELECTOR = 0xa9059cbb; // transfer(address,uint256)
    bytes4 internal constant APPROVE_SELECTOR  = 0x095ea7b3; // approve(address,uint256)

    struct Decoded {
        bytes4 selector;
        address recipient;
        uint256 amount;
        bytes32 calldataHash;
    }

    error InvalidCalldataLength(uint256 actual, uint256 expected);
    error UnsupportedSelector(bytes4 selector);
    error InvalidApprovalAmount(uint256 amount);
    error TargetNotAllowed(address target);

    function decode(
        address expectedToken,
        bytes32,
        address target,
        uint256 value,
        bytes calldata data
    ) internal pure returns (Decoded memory result) {
        // 1. Enforce target: only the managed token contract, with native value equal to 0
        if (target != expectedToken || value != 0) {
            revert TargetNotAllowed(target);
        }

        // 2. Enforce byte length: 4 (selector) + 32 (address) + 32 (uint256) = 68
        if (data.length != 68) {
            revert InvalidCalldataLength(data.length, 68);
        }

        // 3. Extract the first 4 bytes as the function selector
        bytes4 selector = bytes4(data[:4]);
        if (selector != TRANSFER_SELECTOR && selector != APPROVE_SELECTOR) {
            revert UnsupportedSelector(selector);
        }

        // 4. Slice out the recipient address (skip the first 12 zero-padding bytes)
        address recipient = address(uint160(uint256(bytes32(data[4:36]))));

        // 5. Slice out the amount
        uint256 amount = uint256(bytes32(data[36:68]));

        // 6. Core defense: prohibit unlimited approval (type(uint256).max)!
        if (selector == APPROVE_SELECTOR) {
            if (amount == type(uint256).max || amount == 0) {
                revert InvalidApprovalAmount(amount);
            }
        }

        return Decoded({
            selector: selector,
            recipient: recipient,
            amount: amount,
            calldataHash: keccak256(data)
        });
    }
}

3. Enforcing Recipient and Policy Allowlists (AgentWalletGuard.sol)

After PaymentDecoder extracts recipient and amount, control returns to AgentWalletGuard. The contract requires the recipient to belong to the explicitly authorized set for that policy:

// From agent-wallet-guard/contracts/src/AgentWalletGuard.sol
        // 5) Recipient allowlist: revert for any address outside the allowlist
        if (!_allowedRecipients[policyId][decoded.recipient]) {
            revert RecipientNotAllowed(decoded.recipient);
        }

        // 6) Generate a consistent intent hash to bind the approval record
        bytes32 intentHash = keccak256(
            abi.encode(
                msg.sender,
                agentId,
                policyId,
                decoded.recipient,
                decoded.amount,
                userOp.nonce,
                decoded.calldataHash
            )
        );

        // 7) Deduct budget: check per-transaction and daily limits ...

Whether the agent calls transfer or a restricted approve, the transaction is blocked on-chain if recipient is absent from _allowedRecipients[policyId] or the amount exceeds the per-transaction budget.


Off-Chain Control Plane: Early Filtering in the TypeScript Policy Gateway

Contract-level interception ultimately protects assets, but waiting for every unauthorized call to revert on-chain wastes paymaster gas subsidies. Before asking the bundler to broadcast, the API gateway (agent-wallet-guard/api) must perform zero-cost static prechecks under exactly the same rules.

In agent-wallet-guard/api/src/api/v1/resources/intents.ts, the policy checker immediately checks the target, selector, and amount against multiple allowlists when it receives an intent:

// From agent-wallet-guard/api/src/api/v1/resources/intents.ts
function checkPolicy(
  policyRecord: PolicyRecord | null,
  input: PolicyCheckInput,
): { reasons: DecisionReason[] } {
  const reasons: DecisionReason[] = [];
  if (!policyRecord) return { reasons };

  // 1. Enforce chain ID consistency
  if (policyRecord.chainId !== input.chainId) {
    reasons.push({
      code: INTENT_REASON.CHAIN_NOT_ALLOWED,
      message: `Policy chain ${policyRecord.chainId} ≠ wallet chain ${input.chainId}`,
      field: 'target',
    });
  }

  // 2. Match the target contract allowlist
  const allowlist = policyRecord.version.allowlist;
  const targetMatch = allowlist.find(
    (entry) => entry.kind === ALLOWLIST_ENTRY_KIND.ADDRESS && 
               entry.value.toLowerCase() === input.target.toLowerCase()
  );
  if (!targetMatch) {
    reasons.push({
      code: INTENT_REASON.TARGET_NOT_ALLOWED,
      message: 'Target contract is not in allowlist',
      field: 'target',
    });
  }

  // 3. Match the function selector allowlist (for example, 0xa9059cbb)
  const selectorMatch = allowlist.find(
    (entry) => entry.kind === ALLOWLIST_ENTRY_KIND.SELECTOR && 
               entry.value.toLowerCase() === input.selector.toLowerCase()
  );
  if (!selectorMatch) {
    reasons.push({
      code: INTENT_REASON.SELECTOR_NOT_ALLOWED,
      message: 'Function selector is not permitted by policy',
      field: 'selector',
    });
  }

  // 4. Check per-transaction limits with BigInt integers (never floating point)
  if (input.valueRaw && policyRecord.version.limits.perTx.raw) {
    const value = BigInt(input.valueRaw);
    const limit = BigInt(policyRecord.version.limits.perTx.raw);
    if (value > limit) {
      reasons.push({
        code: INTENT_REASON.PER_TX_LIMIT_EXCEEDED,
        message: 'Raw value exceeds single-transaction limit',
        field: 'value',
      });
    }
  }

  return { reasons };
}

Any selector outside the allowlist, such as an attacker’s transferFrom, setApprovalForAll, or custom administrative function, is marked DENIED and rejected by the gateway in under 1 millisecond, without spending even 1 wei of on-chain gas.


Verification with Foundry Tests

Agent Wallet Guard includes comprehensive Foundry tests (agent-wallet-guard/contracts/test/AgentWalletGuard.t.sol) to verify parameter slicing and allowlists under extreme boundary conditions.

1. Forcing an Unlimited Approval Attack to Revert

The test asserts that even with a valid recipient, an agent’s attempt to approve type(uint256).max immediately raises InvalidApprovalAmount:

    function test_RevertWhen_AgentAttemptsInfiniteApproval() public {
        vm.prank(agentSessionKey);
        bytes memory maliciousCall = abi.encodeWithSelector(
            IERC20.approve.selector,
            allowedMerchant,
            type(uint256).max // Attempt to grant unlimited allowance
        );

        // Assert that the contract raises InvalidApprovalAmount
        vm.expectRevert(
            abi.encodeWithSelector(PaymentDecoder.InvalidApprovalAmount.selector, type(uint256).max)
        );
        guard.executeValidatedUserOp(packUserOp(maliciousCall), userOpHash);
    }

2. Blocking a Recipient Outside the Allowlist

The test asserts that even a transfer of just 1 USDC, well below the 50 USDC per-transaction limit, must raise RecipientNotAllowed if the recipient is not registered:

    function test_RevertWhen_RecipientNotInAllowlist() public {
        address unverifiedHacker = address(0xBAD);
        vm.prank(agentSessionKey);
        bytes memory transferCall = abi.encodeWithSelector(
            IERC20.transfer.selector,
            unverifiedHacker,
            1e6 // 1 USDC
        );

        vm.expectRevert(
            abi.encodeWithSelector(AgentWalletGuard.RecipientNotAllowed.selector, unverifiedHacker)
        );
        guard.executeValidatedUserOp(packUserOp(transferCall), userOpHash);
    }

3. Defending Against Malformed Calldata Injection

The test asserts that appending 32 dirty bytes to a valid 68-byte payload, making it 100 bytes long, causes the contract to revert during the length check:

    function test_RevertWhen_CalldataHasDirtyPadding() public {
        bytes memory validCall = abi.encodeWithSelector(IERC20.transfer.selector, allowedMerchant, 1e6);
        bytes memory dirtyCall = bytes.concat(validCall, hex"deadbeef1234"); // Append dirty bytes

        vm.expectRevert(
            abi.encodeWithSelector(PaymentDecoder.InvalidCalldataLength.selector, dirtyCall.length, 68)
        );
        guard.executeValidatedUserOp(packUserOp(dirtyCall), userOpHash);
    }

Four Production Rules for AI Agent Parameter Controls

If you are designing permissions for autonomous trading bots, AI operations agents, or enterprise finance agents, establish these four requirements during architecture review:

  1. Never use msg.value as the sole security metric: in Ethereum and the EVM ecosystem, destructive exploits such as approval phishing often transfer exactly 0 native value.
  2. Enforce fixed-length slicing in the contract: for high-frequency token operations, require the standard 68-byte calldata length and extract arguments through low-level memory pointers or slices to prevent data confusion and padding exploits.
  3. Prohibit unlimited agent approvals: strictly monitor the approve selector in the smart contract. An amount equal to type(uint256).max or above the per-transaction limit must revert.
  4. Independently restrict target contracts and recipients: agents may call only verified token contracts on the allowlist, and token recipients must pass a static allowlist or one-way registration process, closing routes to attacker-controlled collection addresses.

Enterprise AI Agent Account Security Delivery

Fone provides full-stack solutions for enterprise autonomous agents, decentralized AI workflows, and cross-chain finance bots, spanning cryptographic key management, ERC-4337 smart accounts, and calldata parameter-control gateways. Our Agent Wallet Guard has passed rigorous production-grade test suites and is ready to use: