Integration

Live Onchain Data in Spring AI: Build an EVM Blockchain Tool

Build a Spring AI @Tool that reads live EVM contract data — token balances, oracle prices, Aave positions — without ABIs or RPC nodes.

evmquery team · · 7 min read
Spring AI plus evmquery: live EVM blockchain data as a @Tool service

Spring AI turns any Spring Boot service into an AI assistant, but the model’s knowledge of on-chain state ends at training time. Ask a ChatClient what the current USDC balance of a wallet is and it either hallucinates a number or tells you it cannot know. That data lives on-chain, updates every block, and has never been in any training corpus.

One @Tool method fixes this. Annotate a regular Java method with @Tool, wire the service into a ChatClient call via .defaultTools(evmQueryService), and the model can invoke it whenever it needs live contract state. Spring AI handles the full function-calling loop: schema export, dispatch, result injection, and final answer generation. You write no JSON schema, no parsing logic, no dispatch switch statement.

TL;DR

Add spring-ai-anthropic-spring-boot-starter (or OpenAI), annotate a service method with @Tool, and POST to https://api.evmquery.com/api/v1/query inside it. Your Spring Boot agent can then read live USDC balances, ETH prices from Chainlink, and Aave health factors on Ethereum, Base, or BNB. No ABIs, no RPC node. Free tier: 2,000 credits/month.

How Spring AI @Tool works

Spring AI’s function-calling system is driven by annotations. Mark a method with @Tool and the framework exports its name and description to the language model at runtime. When the model decides to call the tool, Spring AI deserializes the model’s JSON arguments into the method’s parameter types, invokes the method, and feeds the return value back into the conversation.

Parameters use @ToolParam to carry the description the model reads when deciding how to fill each argument. The Java type annotation tells Spring AI what JSON schema field type to expose; the description string tells the model what the parameter means. Both live on the same annotation, no separate schema file needed.

The tool execution loop is transparent to your code. Your method is called synchronously. When it returns, the model continues generating from where it left off, now with the live result as context.

Spring AI 1.0.0 supports @Tool on methods of any Spring-managed @Service, @Component, or @Bean. The return type can be any Jackson-serializable type, though String is the most direct choice for tool output the model reads in prose.

What you will build

A single EvmQueryService class with one queryContract method. The method takes a chain identifier, a contract alias and address, a CEL expression, and an optional wallet address. evmquery resolves the ABI automatically, evaluates the expression against the live chain state, and returns a decoded scalar. Any ChatClient you wire it into can then answer questions about token balances, oracle prices, and DeFi positions from real on-chain data.

For a broader view of the integration surfaces available to Java developers, the developer page covers MCP, REST, and n8n options together.

Setup

pom.xml

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- Choose one provider -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-anthropic-spring-boot-starter</artifactId>
    </dependency>
</dependencies>

application.properties

spring.ai.anthropic.api-key=${ANTHROPIC_API_KEY}
evmquery.api-key=${EVMQUERY_API_KEY}

Get a free evmquery key at https://app.evmquery.com/onboarding?plan=free. The free tier includes 2,000 credits per month.

Defining EvmQueryService

package com.example.agent;

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;

import java.util.HashMap;
import java.util.Map;

@Service
public class EvmQueryService {

    private static final String ENDPOINT = "https://api.evmquery.com/api/v1/query";

    @Value("${evmquery.api-key}")
    private String apiKey;

    private final RestClient restClient = RestClient.create();

    private record QueryResult(Object value) {}
    private record QueryMeta(String blockNumber) {}
    private record EvmQueryResponse(QueryResult result, QueryMeta meta) {}

    @Tool(
        description = "Read live state from an EVM smart contract. " +
            "Use for token balances, DeFi positions, oracle prices, or any contract view function. " +
            "Supported chains: evm_ethereum, evm_base, evm_bnb_mainnet. " +
            "Do NOT use for historical data or event logs."
    )
    public String queryContract(
            @ToolParam(description = "Chain identifier: evm_ethereum, evm_base, or evm_bnb_mainnet")
            String chain,
            @ToolParam(description = "Short alias for the contract (e.g. usdc, aave, eth_usd)")
            String contractAlias,
            @ToolParam(description = "Contract address as a 0x-prefixed hex string")
            String contractAddress,
            @ToolParam(description = "CEL expression to evaluate " +
                "(e.g. formatUnits(usdc.balanceOf(wallet), usdc.decimals()))")
            String expression,
            @ToolParam(
                description = "Wallet address for parameterized expressions. " +
                    "Pass an empty string if not needed.",
                required = false
            )
            String walletAddress
    ) {
        Map<String, Object> schema = new HashMap<>();
        schema.put("contracts", Map.of(contractAlias, Map.of("address", contractAddress)));

        Map<String, Object> body = new HashMap<>();
        body.put("chain", chain);
        body.put("schema", schema);
        body.put("expression", expression);

        if (walletAddress != null && !walletAddress.isBlank()) {
            schema.put("context", Map.of("wallet", "sol_address"));
            body.put("context", Map.of("wallet", walletAddress));
        }

        EvmQueryResponse response = restClient.post()
                .uri(ENDPOINT)
                .header("x-api-key", apiKey)
                .contentType(MediaType.APPLICATION_JSON)
                .body(body)
                .retrieve()
                .body(EvmQueryResponse.class);

        return response.result().value() + " (block " + response.meta().blockNumber() + ")";
    }
}

Three things worth noting:

  • @Tool exports the method. The framework reads description from the annotation and sends it to the model alongside the chat context. A precise, action-oriented description cuts misrouted calls. “Read live state from an EVM smart contract” with explicit chain names and a do-not-use clause is harder to misinterpret than “Query blockchain.”
  • Contracts as objects. The evmquery REST API requires each contract entry as {"address": "0x..."}, not a bare address string. The Map.of(contractAlias, Map.of("address", contractAddress)) wrapper converts the model’s clean arguments automatically.
  • Typed records for the response. Spring Boot 3.x enables the -parameters compiler flag by default, so Jackson can deserialize into Java records without @JsonProperty annotations. The inner records keep the code clean without introducing a separate DTO file.

Three live recipes

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

ERC-20 balance check

service.queryContract(
    "evm_ethereum",
    "usdc",
    "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    "formatUnits(usdc.balanceOf(wallet), usdc.decimals())",
    "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
);
// → "0.11674 (block 25148304)"

formatUnits reads decimals() from the contract directly, so the scaling is correct regardless of whether the token uses 6 (USDC), 8, or 18 decimal places.

service.queryContract(
    "evm_ethereum",
    "eth_usd",
    "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419",
    "formatUnits(eth_usd.latestAnswer(), eth_usd.decimals())",
    ""
);
// → "2135.94 (block 25148304)"

No wallet needed, so walletAddress is an empty string. The address is the canonical Chainlink ETH/USD aggregator on Ethereum mainnet. Swap the address for any other Chainlink price feed; the expression stays the same.

Aave health factor

service.queryContract(
    "evm_ethereum",
    "aave",
    "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2",
    "formatUnits(aave.getUserAccountData(wallet).healthFactor, 18)",
    "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"
);
// → "1.157920892373162E59 (block 25148304)"

What the large health factor means

A healthFactor of 1.16e59 is not an error. For wallets with no active borrow position, Aave returns the maximum uint256 value divided by 1e18, which represents infinite health. A value below 1.0 indicates liquidation risk.

getUserAccountData returns a struct. evmquery exposes struct fields via dot notation, so healthFactor, totalCollateralBase, totalDebtBase, and more are all reachable from the same expression with no ABI decoding step.

Wiring a ChatClient

With the service defined, a ChatClient bean that answers on-chain questions takes about twenty lines:

package com.example.agent;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;

@Component
public class BlockchainAssistant {

    private final ChatClient chatClient;

    public BlockchainAssistant(
            ChatClient.Builder builder,
            EvmQueryService evmQueryService
    ) {
        this.chatClient = builder
                .defaultSystem(
                    "You are a blockchain data assistant. " +
                    "Use queryContract to fetch live on-chain data whenever the user asks " +
                    "about token balances, oracle prices, or DeFi positions. " +
                    "Always include the block number in your reply."
                )
                .defaultTools(evmQueryService)
                .build();
    }

    public String ask(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }
}

ChatClient.Builder is auto-configured by Spring AI’s starter. The .defaultTools(evmQueryService) call scans the service for @Tool-annotated methods and registers them for every conversation. Calling assistant.ask("What is the current ETH/USD price from Chainlink?") triggers the full tool-use loop: the model reads the queryContract description, constructs the tool call parameters, Spring AI dispatches to EvmQueryService.queryContract, and the live result appears in the final reply.

Per-request vs. default tools

.defaultTools(evmQueryService) registers tools for every call built from this ChatClient. If you want to control tool availability per-request, omit defaultTools and use .tools(evmQueryService) on the individual chatClient.prompt() chain instead. The dispatch behavior is identical; the scope is different.

Switching providers

EvmQueryService is model-agnostic. Switching from Anthropic to OpenAI is a dependency swap and two property changes:

pom.xml — swap the starter:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>

application.properties:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o

EvmQueryService and BlockchainAssistant are unchanged. Spring AI normalizes the @Tool dispatch protocol across all providers that support function calling, including Azure OpenAI, Vertex AI Gemini, and Ollama.

Java developers building production DeFi tooling can find additional expression patterns, multi-contract examples, and chain references on the developer page.

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 expression against the live chain today.