An AI assistant reads an invoice, identifies the supplier and suggests a payment. A Java tool can turn that suggestion into a ledger transaction. The interesting engineering question is who authorizes the transition between those two steps.
The answer should be visible in application code. A model may select a business task. An authenticated business process approves a specific order. A payment component constructs the transaction from that approved order. Each step has a different responsibility, and each needs its own evidence.
For Java teams connecting AI to financial workflows, this is a particularly useful direction to explore now: agent integrations are becoming easier, while the consequences of granting them broad tools remain substantial. This article builds a small, tested boundary with real Spring AI tool callbacks and real xrpl4j payment objects. It prepares unsigned transactions; it never moves funds.
Why this combination matters now
Three developments bring the topic into focus. Spring AI 2.0 moved tool execution into a composable advisor architecture, giving Java applications a clearer place to control the tool loop. The June 12 GA announcement describes that change and the Spring Boot 4 / Framework 7 baseline. This is an established foundation, rather than an announcement from this week. Spring AI 2.0 GA.
The more recent release list marks Spring AI 2.0.1 as the latest stable release and 2.1.0-M1 as a prerelease. Meanwhile, the October 2 article on modular RAG and TypeSafe Jev illustrates another active direction: selecting and evaluating the information an AI workflow uses. These are useful mechanisms for producing better proposals. My architectural conclusion is that proposal quality and permission to spend need separate controls. Spring AI releases, modular RAG and TypeSafe Jev.
On the blockchain side, Ripple's June 9 XRPL AI Starter Kit announcement connects documentation access, agent skills and x402-based service payments using XRP or RLUSD. It shows a concrete integration direction, but does not establish the controls of an individual business application. XRPL AI Starter Kit.
The Java SDK is evolving too: xrpl4j 7.0.0-rc.3, released September 24, remains a prerelease in the release listing checked for this article. The example pins stable 6.0.0, alongside Spring AI 2.0.1, rather than depending on release-candidate functionality. Research date: October 9, 2026. xrpl4j releases.
This is my editorial choice for Homann's Java, Spring, AI and blockchain audience, rather than a ranking based on market adoption. It also extends our previous xrpl4j settlement article: that article checked incoming value before accounting credit; this one checks authority before preparing outgoing value.
Give the model a business reference
A tempting tool signature accepts a destination, amount, asset and network. That makes the model responsible for constructing financial instructions from whatever text it has encountered. A supplier document could contain a different account, an ambiguous amount or an instruction to bypass the normal workflow.
Our tool accepts only an invoice ID. The application resolves that ID inside an authenticated tenant and loads an immutable order from its own records. The model cannot pass the payer account, destination, destination tag, XRP amount, fee cap or approval through the tool schema.
That smaller interface still needs authorization. A model could select another invoice that exists. In this demonstration, the tool can prepare any already approved invoice in the caller's tenant. A real application should further restrict that scope to the current task, user entitlement or a narrowly issued capability when needed. Tenant isolation is one boundary, not the entire access policy.
Spring AI's ToolContext carries application data to the tool without sending it to the model. We use it for a typed caller whose tenant comes from authentication. The application must construct that context from trusted request state. Copying a tenant string out of chat text would undermine the design. Spring AI tool context.
Here is the complete tested tool class:
package com.homannsoftware.payments;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
public final class PaymentTools {
/** Populate only from authenticated application state, never chat text. */
public record Caller(String tenant) {
public Caller {
if (tenant == null || tenant.isBlank()) throw new IllegalArgumentException("Missing tenant");
}
}
private final PaymentAuthority authority;
public PaymentTools(PaymentAuthority authority) { this.authority = authority; }
@Tool(description = "Prepare an already approved invoice as an unsigned XRP payment. Never signs or submits.")
public String preparePayment(String invoiceId, ToolContext context) {
if (context == null || !(context.getContext().get("caller") instanceof Caller caller))
return "REJECTED: authenticated caller required";
if (invoiceId == null || invoiceId.isBlank()) return "REJECTED: invoice ID required";
var result = authority.prepare(new PaymentAuthority.Key(caller.tenant(), invoiceId));
return result.status() + ": " + result.reason();
}
}
The model sees the status and a short explanation. It receives no private key, signed transaction or approval token. The approval operation is deliberately absent from the registered tools. Adding an approvePayment tool to the same model would erase the separation we are trying to establish.
Approve a snapshot, then check it again
The example stores an Order containing the tenant and invoice key, revision, network, payer account, destination, destination tag and amount in integer drops. An Approval contains the entire immutable order snapshot, an expiry instant and a maximum fee in drops.
Why include both the revision and the actual fields? A version is useful for audit and optimistic concurrency, but a programming error could change fields without incrementing it. Snapshot equality catches both cases. Our tests change the revision, network, payer, destination, tag and amount independently. Every change invalidates the old approval before the adapter is called.
The preparation method below is copied directly from the tested source. It uses the server clock, with expiry treated as exclusive: an approval expiring at 09:00:00 is no longer usable at exactly 09:00:00.
public synchronized Result prepare(Key key) {
var order = orders.get(key);
if (order == null) return new Result(Status.REJECTED, "Unknown invoice in this tenant");
if (prepared.containsKey(key)) return new Result(Status.PREPARED, "Existing unsigned payment");
if (attempted.contains(key)) return new Result(Status.REVIEW, "Prior attempt requires reconciliation");
var approval = approvals.get(key);
if (approval == null) return new Result(Status.REVIEW, "Approval required");
if (!approval.snapshot().equals(order)) return new Result(Status.REVIEW, "Order changed after approval");
if (!clock.instant().isBefore(approval.expires())) return new Result(Status.REVIEW, "Approval expired");
attempted.add(key); // Reserve before invoking the adapter; failure stays blocked.
try {
prepared.put(key, Objects.requireNonNull(preparer.prepare(order, approval.maxFeeDrops())));
return new Result(Status.PREPARED, "Unsigned payment prepared; no funds moved");
} catch (RuntimeException failure) {
return new Result(Status.REVIEW, "Preparation failed; reconcile before retry");
}
}
The synchronized method reserves the invoice before invoking the adapter. A repeated successful call returns the existing prepared object. A failed attempt remains blocked for reconciliation. This trades automatic recovery for a clear failure boundary in a small demonstration.
The reservation protects only one PaymentAuthority instance in one running process. It is not durable and does not coordinate replicas. The downloadable source makes that limitation explicit. In production, store the approved snapshot and workflow state in a database, enforce a unique business operation key, and atomically transition from approved to claimed. Keep remote calls outside a long database transaction, and give claims a recoverable lifecycle.
Approval expiry here controls when preparation may begin. It does not authorize signing or submission hours later. An existing prepared payment can still be returned after expiry because returning an unsigned object causes no payment. A separate signing service must enforce its own freshness and revocation rules before money can move.
Construct a narrow XRPL payment
The adapter uses real xrpl4j types and supports a direct XRP Payment. It constructs the payer, destination, tag and amount from the approved order. A trusted worker supplies the account sequence, a validated ledger index and the proposed fee. The model supplies none of them.
package com.homannsoftware.payments;
import com.google.common.primitives.UnsignedInteger;
import com.google.common.primitives.UnsignedLong;
import org.xrpl.xrpl4j.model.transactions.*;
/** Trusted execution inputs. No RPC, autofill, signing, or submission. */
public final class XrplPreparer implements PaymentAuthority.Preparer {
private final String network;
private final long sequence, validatedLedger, feeDrops;
public XrplPreparer(String network, long sequence, long validatedLedger, long feeDrops) {
if (network == null || network.isBlank() || sequence < 1 || sequence > 0xffff_ffffL
|| validatedLedger < 1 || validatedLedger > 0xffff_ffffL - 4 || feeDrops < 1)
throw new IllegalArgumentException("Invalid execution inputs");
this.network = network; this.sequence = sequence;
this.validatedLedger = validatedLedger; this.feeDrops = feeDrops;
}
@Override public Payment prepare(PaymentAuthority.Order order, long maxFeeDrops) {
if (!network.equals(order.network())) throw new IllegalArgumentException("Network mismatch");
if (feeDrops > maxFeeDrops) throw new IllegalArgumentException("Fee exceeds approval");
return Payment.builder()
.account(order.account()).destination(order.destination())
.destinationTag(UnsignedInteger.valueOf(order.tag()))
.amount(XrpCurrencyAmount.of(UnsignedLong.valueOf(order.drops())))
.fee(XrpCurrencyAmount.of(UnsignedLong.valueOf(feeDrops)))
.sequence(UnsignedInteger.valueOf(sequence))
.lastLedgerSequence(UnsignedInteger.valueOf(validatedLedger + 4))
.build();
}
}
For the controlled test fixture, the amount is 1,000,000 drops, the fee is 12 drops, the sequence is 7 and the validated ledger index is 1,000. The resulting LastLedgerSequence is 1,004. These are synthetic inputs, not current network observations or a recommended live fee.
The Payment has no signature, payment paths, SendMax or DeliverMin, and no partial-payment flag. Direct XRP payments have a simpler asset contract than issued-currency or cross-currency payments. The protocol's Payment reference explains the transaction fields and the additional forms a Payment can take. XRPL Payment reference.
The fee cap deserves its own check. XRPL transaction costs can increase with load, and the transaction burns the fee specified in the signed transaction. A worker should obtain current fee information and compare the final value with the authorized cap. An autofill step after the check must not silently replace that value. Our fixture accepts a fee equal to or below the cap and blocks one above it. XRPL transaction cost.
The network comparison is an application routing check. It is not cryptographic network binding. A production executor must bind endpoint configuration, account access, signing policy and any applicable network fields to the approved network. Likewise, the fixture's fixed account sequence cannot be reused across different outgoing payments: a worker needs per-account sequence coordination and current ledger observations.
An integration trap the tests actually found
Compilation passed before the first callback test failed. The unaligned Maven dependency graph selected jackson-annotations 2.14.3 through xrpl4j, while the Spring AI callback's Jackson 3 databind path expected a newer annotation class, JsonSerializeAs. The failure appeared when MethodToolCallback initialized, not when our payment model compiled.
The project now imports the Jackson 3.1.5 BOM, aligning its Jackson 3 core/databind to 3.1.5 and the shared annotations artifact to 2.21. The XRPL mapper continues to use its Jackson 2 databind path. Jackson's own documentation confirms that Jackson 3 still uses the 2.x annotations artifact. Jackson annotations documentation.
The test suite invokes real generated Spring AI callbacks with JSON arguments and separately serializes and deserializes the prepared Payment through xrpl4j's ObjectMapperFactory. Both now succeed in the same dependency graph. That verifies the paths used here; it is not a compatibility guarantee for every SDK object, custom mapper or Spring Boot dependency arrangement. Keep the actual application's managed dependencies and serialization tests under review.
What the tests prove
The downloadable Maven project targets Java 21 and was executed with Temurin 21.0.11+10 and Maven 3.9.11. 27 tests passed with zero failures, errors or skips. They exercise Spring AI 2.0.1, xrpl4j-core 6.0.0 and JUnit Jupiter 5.13.4.
| Boundary | Executed evidence |
|---|---|
| Tool exposure | Exactly one callback; schema exposes invoice ID and excludes caller, tenant, destination, amount and fee cap |
| Trusted identity | Missing caller, wrong caller type and another tenant cannot prepare the approved invoice |
| Approval | Missing approval, exact expiry and each changed snapshot field block preparation |
| Transaction construction | Exact XRP amount, tag, fee, sequence and ledger bound; no signature or conversion fields |
| Repeated requests | Repeated calls retain the same prepared object; 16 concurrent calls invoke the adapter once |
| Failure handling | Adapter failure blocks further attempts and requires reconciliation |
| Dependency integration | Real tool JSON dispatch and XRPL JSON roundtrip both succeed |
Three additional mutation checks deliberately removed snapshot validation, expiry validation and the successful duplicate check. Each mutation caused assertion failures; the original source was restored and all 27 baseline tests passed again. The source archive includes the commands and logs.
To reproduce the baseline from the extracted examples directory:
mvn -B clean test
After Maven has resolved the dependencies, run the optional mutation checks:
python verify_mutations.py /absolute/path/to/mvn
On Windows, pass the path to mvn.cmd. Download the tested source project.
These tests do not call an LLM, connect to a ledger, use credentials, sign or submit a transaction. The injection-like invoice string test proves that arbitrary text cannot select an unknown stored order. It does not measure a model's resistance to prompt injection. The concurrency test proves one in-process preparation, not exactly-once payment after a crash.
Carry the boundary through signing and settlement
Preparation is an intermediate state. For an actual payment workflow, the signing component should accept the approved payment record, verify the final transaction fields and fee, and persist the signed transaction identity before network submission. Approval changes or expired ledger windows need an explicit recovery decision.
The XRPL reliable-submission guidance separates provisional submission from verification against validated ledgers. It recommends persisting transaction details before submission and using LastLedgerSequence to bound inclusion. If the outcome is unknown, a timeout is not a reason to create a second payment. Verify the original identity, with sufficient trusted ledger history, before deciding whether replacement is appropriate. Reliable transaction submission.
For a paid AI service, there is another business boundary after settlement: did the expected service return an acceptable result? Paying for a response and accepting its quality are distinct events. Keep service delivery evidence alongside payment evidence, and model refunds, disputes or review as explicit use cases rather than asking the model to improvise them.
The useful role for AI is substantial: extract invoice candidates, explain discrepancies, assemble a review packet, select an approved workflow and help investigate exceptions. Java and Spring provide a place to express the responsibilities and enforce the transitions. xrpl4j provides transaction types. A maintainable system connects those pieces with a business contract that remains understandable when the model proposes something unexpected.
Start with one tenant-scoped tool, one approved order and one observable transition. Expand the authority only when the business use case, recovery procedure and executed evidence justify it.