Researched October 8, 2026. Includes an executed Java 21 example using xrpl4j 6.0.0, 21 tests and three detected mutations.

A payment service receives a successful response from a ledger node. An invoice is marked paid. A message tells fulfillment to ship the order. Everything looks ordinary until the team discovers that the response was provisional—or that the payment delivered only a fraction of the amount the application booked.

This is where blockchain integration becomes software architecture. The interesting question is what evidence your business needs before it changes its own state.

XRPL is a timely place to examine that question. Its recent releases combine expanding financial capabilities with substantial work on correctness. For Java teams, xrpl4j—the Java SDK for the XRP Ledger—provides a practical integration boundary. The SDK gives us ledger types; our application still has to define the meaning of a settled invoice.

Why this topic matters now

The September 16 release of xrpld 3.4.0 introduced LendingProtocolV1_1, including closed-ended vaults and cash-basis accounting. The latter recognizes interest as payments occur. It is a useful reminder that financial software must distinguish a scheduled obligation from received value. XRPL 3.4.0 release notes

September 25 brought xrpld 3.4.1, an emergency release addressing security-sensitive protocol issues. Its announcement gives October 9 as the expected activation date for fixBatchV1_2, conditional on continued validator support. That is an announced expectation as of this article’s research date, not a completed activation. Operators should follow the official upgrade notice and current network state. XRPL 3.4.1 release notes

There are three independent questions to answer before adopting a new capability:

Layer Evidence to check What it does not establish
SDK Your pinned artifact models and serializes the required fields The network accepts those transactions
Server Your node implements the relevant amendment The amendment is enabled on the target network
Application Your business rules and recovery paths are tested The correctness of the ledger implementation or its operators

XRPL enables protocol amendments through validator voting. A server release and an enabled amendment are different deployment facts. Check the target network before enabling feature-dependent workflows. XRPL amendment process

The SDK deserves the same care. On October 8, GitHub identifies 6.0.0 as the latest stable xrpl4j release and lists 7.0.0-rc.3 as a prerelease. We pin 6.0.0 for this example’s existing Payment models. We do not exercise lending, confidential transfers or other new protocol features. xrpl4j releases

Three separate checks connect SDK support, server implementation and enabled network amendments.

Feature availability requires evidence at each layer. A successful compilation proves only part of the integration.

Define settlement before writing the adapter

Our use case is deliberately narrow: one invoice, one incoming Payment, one supported issued currency, one exact amount. It uses synthetic USD fixtures and a test network label; it does not represent a particular stablecoin issuer.

The invoice binds a registered transaction hash to a destination account, destination tag, currency code, issuer and expected amount. Registering the hash is an application workflow—for example, the payer reports a hash that the service then independently looks up. A claimed hash is never itself evidence of payment. Invoice tags must also be assigned and looked up by the application, with a clear reuse policy.

This example’s decision vocabulary is small:

  • WAIT: the observation is provisional.
  • REJECT: the evidence contradicts the invoice or records a validated failure.
  • REVIEW: the observation is incomplete, unsupported or financially different from the expected settlement.
  • ACCEPT: the supported payment satisfies all checks and can enter the accounting transaction.

ACCEPT is an admission decision. It becomes a recorded credit only after the database commit.

For a classic issued currency, we compare the currency and issuer together. In this application, “USD” from another account is a different accepted asset. We also check the destination and tag because a shared receiving account needs an unambiguous invoice mapping.

The network label comes from our configured adapter, not from a field that magically authenticates a transaction response. Bind endpoints and persisted records to an explicit network configuration. The example trusts the configured node’s validated-ledger response; it does not independently verify consensus or cryptographic inclusion proofs.

Read delivered value from metadata

XRPL’s tx API distinguishes final observations through validated. An omitted or false value is not final. A validated transaction can also have a failure result; ledger inclusion alone does not satisfy our payment policy. XRPL tx API

Payment processing has another important boundary: the requested amount can differ from the delivered amount. XRPL’s partial-payment documentation describes how integrations can overcredit users if they use the Payment’s Amount field. The correct input for received value is metadata’s delivered_amount. If that evidence is unavailable, our policy stops for review. XRPL partial payments

An invoice expects 100 USD. A transaction requests 1,000,000 USD but delivers 0.01 USD. The settlement gate requires review and creates no credit.

These are constructed test values. The requested amount is never our accounting input.

Here is the complete settlement policy used by the tests. The dependency is org.xrpl:xrpl4j-core:6.0.0; Maven also pins JUnit and H2 in the downloadable project.

package com.homannsoftware.xrpl;

import java.math.BigDecimal;
import java.util.Objects;
import java.util.Optional;
import org.xrpl.xrpl4j.model.client.transactions.TransactionResult;
import org.xrpl.xrpl4j.model.transactions.*;

/** Strict one-payment-per-invoice policy for issued currencies, not XRP or MPTs. */
public final class SettlementGate {
  public enum Status { WAIT, REVIEW, REJECT, ACCEPT }
  public record Invoice(String id, String network, Hash256 hash, Address destination,
                        long tag, String currency, Address issuer, BigDecimal due) {
    public Invoice {
      Objects.requireNonNull(id); Objects.requireNonNull(network); Objects.requireNonNull(hash);
      Objects.requireNonNull(destination); Objects.requireNonNull(currency);
      Objects.requireNonNull(issuer); Objects.requireNonNull(due);
      if (id.isBlank() || network.isBlank() || tag < 0 || tag > 0xffff_ffffL || due.signum() <= 0)
        throw new IllegalArgumentException("Invalid invoice");
    }
  }
  public record Credit(String network, Hash256 hash, String invoice, BigDecimal amount) {}
  public record Decision(Status status, String reason, Optional<Credit> credit) {}
  private static Decision stop(Status status, String reason) {
    return new Decision(status, reason, Optional.empty());
  }

  public static Decision evaluate(Invoice invoice, String observedNetwork,
                                  TransactionResult<? extends Transaction> result) {
    if (!invoice.network().equals(observedNetwork) || !invoice.hash().equals(result.hash()))
      return stop(Status.REJECT, "Network or hash mismatch");
    if (!result.validated()) return stop(Status.WAIT, "Provisional observation");
    if (result.ledgerIndex().isEmpty() || result.metadata().isEmpty())
      return stop(Status.REVIEW, "Incomplete validated evidence");
    var meta = result.metadata().orElseThrow();
    if (!"tesSUCCESS".equals(meta.transactionResult()))
      return stop(Status.REJECT, "Validated transaction failed");
    if (!(result.transaction() instanceof Payment payment))
      return stop(Status.REJECT, "Not a Payment");
    if (!invoice.destination().equals(payment.destination())
        || payment.destinationTag().isEmpty()
        || payment.destinationTag().orElseThrow().longValue() != invoice.tag())
      return stop(Status.REJECT, "Destination or tag mismatch");
    if (!(meta.deliveredAmount().orElse(null) instanceof IssuedCurrencyAmount delivered))
      return stop(Status.REVIEW, "Missing or unsupported delivered amount");
    if (!invoice.currency().equals(delivered.currency()) || !invoice.issuer().equals(delivered.issuer()))
      return stop(Status.REJECT, "Asset identity mismatch");
    final BigDecimal actual;
    try { actual = new BigDecimal(delivered.value()); }
    catch (NumberFormatException ex) { return stop(Status.REVIEW, "Malformed amount"); }
    if (actual.signum() <= 0 || actual.compareTo(invoice.due()) != 0)
      return stop(Status.REVIEW, "Delivered amount does not exactly settle invoice");
    return new Decision(Status.ACCEPT, "Verified payment", Optional.of(
        new Credit(invoice.network(), result.hash(), invoice.id(), actual)));
  }
}

The types above are actual xrpl4j models. TransactionResult exposes validation status and optional metadata; the library’s metadata model exposes the delivered amount as an optional currency amount. xrpl4j TransactionResult source, TransactionMetadata source

BigDecimal.compareTo makes 100, 100.00 and 1e2 numerically equivalent. We avoid binary floating-point arithmetic. Overpayments go to review under this policy, just as underpayments do. That is a business choice; a service that accepts multiple installments needs a different aggregate model with cumulative allocation and overpayment handling.

XRP and Multi-Purpose Tokens also need their own amount and identity policies. MPTs have a distinct token model, including an issuance identifier and scaling rules. Treating every asset as an issued-currency triple would erase meaningful protocol differences. XRPL Multi-Purpose Tokens

Make credit and notification one database decision

A correct ledger check still leaves a failure window. Suppose the credit commits, the worker loses its response, and the same observation arrives again. Or suppose the accounting entry commits but the fulfillment event does not.

Our sample uses an immutable receipt as the accounting entry. Its primary key is (network, hash), and the invoice column is unique under the one-payment policy. An outbox row is inserted in the same JDBC transaction. A repeated identical observation returns false; a conflicting amount, invoice assignment or second transaction for an already-settled invoice raises a conflict.

The complete storage component is below. The package-private overload provides the test’s failure injection point.

package com.homannsoftware.xrpl;

import java.math.BigDecimal;
import java.sql.*;

/** The immutable receipt is the accounting entry; its event commits in the same transaction. */
public final class CreditStore {
  private final String url;
  public CreditStore(String url) throws SQLException {
    this.url = url;
    try (var c = DriverManager.getConnection(url); var s = c.createStatement()) {
      s.execute("CREATE TABLE IF NOT EXISTS receipt (network VARCHAR(80), hash CHAR(64), invoice VARCHAR(100) UNIQUE NOT NULL, amount VARCHAR(200) NOT NULL, PRIMARY KEY(network,hash))");
      s.execute("CREATE TABLE IF NOT EXISTS outbox (network VARCHAR(80), hash CHAR(64), invoice VARCHAR(100) NOT NULL, PRIMARY KEY(network,hash), FOREIGN KEY(network,hash) REFERENCES receipt(network,hash))");
    }
  }
  public boolean record(SettlementGate.Credit credit) throws SQLException {
    return record(credit, false);
  }
  // Package-private fault injection: fail after receipt insertion, before event insertion.
  boolean record(SettlementGate.Credit credit, boolean failBeforeEvent) throws SQLException {
    try (var c = DriverManager.getConnection(url)) {
      c.setAutoCommit(false);
      try {
        try (var s = c.prepareStatement("INSERT INTO receipt VALUES (?,?,?,?)")) {
          s.setString(1, credit.network()); s.setString(2, credit.hash().value());
          s.setString(3, credit.invoice()); s.setString(4, credit.amount().stripTrailingZeros().toPlainString());
          s.executeUpdate();
        }
        if (failBeforeEvent) throw new SQLException("Injected failure", "HY000");
        try (var s = c.prepareStatement("INSERT INTO outbox VALUES (?,?,?)")) {
          s.setString(1, credit.network()); s.setString(2, credit.hash().value());
          s.setString(3, credit.invoice()); s.executeUpdate();
        }
        c.commit();
        return true;
      } catch (SQLException ex) {
        c.rollback();
        if (!"23505".equals(ex.getSQLState())) throw ex;
        try (var s = c.prepareStatement("SELECT invoice,amount FROM receipt WHERE network=? AND hash=?")) {
          s.setString(1, credit.network()); s.setString(2, credit.hash().value());
          try (var rows = s.executeQuery()) {
            if (rows.next() && rows.getString(1).equals(credit.invoice())
                && new BigDecimal(rows.getString(2)).compareTo(credit.amount()) == 0) return false;
          }
        }
        throw new SQLException("Conflicting receipt or already-settled invoice", "23505", ex);
      }
    }
  }
  public int count(String table) throws SQLException {
    if (!table.equals("receipt") && !table.equals("outbox")) throw new IllegalArgumentException();
    try (var c = DriverManager.getConnection(url); var s = c.createStatement();
         var rows = s.executeQuery("SELECT COUNT(*) FROM " + table)) {
      rows.next(); return rows.getInt(1);
    }
  }
}

The receipt contains the admitted amount and invoice assignment. A production audit record should retain additional evidence: ledger index, result code, destination, tag, asset identity, adapter version and observation provenance. Schema design should follow the accounting model, including precision limits and retention requirements.

The unique constraint is doing real work: a check-then-insert sequence outside a transaction would leave a race. Here, the database arbitrates competing deliveries. The duplicate path verifies that the existing record agrees with the new request; duplicate identifiers with different contents are not silently accepted.

Ledger evidence passes through the settlement policy to a JDBC transaction that commits a receipt and outbox event together. Repeated delivery creates no second record.

The demonstrated atomic boundary ends at the database. An outbox publisher and its consumer need their own retry and duplicate handling.

We do not claim exactly-once delivery to fulfillment. The outbox publisher is outside this sample, and downstream consumers must tolerate redelivery. The example also exposes the store as a small component rather than an authorization system: production wiring must ensure only admitted decisions can reach it.

In a Spring application, I would keep the XRPL adapter responsible for node access, the settlement policy responsible for invoice rules, and the persistence service responsible for the accounting transaction. A Spring transaction can replace the explicit JDBC boundary once its proxy behavior and database constraints are verified. This project does not include or test Spring wiring.

What the tests actually prove

The project ran on Eclipse Temurin 21.0.11+10, Maven 3.9.11, xrpl4j-core 6.0.0, JUnit Jupiter 5.13.4 and H2 2.2.224. The final baseline passed 21 tests, with zero failures, errors or skipped tests.

The tests cover provisional success, validated failure, missing metadata and ledger index, mismatched network and hash, incorrect destination and tag, wrong currency or issuer, and missing or unsupported delivered amounts. They also check zero, negative, malformed, insufficient and excessive values.

One JSON fixture goes through xrpl4j’s real Jackson mapper with the API-v1 result shape and the partial-payment flag. It requests 1,000,000 USD and delivers 0.01 USD. The policy returns REVIEW and produces no credit. That verifies an actual deserialization boundary, beyond tests that construct Java objects directly.

Storage tests cover rollback between receipt and outbox insertion, redelivery after a committed result is ignored by the caller, conflicts, reopening a file database, and 16 concurrent deliveries that produce one receipt and one outbox row.

We then changed the implementation three times without changing the tests: bypass validation, ignore the issuer, and use the requested amount. Each variant compiled and failed assertions. Restoring the original source restored the passing baseline. The logs and mutation results are included with the source.

To reproduce:

# Install Java 21 and Maven, then enter the examples directory.
mvn -B clean test
python verify_mutations.py /path/to/mvn
# On Windows, pass the full path to mvn.cmd instead.

Download the tested example project. These are controlled integration tests with real library models and a real H2 database. They do not submit transactions, use private keys, validate a live node, test API-v2 interoperability, benchmark production throughput or simulate abrupt process termination. A deployment needs separate tests for its endpoint/API version, database, outage recovery and operational trust assumptions.

Outgoing payments need a recovery model too

The sample above handles incoming evidence. Outgoing submission is a separate workflow. Persist the signed transaction’s identity and recovery data before submitting it. Treat the submission response as provisional; reconcile by hash against validated ledgers. Use LastLedgerSequence to bound inclusion. XRPL reliable transaction submission

A timeout must not trigger a freshly signed payment automatically. First establish the original transaction’s outcome. Even txnNotFound can reflect unavailable history; it is not sufficient evidence of failure. Expiry handling needs the relevant ledger range and its completeness. XRPL tx API: not-found responses

Build around the evidence your business needs

XRPL’s expanding financial feature set makes Java integration more interesting. It also makes vague success handling harder to defend.

Start with a business invariant: this invoice can receive one credit for the right asset, recipient and delivered value, supported by final ledger evidence. Give each responsibility a clear owner. Preserve enough information to explain the decision later. Test the cases that would create an incorrect credit, and make sure those tests reject broken implementations.

That is the engineering opportunity behind the trend: financial infrastructure that connects ledger events to business decisions through explicit, reviewable contracts.