A Blockchain Event Is Not Final: Building a Reorg-Safe Payment Indexer
· By Rizkyy. · 10 min read
On this page
A Blockchain Event Is Not Final: Building a Reorg-Safe Payment Indexer
A blockchain transaction can succeed, emit an event, and appear in the latest block—and still disappear.
That sounds contradictory until we separate two ideas: observation and finality. An event tells us what one version of the chain contains right now. It does not immediately prove that this version will remain canonical.
This distinction matters in payment infrastructure. If an application treats the first observed event as final, it may mark an invoice as paid, update its ledger, notify the merchant, and release goods before the block containing that payment is reorganized away.
While building Mayarin's on-chain payment path, I needed an indexer that could answer a stricter question:
When is a
PaymentCompletedevent stable enough to change the off-chain payment state?
The resulting design is intentionally small. It does not attempt to predict reorgs or create absolute finality. It records observations, checks them against the canonical chain, waits for a configurable confirmation depth, and keeps watching for a bounded period afterward.
This post explains the reasoning, algorithms, and failure modes behind that design.
The event is evidence, not yet a command
Mayarin's PaymentRouter emits PaymentCompleted after it receives the payer's asset, optionally swaps it, and pays the merchant wallet. The event includes the payment intent identifier, transaction amounts, fee, refund, and merchant address.
The naive implementation would subscribe to that event and immediately call the clearing engine:
onPaymentCompleted((event) => {
clearingEngine.recordPaymentCompleted(event.intentId, event);
});This couples payment completion to the node's latest view of the chain. A one-block reorg could then erase the event after the application has already made an irreversible business decision.
Instead, I treat every new log as a pending observation:
PaymentCompleted log
↓
record as PENDING
↓
compare its block hash with the canonical chain
↓
wait for confirmation depth
↓
mark CONFIRMED
↓
tell the clearing engineThe event enters the database immediately, so it is visible and auditable. But it does not touch the payment state or ledger until it crosses the configured finality line.
Counting confirmations correctly
Let:
- be the block number containing the event;
- be the current canonical head;
- be the number of confirmations.
I count confirmations inclusively:
The is important. An event in the current head block already has one confirmation, not zero.
For example, if the event was recorded in block and the head is block :
With a required depth of , the event becomes eligible for confirmation at exactly that point.
Confirmation depth is a policy, not a universal constant. A testnet, an L2, and Ethereum mainnet may justify different values. The indexer accepts the depth as configuration because the business risk belongs to the deployment, not to a hard-coded opinion inside the domain logic.
Confirmations are necessary, but hashes detect the reorg
Block height alone cannot tell us whether the original block is still part of the canonical chain. A reorg can replace a block while leaving the chain at the same height.
For every observed event, the indexer stores both:
where is the block hash reported when the log was first read.
During reclassification, it asks the chain for the current canonical hash at the same height, , and compares them:
The status rule is:
If the current head is temporarily below , there is no canonical hash to compare yet. That absence is not proof that the event was orphaned, so the safe classification remains PENDING.
Once an observation becomes ORPHANED, that status is terminal. A block that left the canonical chain does not later return under the same hash. Keeping the record preserves the audit trail rather than pretending the event was never observed.
A tick is one complete, bounded pass
The indexer does not rely on a long-lived subscription being perfectly available. It operates as repeated, deterministic passes. One tick(chain) performs five steps:
- Read the current head and the persisted cursor.
- Scan a bounded range for
PaymentCompletedlogs. - Record new observations as
PENDING. - Reclassify probable events against canonical block hashes.
- Hand confirmed, uncompleted events to the clearing engine.
- Persist the scan cursor last.
If is the last scanned block and is the maximum block range, the next pass scans:
Bounding the range prevents a large backlog from creating an oversized RPC request. It also gives each pass predictable work and lets the process make incremental progress after downtime.
The stream cursor is keyed by both chain and router address:
That distinction matters because the same chain may have other indexed streams, such as token transfers. A cursor keyed only by chain could allow one stream to skip blocks that another has not processed.
Why the cursor must be written last
The cursor is a promise: “everything through this block was processed.” Writing it too early can turn a crash into permanent data loss.
Consider the unsafe order:
advance cursor to block 1,000
↓
record logs from blocks 901–1,000If the process crashes between those operations, the next pass starts at block . Events in the skipped range may never be seen again.
The safe order is:
scan the range
↓
record and classify events
↓
complete eligible payments
↓
write the cursor lastA crash before the final write causes the next pass to scan the same range again. That is safe only because ingestion is idempotent.
Every log is uniquely identified by its envelope:
The database enforces uniqueness on this tuple. Replaying a range therefore finds the same logical event instead of creating a duplicate.
The combination is more important than either mechanism alone:
Cursor-last without idempotency would duplicate records. Idempotency with cursor-first would still allow missing records. Together, they make at-least-once scanning produce an effectively-once observation.
Reclassification must run even when there are no new blocks
One subtle bug appears when an indexer returns early because the head has not advanced:
if (from > to) return;A reorg can replace blocks without increasing the head number. In that case there is no new range to scan, but previously recorded events may now point to non-canonical hashes.
The indexer therefore skips only the log scan when . It still reclassifies every event inside the watch window.
This separates two independent questions:
- Are there new blocks whose logs I have not read?
- Are the blocks containing existing observations still canonical?
The answer to the first can be “no” while the answer to the second has changed completely.
Bounding the reorg watch window
Re-reading block hashes for every historical payment forever would create unbounded work. The indexer needs an explicit point after which it accepts the chain's finality assumption.
Let:
- be the confirmation depth;
- be the configured reorg-watch multiplier;
- be an event's current confirmations.
The event remains eligible for canonical-hash probing while:
With and , the indexer watches through twelve confirmations:
At six confirmations the event may complete the payment. From confirmations seven through twelve, it remains under reorg observation. After twelve, it is treated as final and no longer consumes probe work.
This is not mathematical proof that a deeper reorg cannot happen. It is a bounded operational policy that makes the assumption visible, configurable, and testable.
The probe is also grouped by block height. If payment events appeared in one block, the indexer reads that canonical block hash once and reuses it for all events. The cost depends on the number of distinct observed heights, not simply on the number of payments.
Completing the payment at most once
A confirmed settlement is handed to the clearing engine only when it has no completion timestamp:
After the clearing engine accepts the event, the indexer sets completedAt. Future passes can continue probing the block for reorgs, but they do not repeatedly complete the payment.
This is at-most-once delivery at the indexer boundary, with idempotency underneath as a second defense. The clearing engine itself returns early if the payment has already moved beyond PAYMENT_PENDING.
The event carries the values observed on-chain—settled amount, fee, and refund—not merely the values that were quoted earlier. The blockchain is the source of truth for what the contract actually moved, and the ledger should reconcile against that evidence.
An unmatched settlement is a reconciliation finding
The router emits PaymentCompleted only for an order signed by the backend. Therefore, a confirmed event whose intentId matches no known payment is not random chain noise. It means the chain and the application database disagree.
The indexer publishes an unmatched-settlement event and marks the observation as handled. It does not repeat the same lookup on every pass.
This behavior may seem surprising. Why not keep retrying forever?
Because not every mismatch is transient. A non-retryable refusal can otherwise become a permanent loop that blocks every valid settlement behind it. Surfacing one durable reconciliation finding is more useful than producing the same error every few seconds.
Retryable infrastructure failures are different. If the database or RPC provider is temporarily unavailable, the error propagates and the cursor remains unchanged. The next pass safely replays the range.
What happens when a confirmed event is later orphaned?
Before completion, the answer is simple: mark it ORPHANED and never tell the clearing engine.
After completion, there may be no safe automatic reversal.
By that time the merchant may already have been paid and notified. The clearing state machine is intentionally forward-only; SETTLED → unsettled is not a legal transition. Automatically inserting a compensating ledger entry would describe a treasury loss, but it would not recover the missing funds.
The indexer therefore records and publishes the reorg with wasCompleted: true. It becomes a human reconciliation and treasury incident, not a pretend rollback.
This is an uncomfortable but honest boundary:
Software can detect that finality failed. It cannot reverse an external economic action merely by changing a database row.
Tests that matter more than the happy path
The core scenarios I test are about ambiguity and repetition:
- A new event remains pending below the configured depth.
- It completes at exactly the required confirmation count.
- Multiple passes tell the clearing engine only once.
- Rewinding the cursor and rescanning does not duplicate the log.
- A block-hash mismatch marks an event orphaned.
- A reorg is detected even when the head number does not change.
- An orphaned event never completes a payment later.
- A post-completion reorg emits a human-review event.
- An unmatched settlement is surfaced once rather than retried forever.
- A retryable sink failure leaves the cursor behind so the next pass can recover.
These tests use a scripted fake chain. They mine blocks, replace canonical blocks, and run tick() directly. There are no timers or sleeps, which keeps the edge cases deterministic.
The policy itself is pure: given block numbers, hashes, and configuration, it returns PENDING, CONFIRMED, or ORPHANED. That separation makes the finality rules easy to test without a node, database, or network.
Lessons I would reuse
Building this indexer reinforced several principles that apply beyond blockchain payments:
- An event is an observation before it is a business decision. Record it first, then apply the policy that makes it actionable.
- Store evidence, not just conclusions. Block number and block hash let the system later verify whether its observation remains canonical.
- Write progress markers last. A cursor should advance only after all work it represents has completed.
- Make replay harmless. Stable event identity turns crash recovery into ordinary rescanning.
- Separate new-data scanning from old-data validation. A system can have no new input while previously seen input becomes invalid.
- Bound ongoing verification explicitly. A finite watch window makes finality assumptions operational rather than accidental.
- Do not fake reversibility. When external value has moved, record a late contradiction and escalate it honestly.
- Distinguish retryable outages from reconciliation findings. One should be retried; the other should be surfaced.
Closing thought
Blockchain applications often speak about events as though they were immediate facts. Payment infrastructure cannot afford that shortcut.
The safer model is:
Each arrow represents a policy decision, not just elapsed time.
A reorg-safe indexer does not promise that history can never change. It makes the system wait before acting, preserves enough evidence to detect change, and responds honestly when the finality assumption fails.
That is the difference between merely reading blockchain events and building payment infrastructure on top of them.