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.
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.
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.
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.
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.
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.
The cursor only moves forward
block_offset reports progress; it is not the dedup key, so out-of-order
delivery cannot rewind it.
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.
Any other error is queued for retry
...while the cursor still advances. One poison block must not stall every real block 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.
Nothing is silently lost
findOrphanedHeights(chainId) reports every height that needs attention and that
no other mechanism owns.
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 applied | Tests that failed |
|---|---|
| Never expire a lease | lease reclaim, claim-without-start-time |
Drop the COMPLETED short-circuit | replay idempotency |
| Drop the "already queued" filter from the orphan scan | two orphan tests |
| Collapse batching into a single pass | both wide-block tests |
From raw block to "this transaction is yours"
UtxoBlockClassifier applies five rules, in order, to every
batch of transactions in a block:
- Index every output that carries an address. Data outputs (e.g.
OP_RETURN) and address-less outputs are ignored. - 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. - A watched receive address in an output → money arriving. Classified
RECEIVE. - A watched change address in an output → one of your own spends, seen via its change.
Classified
SEND. - Anything left over may still be a spend that produced no change —
so the engine asks your
OwnedSendRegistry.
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.
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:
- The block wasn't published yet — deliberately never queued for retry — and a later height has since moved the cursor past it.
- 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.
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.
| Interface | Question it answers |
|---|---|
| BlockSource | Give me the block published at this key. |
| WatchedAddressRegistry | Do I own any of these addresses? |
| OwnedSendRegistry | Did I broadcast this transaction? |
| TransactionSink | Here 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");
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);
}
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
COMPLETEDheight 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.
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.