Integration

Live Onchain Data in DSPy: Build an EVM Blockchain Tool with ReAct

Wrap a live EVM contract reader as a dspy.Tool and wire it into a DSPy ReAct agent — read USDC balances, Chainlink prices, and Aave positions from a natural language prompt.

evmquery team · · 7 min read
DSPy plus evmquery: live EVM blockchain data as a ReAct agent tool

DSPy treats every LLM interaction as a program, not a conversation. You declare what you want with a typed signature ("question -> answer"), add tools via dspy.ReAct, and DSPy handles the reasoning loop. The model traces its steps, selects a tool, processes the result, and delivers a structured answer.

The gap is live EVM blockchain data. DSPy can optimize tool selection, but it cannot invent the current ETH price or a wallet’s USDC balance. Those numbers live on-chain and change every block. An evmquery tool closes that gap in under 25 lines, giving any DSPy ReAct agent access to live EVM contract state without ABIs, RPC nodes, or web3 boilerplate.

TL;DR

pip install dspy requests, define a plain Python function evmquery_read with type hints and a docstring, pass it to dspy.ReAct, and your agent can answer questions about USDC balances, ETH prices, and Aave positions from a natural language prompt. Free tier: 2,000 credits/month, no credit card needed.

How DSPy ReAct works

dspy.ReAct implements the Reasoning and Acting loop. At each iteration, the language model reasons about what it knows, decides which tool to call, receives an observation, and continues until it can answer or hits max_iters. DSPy creates a “finish” tool automatically; the model calls it when it has enough information to construct the final output.

import dspy

def get_price(ticker: str) -> float:
    """Return the current price in USD for a given ticker symbol."""
    ...  # call a real API here

react = dspy.ReAct("question -> answer", tools=[get_price])
result = react(question="What is the current ETH price?")
print(result.answer)

Three things to notice:

  • The signature string "question -> answer" defines the agent’s I/O contract. DSPy enforces that the agent produces an answer field — no parsing required.
  • Tools are plain callables. DSPy reads the type hints and docstring to build the schema the model sees.
  • dspy.Tool is optional. Pass plain functions directly, or wrap explicitly with dspy.Tool(fn, name="...", desc="...") for fine-grained control over the tool name and description shown to the model.

What you’ll build

A single evmquery_read tool that any DSPy ReAct agent can invoke. The tool accepts a chain identifier, a named contract address map, a CEL expression, and optional context variables. It calls the evmquery REST API and returns the decoded result along with the block number.

If you want to query the chain interactively from Claude Desktop or Cursor without writing code, the evmquery MCP server is the faster path. For Python agents where you want full control over schema, error handling, and DSPy’s prompt optimizer, the function tool below is the right surface.

Setup

pip install dspy requests
export ANTHROPIC_API_KEY=sk-ant-...
export EVMQUERY_API_KEY=evmq_...

Get a free evmquery key at https://app.evmquery.com/onboarding?plan=free. The free tier covers 2,000 credits per month: roughly 1,000 typical contract reads.

Defining the evmquery tool

evmquery’s REST API takes a chain, a named contract map, a CEL expression, and optional typed context variables. The response includes a decoded result.value and the meta.blockNumber at which it was read.

One format note: the API requires each contract entry to be {"address": "0x..."}, not a bare address string. The tool below accepts plain address strings from the model and converts them internally — the model never needs to know about evmquery’s schema shape.

import os
from typing import Optional
import requests

EVMQUERY_API = "https://api.evmquery.com/api/v1/query"


def evmquery_read(
    chain: str,
    contracts: dict,
    expression: str,
    context: Optional[dict] = None,
) -> str:
    """Read live data from an EVM smart contract.

    Use for current token balances, DeFi positions, oracle prices, or any
    contract view function on Ethereum, Base, or BNB Smart Chain.
    Supported chains: evm_ethereum, evm_base, evm_bnb_mainnet.
    Do NOT use for historical data or event logs.

    Args:
        chain: Chain identifier — evm_ethereum, evm_base, or evm_bnb_mainnet.
        contracts: Map of short name to 0x contract address.
                   Example: {"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"}
        expression: CEL expression to evaluate. Contract names become variables.
                    Example: "formatUnits(usdc.balanceOf(wallet), usdc.decimals())"
        context: Optional runtime values. Pass a single address string or a list
                 of addresses for multi-wallet expressions.
    """

    def _type(v) -> str:
        return "list<sol_address>" if isinstance(v, list) else "sol_address"

    contract_schema = {k: {"address": v} for k, v in contracts.items()}

    body: dict = {
        "chain": chain,
        "schema": {"contracts": contract_schema},
        "expression": expression,
    }
    if context:
        body["schema"]["context"] = {k: _type(v) for k, v in context.items()}
        body["context"] = context

    resp = requests.post(
        EVMQUERY_API,
        json=body,
        headers={"x-api-key": os.environ["EVMQUERY_API_KEY"]},
        timeout=10,
    )
    resp.raise_for_status()
    data = resp.json()
    return f"{data['result']['value']} (block {data['meta']['blockNumber']})"

Two implementation notes worth keeping:

  • The docstring is the schema. DSPy passes it to the model as the tool description. The scope constraint (“Do NOT use for historical data”) reduces misrouted calls.
  • Type inference for context variables. evmquery requires knowing whether a value is a single address (sol_address) or a list (list<sol_address>). The _type helper infers this from the Python value, so the model only needs to pass a string or a list.

Three live queries

All expressions below were validated against the live chain before publication.

ERC-20 balance check

evmquery_read(
    chain="evm_ethereum",
    contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
    expression="formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
    context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "0.11674 (block 25126787)"

formatUnits reads decimals() from the contract itself. USDC uses 6 decimals, DAI uses 18; the expression handles both without hard-coding the value.

evmquery_read(
    chain="evm_ethereum",
    contracts={"eth_usd": "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419"},
    expression="formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
)
# → "2129.01 (block 25126787)"

No context variables needed: a pure contract read with no wallet parameter. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet.

Aave health factor

evmquery_read(
    chain="evm_ethereum",
    contracts={"aave": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2"},
    expression="formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
    context={"wallet": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"},
)
# → "1.157920892373162e+59 (block 25126766)"

healthFactor represents how far above the liquidation threshold a position sits. Values above 1.0 are safe; below 1.0 triggers liquidation. For wallets with no active borrow position, evmquery returns approximately 1.16e59: this is the protocol’s uint256 maximum divided by 1e18 and represents infinite health, not an error. Make sure your agent’s system instructions document this so the model doesn’t surface a confusing number to the user.

Struct fields use dot notation: aave.getUserAccountData(wallet).healthFactor. Drop the field access to return the full six-field struct (total collateral, total debt, available borrows, LTV, liquidation threshold, health factor) as a JSON object.

Building the DSPy ReAct agent

With the tool defined, the agent is five lines:

import dspy

dspy.configure(lm=dspy.LM("anthropic/claude-sonnet-4-6"))

agent = dspy.ReAct(
    "question -> answer",
    tools=[evmquery_read],
    max_iters=5,
)

result = agent(question="What is the current ETH price in USD according to Chainlink?")
print(result.answer)
# ETH is trading at $2,129.01 as of block 25,126,787 on the Chainlink oracle.

max_iters=5 caps the reasoning loop. For simple contract reads, the agent finishes in two iterations: one tool call, one finish call. Raising the limit is useful for multi-step queries (fetch a wallet’s balance, then check if it exceeds a threshold) but adds latency on each extra iteration.

Switching providers

DSPy uses LiteLLM under the hood — swap the model string to use any supported provider. dspy.LM("openai/gpt-4o-mini") for OpenAI, dspy.LM("gemini/gemini-1.5-pro") for Google. The tool definition and agent code stay identical; DSPy normalizes the tool-calling interface across providers.

For production agents, a typed dspy.Signature subclass gives field-level control over descriptions and output structure:

class BlockchainQuery(dspy.Signature):
    """Answer questions about live EVM contract state using the evmquery tool."""

    question: str = dspy.InputField(desc="A question about current blockchain data")
    answer: str = dspy.OutputField(
        desc="The answer, including the exact numeric value and block number"
    )

agent = dspy.ReAct(BlockchainQuery, tools=[evmquery_read], max_iters=5)

The desc on answer tells the model to always include the block number. For scheduled monitoring jobs or data freshness audits this matters: the block number tells the caller exactly when the value was read.

DSPy’s optimizer (dspy.MIPROv2 or BootstrapFewShot) can further improve tool selection quality by compiling few-shot examples into the agent’s prompts. This is the main differentiator from most other frameworks: evmquery queries become part of a learnable program, not a fixed prompt.

Developers building DeFi tooling and automated portfolio monitors can find more expression patterns in the AI users guide, covering the REST tool and MCP surfaces side by side.

Multi-wallet scans

For portfolio monitoring across multiple addresses, pass a list to context and use the CEL map macro:

evmquery_read(
    chain="evm_ethereum",
    contracts={"usdc": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"},
    expression="wallets.map(w, formatUnits(usdc.balanceOf(w), usdc.decimals()))",
    context={
        "wallets": [
            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
            "0xBE0eB53F46cd790Cd13851d5EFf43D12404d33E8",
        ]
    },
)

Passing a Python list triggers list<sol_address> automatically via the _type helper. One API round-trip returns all balances in order — no loop, no N separate tool calls from the model.

DSPy tool vs MCP: picking the right surface

DSPy tool (this post)MCP server
LanguagePythonAny MCP client
Setup~25 lines in your agent filePaste one config block
ControlFull: schema, error handling, loggingClient manages the conversation
Prompt optimizationYes — MIPRO and BootstrapFewShot applyNo
Best forPython agents, optimized pipelinesClaude Desktop, Cursor, VS Code

If you want to improve tool selection quality over time using DSPy’s optimizer, the function tool is the right surface. If you want to query the chain interactively from your IDE without writing code, the evmquery MCP server guide gets you there in under five minutes.

Next steps

Two minutes to your first live chain read

2,000 credits a month, no credit card required. Grab a free API key and run your first DSPy expression against the live chain today.