Java 21 · Spring Boot · Backend Engineering

multichain-sync

A pluggable block-sync engine for UTXO chains. It works out which transactions in a block belong to addresses you care about — and survives duplicate delivery, out-of-order blocks, a height that isn't published yet, one poison block, and a worker that dies mid-block.

Java 21 Spring Boot Gradle H2 / JPA JUnit 5
In plain English

What this actually does

A blockchain publishes blocks of transactions, one after another. A business sitting on top of it — an exchange, a wallet, a payments desk — needs to know which of those transactions touch its own customers, and which direction the money moved. That sounds like a lookup. It's actually a reliability problem, because the stream of blocks arriving at your system is never as clean as "one block, once, in order."

This engine is the piece that answers "is this transaction mine, and which way did it go?" reliably — even when the same block turns up twice, blocks arrive in the wrong order, the source is running behind the real chain, or the process reading a block gets killed halfway through.

Think of it like a post office sorting sacks of mail into the right household mailboxes. Sacks sometimes get delivered twice. Sometimes Tuesday's sack shows up before Monday's. Sometimes a sack is delayed a day and simply hasn't arrived yet — that's not a lost sack, just a late one. And every so often the sorter collapses halfway through a sack and someone else has to pick up where they left off, without double-delivering the letters already sorted. This engine is the sorting room: it makes sure every letter reaches the right box exactly once, no matter which of those things happens.

Why this is harder than it looks

The hard part isn't parsing a block

Reading JSON and matching addresses is the easy 10%. The other 90% is everything happening around that one block:

The same height, twice

An at-least-once source redelivers. The same block can arrive again minutes — or days — later, and processing it twice must not double-count anything.

Out of order

Blocks don't always land in height order. A tracking cursor that isn't careful can be rewound by a late arrival and quietly re-open work that already finished.

Behind the chain tip

Your indexer lags the real chain routinely. A height that "isn't there yet" is not a failure and must not be treated like one.

One poison block

A single malformed block must not stall the thousand real blocks behind it — but it also can't just be silently dropped.

A worker dies mid-block

A pod gets evicted halfway through processing. Whatever claim it took on that height must not become a permanent lock nobody ever clears.

Fixes fight each other

Each of the above has an obvious-looking fix, and each obvious fix quietly breaks one of the others. This engine picks one consistent set of tradeoffs and states them, rather than leaving them as an accident.

Architecture

One engine, four extension points

The engine owns exactly three tables — its idempotency and lease contract — and delegates everything chain-specific or application-specific to four small interfaces you implement. Dashed boxes below are the pluggable extension points; solid boxes are the engine's own code.

multichain-sync architecture A block source feeds BlockSyncEngine, which owns three tables (block_offset, block_processing_details, block_processing_failed) and hands batches to UtxoBlockClassifier. The classifier consults WatchedAddressRegistry and OwnedSendRegistry, then passes classified transactions to TransactionSink, which hands off to the host application. BlockSource, WatchedAddressRegistry, OwnedSendRegistry and TransactionSink are the four pluggable interfaces. BlockSource pluggable (files / storage / API) BlockSyncEngine cursor · idempotency · leases · batching · retry block_offset block_processing_details block_processing_failed processBlock(chain, height) findOrphanedHeights(chain) batches of ≤ chainsync.batch-size txs UtxoBlockClassifier address matching, send / receive rules, changeless-send recovery WatchedAddressRegistry pluggable OwnedSendRegistry pluggable TransactionSink pluggable the important one your application
Dashed = pluggable interface (you implement) Solid = engine's own code
Interactive walkthrough

Step through what the demo actually does

The engine ships with a runnable demo (DemoRunner) that plays out eight scenarios against two chains. Step through them below and watch the engine's three tables change — block_offset (the cursor), block_processing_details (status per height), and block_processing_failed (the retry queue).

This is an illustrative simulation of the engine's actual logic, precomputed from the real demo scenarios and fixtures — not a live backend. Table state below reflects exactly what BlockSyncEngine would do for each step, in order.

changed this step read, unchanged found by orphan scan
The guarantees

Six guarantees, each with a test — and a mutation to prove the test isn't vacuous

Every guarantee below is backed by a test, and every test was checked against a mutation that should have broken it, to confirm it actually asserts something.

Duplicate delivery is harmless

Dedup is by (chain, height) in block_processing_details. A COMPLETED height is skipped outright.

Without it: a redelivered block gets credited twice.

The cursor only moves forward

block_offset reports progress; it is not the dedup key, so out-of-order delivery cannot rewind it.

Without it: a late, out-of-order block could rewind the cursor and re-open work that already completed.

An unpublished block is not a failure

Sources lag the chain tip routinely. Those heights are not queued for retry and don't move the cursor, so they stay naturally eligible.

Without it: normal lag floods the retry queue with heights that were never actually broken.

Any other error is queued for retry

...while the cursor still advances. One poison block must not stall every real block behind it.

Without it: one malformed block halts the entire chain behind it.

A crashed worker doesn't strand a height

An IN_PROGRESS row is a lease, not a permanent claim. Once lease-timeout-seconds elapses, another worker reclaims it.

Without it: a process killed mid-block leaves a row nothing ever clears, and that height is skipped forever.

Nothing is silently lost

findOrphanedHeights(chainId) reports every height that needs attention and that no other mechanism owns.

Without it: heights left behind by the monotonic cursor become permanently unreachable, with no signal that they exist.

Mutation testing: which mutation broke which tests

Each guarantee was checked against a deliberate mutation of the code that should have violated it. If the corresponding tests hadn't failed, the tests weren't testing anything real.

Mutation → tests that caught it
Mutation appliedTests that failed
Never expire a leaselease reclaim, claim-without-start-time
Drop the COMPLETED short-circuitreplay idempotency
Drop the "already queued" filter from the orphan scantwo orphan tests
Collapse batching into a single passboth wide-block tests
How classification works

From raw block to "this transaction is yours"

UtxoBlockClassifier applies five rules, in order, to every batch of transactions in a block:

  1. Index every output that carries an address. Data outputs (e.g. OP_RETURN) and address-less outputs are ignored.
  2. Ask your registry which of those addresses you watch. The engine keeps no address table of its own — that's entirely the host's WatchedAddressRegistry.
  3. A watched receive address in an output → money arriving. Classified RECEIVE.
  4. A watched change address in an output → one of your own spends, seen via its change. Classified SEND.
  5. Anything left over may still be a spend that produced no change — so the engine asks your OwnedSendRegistry.
Why step 5 exists

Steps 3 and 4 are both blind to one specific case: a spend that moves an entire balance out. A normal spend leaves change — a small output back to yourself — and that change output is what steps 3 and 4 detect. But when a spend empties the whole balance, there is no change output at all, and therefore no watched address appears anywhere in the transaction. Address matching has nothing to match against. The only party who can possibly know the transaction was yours is whoever broadcast it — so the engine hands the unmatched transaction hashes back to the host and asks, via OwnedSendRegistry, "did you send this?" If the host says yes, the transaction is recovered as a SEND with no matched address. It's the subtlest rule in the engine, and it exists specifically because pure address-matching cannot see this case at all.

The orphan scan

A monotonic cursor has a blind spot — this is how it's covered

Because block_offset only ever moves forward, and it advances past failures on purpose (so one poison block can't stall the chain), any height left behind stops being reachable by a cursor-driven scheduler. A scheduler that just asks "what's the next height after the cursor?" will never look backwards.

There are exactly two ways a height ends up stranded like this:

  1. The block wasn't published yet — deliberately never queued for retry — and a later height has since moved the cursor past it.
  2. A worker claimed it and died, and the lease has since expired.

findOrphanedHeights(chainId) finds both, and excludes any height already sitting in the retry queue — a retry worker already owns those. Feed the result back into processBlock on a schedule; any height that has since become available completes and drops off the list on its own.

Extending it

Implement four interfaces. Nothing else changes.

The engine's only per-chain knowledge is where that chain's blocks live. Everything else — classification rules, idempotency, leases, batching — is identical across every UTXO chain.

The four pluggable interfaces
InterfaceQuestion it answers
BlockSourceGive me the block published at this key.
WatchedAddressRegistryDo I own any of these addresses?
OwnedSendRegistryDid I broadcast this transaction?
TransactionSinkHere is what I found — do your thing.

TransactionSink is the important one: it's the line between "which transactions in this block are mine?" — generic, chain-shaped, and what the engine answers — and "what should happen as a result?", which is entirely yours: credit a balance, persist a UTXO, advance an order, run a compliance check, send a notification.

Adding a whole new chain is one line, because that's the entire per-chain surface:

public static final ChainProfile DOGE = new ChainProfile("doge", "doge-mainnet");
public static final ChainProfile DOGE = new ChainProfile("doge", "doge-mainnet");

And the whole of TransactionSink — the boundary the engine hands off across — is genuinely this small:

public interface TransactionSink {

    /**
     * Called once per processed batch, with the transactions in that
     * batch that matched a watched address. Never called with an
     * empty list.
     */
    void accept(List<ClassifiedTransaction> transactions);
}
public interface TransactionSink { void accept(List<ClassifiedTransaction> transactions); }
Limitations, stated plainly

Where the scope deliberately stops

These are the things that would bite someone adopting this as-is. They're not gaps that were missed — they're a scope boundary that was drawn on purpose and written down, rather than discovered the hard way in production.

  • No reorg handling. The cursor ratchets forward and a COMPLETED height is never revisited. A host that needs reorg handling must detect it and reset the affected rows itself. The engine assumes a settled chain.
  • Address matching is exact. No normalisation. Chains with more than one textual encoding — Bitcoin Cash CashAddr versus legacy, Ethereum checksummed versus lowercase hex — will silently fail to match if the block source and the registry disagree. Keeping both sides in the same encoding is the host's responsibility.
  • No transaction boundaries. A wide block hands earlier batches to the sink before a later batch can fail, so sinks must be idempotent per transaction. Making the whole block atomic would mean holding a database transaction open across the entire sink — worse.
  • UTXO chains only. Account-based chains have a different transaction shape and would need a second classifier alongside UtxoBlockClassifier.
  • The orphan scan is O(candidates). One lookup per candidate height. It's a recovery scan run on a schedule, not a hot path — but it isn't a single query either.
  • It's an application, not a library. To consume it elsewhere, split the engine and the demo into separate Gradle modules and publish the engine.

Stated this plainly on purpose: knowing where a system stops is part of knowing what it does.

Run it yourself

No credentials, no network, no blockchain node

Everything runs in-process against an in-memory H2 database. The demo reads sample blocks from the classpath — there's nothing to configure.

Run the demo

./gradlew bootRun

Plays out all eight scenarios above and prints what happened.

Run the tests

./gradlew test

22 tests across three test classes, including a 1,201-transaction block that spans three classification batches.