Smart Contracts & EVM

Smart Contract Interaction for AI Agents:
From ABI to Execution

March 4, 2026 24 min read JavaScript, Python, Solidity

For an AI agent to interact autonomously with the Ethereum ecosystem, it must understand smart contracts from first principles: how the ABI encodes function calls, how to decode event logs, how to estimate gas accurately, and how to simulate transactions before they touch the blockchain. This guide walks through every layer, from raw hex calldata to complete agent implementations using ethers.js and web3.py through the Purple Flea Wallet API.

ABI Fundamentals: Types, Encoding, and Selectors

The Application Binary Interface (ABI) is the specification for how smart contract functions and events are encoded into raw bytes for the EVM. Every call to a smart contract starts with encoding the function selector (4 bytes) followed by the ABI-encoded arguments. Understanding this encoding is essential for agents that need to construct or decode contract interactions without relying on high-level libraries.

Function Selector

The function selector is the first 4 bytes of the Keccak-256 hash of the function's canonical signature. This deterministic mapping allows the EVM to dispatch incoming calldata to the correct function handler.

Function signature: transfer(address,uint256) Keccak256 hash: 0xa9059cbb2ab09eb219583f4a59a5d0623ade346d962bcd4e46b11da047c9049b Function selector (first 4 bytes): 0xa9059cbb Full calldata for transfer(0xRecipient, 1000): 0xa9059cbb ← selector 000000000000000000000000[recipient_address_20b] ← address (32 bytes, left-padded) 00000000000000000000000000000000000000000003e8 ← uint256(1000) Total: 4 + 32 + 32 = 68 bytes

ABI Type System

Core ABI Types and Encoding Rules
uint256 integers 32 bytes, big-endian, unsigned. uintN where N is multiple of 8 (8..256)
int256 signed ints 32 bytes, two's complement. intN (8..256)
address address 20 bytes, left-padded to 32 bytes
bool boolean 32 bytes: 0x00..01 or 0x00..00
bytes32 fixed bytes bytesN (1..32): right-padded static byte arrays
bytes dynamic bytes Dynamic: offset (32B) + length (32B) + data (padded to 32B boundary)
string string UTF-8 encoded, same encoding as bytes (dynamic)
tuple struct Concatenated encoding of member types. Static if all members static.
T[] array Dynamic: offset (32B) + length (32B) + encoded elements

Canonical Signature Rules

Rules for canonical type names in selectors: - No spaces - uint is alias for uint256 (use uint256) - int is alias for int256 (use int256) - tuple(T1,T2,...) for structs - T[k] for fixed arrays, T[] for dynamic arrays Examples: approve(address,uint256) → 0x095ea7b3 swapExactTokensForTokens(uint256,uint256,address[],address,uint256) → 0x38ed1739 multicall(bytes[]) → 0xac9650d8
Encoding Type

Static Types

uint, int, bool, address, bytesN, fixed-size arrays. Encoded inline at fixed position.

Encoding Type

Dynamic Types

bytes, string, T[], tuple with dynamic members. Encoded with an offset pointer to tail section.

Pattern

Head/Tail Encoding

Head contains static values and offset pointers. Tail contains dynamic data. Concatenated as calldata.

Return Data

Return ABI Decode

Return data is ABI-decoded using the function's output types from the ABI JSON. Reverts return no data.

Making Contract Function Calls

For AI agents, there are two categories of contract interactions: read calls (eth_call, no gas, no state change) and write transactions (eth_sendRawTransaction, costs gas, modifies state). Agents should always read first, simulate the write, then submit the actual transaction.

contract_agent.js JavaScript
const { ethers } = require("ethers");

// Purple Flea Wallet API provides a JSON-RPC compatible endpoint
const WALLET_API_RPC = "https://purpleflea.com/wallet-api/v1/rpc";

// ERC-20 ABI (minimal subset for agents)
const ERC20_ABI = [
  "function balanceOf(address) view returns (uint256)",
  "function decimals() view returns (uint8)",
  "function symbol() view returns (string)",
  "function transfer(address to, uint256 amount) returns (bool)",
  "function approve(address spender, uint256 amount) returns (bool)",
  "function allowance(address owner, address spender) view returns (uint256)",
  "event Transfer(address indexed from, address indexed to, uint256 value)",
  "event Approval(address indexed owner, address indexed spender, uint256 value)"
];

class SmartContractAgent {
  constructor(walletApiKey, privateKey) {
    this.provider = new ethers.JsonRpcProvider(
      WALLET_API_RPC,
      undefined,
      { staticNetwork: true }
    );
    this.signer = new ethers.Wallet(privateKey, this.provider);
    this.apiKey = walletApiKey;
  }

  async readTokenInfo(tokenAddress) {
    const token = new ethers.Contract(
      tokenAddress, ERC20_ABI, this.provider
    );
    const [symbol, decimals, balance] = await Promise.all([
      token.symbol(),
      token.decimals(),
      token.balanceOf(await this.signer.getAddress()),
    ]);
    return {
      symbol, decimals: Number(decimals),
      balance: ethers.formatUnits(balance, decimals),
      balanceRaw: balance
    };
  }

  async approveAndTransfer(
    tokenAddress, spender, recipient, amount
  ) {
    const token = new ethers.Contract(
      tokenAddress, ERC20_ABI, this.signer
    );

    // 1. Check allowance first (read — no gas)
    const agentAddr = await this.signer.getAddress();
    const allowance = await token.allowance(agentAddr, spender);

    if (allowance < amount) {
      // 2. Estimate gas for approve
      const approveGas = await token.approve.estimateGas(spender, amount);
      const feeData = await this.provider.getFeeData();

      // 3. Send approve transaction
      const approveTx = await token.approve(spender, amount, {
        gasLimit: approveGas * 110n / 100n,  // 10% buffer
        maxFeePerGas: feeData.maxFeePerGas,
        maxPriorityFeePerGas: feeData.maxPriorityFeePerGas
      });
      await approveTx.wait(1);
      console.log(`Approved: ${approveTx.hash}`);
    }

    // 4. Simulate transfer before sending
    try {
      await token.transfer.staticCall(recipient, amount);
    } catch (e) {
      throw new Error(`Transfer simulation failed: ${e.message}`);
    }

    // 5. Send the actual transfer
    const transferTx = await token.transfer(recipient, amount);
    const receipt = await transferTx.wait(1);
    console.log(`Transferred! Tx: ${receipt.hash}`);
    return receipt;
  }
}

// Usage
const agent = new SmartContractAgent(
  process.env.PF_API_KEY,
  process.env.AGENT_PRIVATE_KEY
);
const info = await agent.readTokenInfo("0xA0b8...");  // USDC
console.log(info.symbol, info.balance);  // → USDC 1250.00

Event Parsing and Log Decoding

Smart contract events are the primary mechanism for observing on-chain state changes without polling every block. Events are stored in transaction receipts as logs — each log has an address, up to 4 indexed topics (32 bytes each), and arbitrary non-indexed data. The first topic is always the event's Keccak-256 signature hash (the "event selector").

Event Selector Calculation

Transfer(address indexed from, address indexed to, uint256 value) Canonical signature (no `indexed` keyword): Transfer(address,address,uint256) Keccak256("Transfer(address,address,uint256)"): 0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef Log structure: topics[0]: 0xddf252ad... ← event selector topics[1]: 0x000...from ← indexed address from topics[2]: 0x000...to ← indexed address to data: 0x000...value ← non-indexed uint256
event_listener.js JavaScript
const { ethers } = require("ethers");

class ContractEventMonitor {
  constructor(provider, contractAddress, abi) {
    this.contract = new ethers.Contract(contractAddress, abi, provider);
    this.iface = new ethers.Interface(abi);
    this.handlers = new Map();
  }

  on(eventName, handler) {
    this.contract.on(eventName, (...args) => {
      const event = args[args.length - 1];  // last arg is EventLog
      handler({
        blockNumber: event.blockNumber,
        txHash: event.transactionHash,
        args: event.args
      });
    });
    return this;
  }

  async getHistoricalLogs(eventName, fromBlock, toBlock = "latest") {
    const filter = this.contract.filters[eventName]();
    const logs = await this.contract.queryFilter(filter, fromBlock, toBlock);
    return logs.map(log => ({
      block: log.blockNumber,
      tx: log.transactionHash,
      args: log.args.toObject(),
    }));
  }

  async decodeArbitraryLog(log) {
    // Useful for decoding logs from transactions that call
    // multiple contracts (e.g., Uniswap swaps emit token Transfer logs)
    try {
      return this.iface.parseLog({
        topics: log.topics,
        data: log.data
      });
    } catch {
      return null;  // unknown event
    }
  }
}

// Monitor USDC transfers to agent's address
const monitor = new ContractEventMonitor(provider, USDC_ADDRESS, ERC20_ABI);
monitor.on("Transfer", (event) => {
  const { from, to, value } = event.args;
  if (to.toLowerCase() === agentAddress.toLowerCase()) {
    console.log(`Received ${ethers.formatUnits(value, 6)} USDC from ${from}`);
  }
});

Gas Estimation and EIP-1559

EIP-1559 (introduced in the London hard fork) replaced the simple gas price model with a two-component fee structure: a base fee (burned, set by the protocol) and a priority fee (tip to the validator). For AI agents, this means gas fee estimation requires forecasting the next base fee and choosing an appropriate tip.

EIP-1559 Transaction Fields: maxFeePerGas = max total fee per gas unit (Wei) maxPriorityFeePerGas = tip to validator (Wei) Actual fee paid per gas: min(maxFeePerGas, baseFee + maxPriorityFeePerGas) Base fee adjustment (each block): baseFee_new = baseFee_old × (1 + 0.125 × (gasUsed - targetGas) / targetGas) Max change per block: ±12.5% Total transaction cost: cost = gasUsed × (baseFee + priorityFee) Agent strategy: set maxFeePerGas = 2 × current baseFee → transaction valid for ~11 blocks even if base fee rises 12.5% per block
21,000
ETH transfer gas
~46,000
ERC-20 transfer gas
~150,000
Uniswap swap gas
~400,000
Complex DeFi tx gas
RPC MethodPurposeGas Cost
eth_estimateGasEstimate gas for a transactionFree (read)
eth_callSimulate transaction (no state change)Free (read)
eth_gasPriceLegacy gas price (pre-1559)Free (read)
eth_feeHistoryHistorical base fees + priority feesFree (read)
eth_sendRawTransactionBroadcast signed transactionGas required

Transaction Simulation with eth_call

Transaction simulation (eth_call) allows an agent to execute a transaction against the current state without broadcasting it to the network. This is the most important safety mechanism available to smart contract agents. If a simulation fails, the broadcast would also fail — and the agent would lose gas. Always simulate before sending.

Simulation Limitations

Simulations reflect the blockchain state at the moment of the eth_call. Between simulation and broadcast, another transaction may change state (e.g., allowance consumed by a competing transaction, liquidity removed from a pool). For time-sensitive operations, keep simulation-to-broadcast latency under 1 second, and set tight slippage guards in the transaction itself.

contract_agent.py Python
import os
from web3 import Web3
from web3.middleware import SignAndSendRawMiddlewareBuilder
from eth_account import Account
import json

# Connect via Purple Flea Wallet API JSON-RPC endpoint
w3 = Web3(Web3.HTTPProvider(
    "https://purpleflea.com/wallet-api/v1/rpc",
    request_kwargs={
        "headers": {"Authorization": f"Bearer {os.getenv('PF_API_KEY')}"}
    }
))

ERC20_ABI = json.loads("""[
  {"inputs":[{"type":"address"}],"name":"balanceOf","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"},
  {"inputs":[{"type":"address"},{"type":"uint256"}],"name":"transfer","outputs":[{"type":"bool"}],"stateMutability":"nonpayable","type":"function"},
  {"inputs":[{"type":"address"},{"type":"uint256"}],"name":"approve","outputs":[{"type":"bool"}],"stateMutability":"nonpayable","type":"function"},
  {"inputs":[{"type":"address"},{"type":"address"}],"name":"allowance","outputs":[{"type":"uint256"}],"stateMutability":"view","type":"function"}
]""")


class ContractAgentPy:
    """web3.py smart contract interaction agent."""

    def __init__(self, private_key: str):
        self.acct = w3.eth.account.from_key(private_key)
        w3.middleware_onion.inject(
            SignAndSendRawMiddlewareBuilder.build(self.acct),
            layer=0
        )

    def _get_contract(self, address: str):
        return w3.eth.contract(
            address=Web3.to_checksum_address(address),
            abi=ERC20_ABI
        )

    def simulate_transfer(self, token_addr: str,
                           recipient: str,
                           amount_wei: int) -> dict:
        """
        Simulate a token transfer without broadcasting.
        Returns gas estimate and simulation success/failure.
        """
        token = self._get_contract(token_addr)
        call = token.functions.transfer(
            Web3.to_checksum_address(recipient),
            amount_wei
        )

        # 1. eth_call simulation
        try:
            result = call.call({"from": self.acct.address})
            sim_ok = bool(result)
        except Exception as e:
            return {"ok": False, "error": str(e)}

        # 2. gas estimate
        gas_est = call.estimate_gas({"from": self.acct.address})

        # 3. EIP-1559 fee data
        latest = w3.eth.get_block("latest")
        base_fee = latest["baseFeePerGas"]
        priority_fee = w3.eth.max_priority_fee
        max_fee = 2 * base_fee + priority_fee

        return {
            "ok": sim_ok,
            "gas_estimate": gas_est,
            "gas_limit": int(gas_est * 1.15),  # 15% buffer
            "base_fee_gwei": w3.from_wei(base_fee, "gwei"),
            "max_fee_gwei": w3.from_wei(max_fee, "gwei"),
            "cost_eth": w3.from_wei(gas_est * max_fee, "ether"),
        }

    def send_transfer(self, token_addr: str,
                        recipient: str,
                        amount_wei: int) -> str:
        """
        Safe transfer: simulate first, then broadcast if simulation passes.
        Returns transaction hash.
        """
        # 1. Simulate
        sim = self.simulate_transfer(token_addr, recipient, amount_wei)
        if not sim["ok"]:
            raise RuntimeError(f"Simulation failed: {sim.get('error')}")

        print(f"Simulation OK | gas: {sim['gas_estimate']} | "
              f"cost: {sim['cost_eth']:.6f} ETH")

        # 2. Build and sign tx
        token = self._get_contract(token_addr)
        latest = w3.eth.get_block("latest")
        base_fee = latest["baseFeePerGas"]
        prio_fee = w3.eth.max_priority_fee

        tx_hash = token.functions.transfer(
            Web3.to_checksum_address(recipient),
            amount_wei
        ).transact({
            "from": self.acct.address,
            "gas": sim["gas_limit"],
            "maxFeePerGas": 2 * base_fee + prio_fee,
            "maxPriorityFeePerGas": prio_fee,
        })
        print(f"Broadcast: 0x{tx_hash.hex()}")

        # 3. Wait for confirmation
        receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
        if receipt["status"] != 1:
            raise RuntimeError(f"Tx reverted: 0x{tx_hash.hex()}")
        return f"0x{tx_hash.hex()}"


# Example
agent = ContractAgentPy(os.getenv("PRIVATE_KEY"))
sim = agent.simulate_transfer(
    "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",   # USDC
    "0xRecipientAddress",
    int(100e6)                                        # 100 USDC (6 decimals)
)
print(f"OK={sim['ok']} gas={sim['gas_estimate']} cost={sim['cost_eth']:.6f}ETH")

Complete Agent Code Examples

Combining all the concepts above, the following is a production-ready agent scaffold that handles contract interaction, event monitoring, gas management, and retry logic — all integrated with the Purple Flea Wallet API.

Purple Flea Wallet API Integration

The Purple Flea Wallet API at purpleflea.com/wallet-api provides a JSON-RPC compatible endpoint that proxies to multiple EVM chains. This means agents can use the standard ethers.js or web3.py interfaces while benefiting from Purple Flea's transaction monitoring, nonce management, and gasless meta-transaction support.

Wallet API FeatureEndpointDescription
JSON-RPC Proxy/v1/rpcFull EVM RPC compatibility. Drop-in for Infura/Alchemy.
Nonce Management/v1/nonce/{address}Atomic nonce allocation — prevents stuck transactions in multi-agent scenarios.
Gas Oracle/v1/gas-oracleReturns recommended gas settings for fast/normal/slow confirmation.
Tx Monitor/v1/tx/{hash}/statusTrack transaction confirmation status with replacement detection.
Batch Calls/v1/batch-callExecute multiple eth_call in a single HTTP request.
Backup Export/v1/exportEncrypted seed export for backup and recovery drills.
Getting Started with the Wallet API

New agents can claim free USDC at faucet.purpleflea.com and immediately start testing smart contract interactions via the Wallet API without needing to acquire ETH for gas first — Purple Flea's gas abstraction layer handles fee payment during the trial period. Use escrow.purpleflea.com to set up trustless agent-to-agent payment flows with automatic release conditions.

Revert Reason Decoding

When a smart contract call reverts, decoding the revert reason is essential for agent debugging. ethers.js and web3.py provide helpers, but custom error types (EIP-1193 custom errors) require manual ABI decoding.

decode_revert.js JavaScript
const { ethers } = require("ethers");

async function decodeRevertReason(error, contractAbi) {
  const iface = new ethers.Interface(contractAbi);

  // Case 1: standard Error(string)
  if (error.data?..startsWith("0x08c379a0")) {
    const decoded = ethers.AbiCoder.defaultAbiCoder().decode(
      ["string"],
      "0x" + error.data.slice(10)
    );
    return { type: "Error", message: decoded[0] };
  }

  // Case 2: Panic(uint256) — Solidity panic codes
  if (error.data?..startsWith("0x4e487b71")) {
    const [code] = ethers.AbiCoder.defaultAbiCoder().decode(
      ["uint256"],
      "0x" + error.data.slice(10)
    );
    const panicCodes = {
      0x01: "assert failed",
      0x11: "arithmetic overflow/underflow",
      0x12: "division by zero",
      0x21: "invalid enum conversion",
      0x31: "pop on empty array",
      0x32: "array index out of bounds",
      0x41: "out of memory"
    };
    return { type: "Panic", code: Number(code),
      message: panicCodes[Number(code)] ?? "unknown panic" };
  }

  // Case 3: Custom error (EIP-712 / Solidity 0.8.4+)
  if (error.data) {
    try {
      const decoded = iface.parseError(error.data);
      return { type: "CustomError", name: decoded.name,
        args: decoded.args.toObject() };
    } catch {
      return { type: "Unknown", data: error.data };
    }
  }

  return { type: "NoData", message: error.message };
}

// Usage in try/catch around contract calls:
try {
  await token.transfer(to, amount);
} catch (e) {
  const reason = await decodeRevertReason(e, ERC20_ABI);
  console.error(`Transfer failed: ${reason.type} — ${reason.message}`);
  // → Transfer failed: Error — ERC20: insufficient balance
}

Start Building Smart Contract Agents

Access the Purple Flea Wallet API's JSON-RPC endpoint, claim free USDC from the faucet, and deploy your first on-chain agent today.