Consensus & Payment Latency
Instant payments settle on cbdc-node, a Cosmos SDK chain whose blocks are produced by
CometBFT (the consensus engine formerly known as Tendermint). This page explains how that
consensus works, and then uses it to answer the practical question: how long does an instant
payment take to be included on-chain?
The short version: the chain commits a block roughly every 600 ms, but that number is the cadence between blocks, not a fee every transaction pays in full. A payment's inclusion time depends on where inside the block window it arrives — typically 300–350 ms on average, often well under 600 ms, occasionally above it.
Part 1 — How Cosmos consensus works
BFT consensus, not mining
CometBFT is a Byzantine Fault Tolerant consensus engine: a fixed set of validators takes
turns proposing blocks, and a block is final the moment more than ⅔ of the voting power signs
it. There is no mining, no probabilistic confirmation, and no reorgs — once a block commits,
it is irreversible. This is why an instant payment can be marked FINALIZED after observing a
single commit, with no "wait for N confirmations" logic anywhere in the platform.
The local development chain (init-chain.sh) runs a single validator, so all the voting
steps below happen inside one process and complete in single-digit milliseconds. On a production
topology with geographically distributed validators, the same steps involve network gossip and
take longer.
The lifecycle of one block
For every block height, each validator walks through the same state machine:
- Commit + execute. Block N is committed and executed by the application (bank sends, group votes, mints…).
- The
timeout_commitwait. The node then deliberately idles. Per the CometBFT configuration reference, this is "how long a validator should wait after committing a block, before starting on the new height (this gives us a chance to receive some more precommits, even though we already have +2/3)". On our single-node localnet there are no stragglers to wait for — the wait simply is the block interval. - Propose. The proposer for the next height builds a block from whatever is in its mempool at that moment and broadcasts it. This is the crucial detail for latency: the mempool is read at the end of the wait, so any transaction admitted during the wait rides the very next block.
- Prevote → Precommit. Two voting rounds; when >⅔ of voting power precommits, the block is decided.
- Commit — and the cycle repeats.
This repo's configuration
apps/chain/init-chain.sh tunes the localnet for instant payments:
# Fast blocks for instant payments: commit every 600ms instead of the 5s
# default so retail transfers settle in sub-second block time.
sed -i 's|^timeout_commit = .*|timeout_commit = "600ms"|' "$CONFIG"
sed -i 's|^timeout_propose = .*|timeout_propose = "600ms"|' "$CONFIG"
timeout_commit = 600ms— the idle wait described above; effectively the block interval.timeout_propose = 600ms— how long a validator waits to receive a proposal before voting nil. On a single-node chain the proposer is itself, so this never adds latency; on a multi-validator network it's the cost of a missed/slow proposer (a failed round).create_empty_blocksis left at its default (true), so blocks are produced on cadence even when idle.
Note that 600 ms is a floor, not the exact interval: the real block time is
timeout_commit plus proposal assembly, the voting round, and block execution. Near-empty
blocks add a few milliseconds; heavy blocks add more.
Block timestamps are not commit timestamps
A block's header Time is BFT time: the voting-power-weighted median of the timestamps in
the previous block's precommit votes (Header.Time == median(LastCommit)). In other words,
block N+1 carries a timestamp from roughly when block N reached consensus — about one block
interval (~600 ms) in the past.
This matters for anyone reading payment timing data: a payment's chainBlockTimestamp will
routinely be earlier than the moment the API broadcast the transaction. That is the protocol
working as designed, not clock skew. The API's TimingCollector therefore keeps the consensus
timestamp for audit only, never uses it as a phase boundary, and only flags clockSkewDetected
when the block time lands after the locally observed commit (a genuine sign the chain clock
runs ahead).
Does commit mean success?
Two different "can it fail?" questions hide here, with opposite answers.
Can a committed block be reverted? No — never. This is where CometBFT differs from Ethereum-style chains, which have a gap between "included" and "finalized" (and, historically, reorg risk — the origin of the "wait for N confirmations" habit). A CometBFT block only commits because more than ⅔ of the voting power precommitted it, and the protocol makes a conflicting block at the same height impossible without ⅓+ of validators double-signing — a slashable, detectable fault, not something that happens by network luck. One commit is finality; a deeper block is not "more final". There is no later "finality event" to wait for.
Can a transaction fail even though its block committed? Yes. A transaction passes two independent gates:
- CheckTx (mempool admission) — what
broadcastTxSynccovers. A lightweight check: signature valid, sequence number correct, fees payable. Passing it does not guarantee the transfer will succeed. - Execution (
FinalizeBlock) — the real state transition, run only when the block commits. It can fail even after CheckTx passed: insufficient funds by execution time (another transaction in the same block drained the account first), out of gas, an application-level rejection. The block still commits normally — it simply records the transaction with a non-zero result code.
So "block committed" ≠ "payment succeeded", and the platform never conflates them. The polling
loop in instant-payment-chain.service.ts waits until the transaction appears in a committed
block and checks its execution result:
const indexed = await stargate.getTx(txHash);
if (indexed) {
if (indexed.code !== 0) {
// Included in a final block, but the transfer itself failed on-chain.
throw new BusinessException(InstantPaymentErrorCode.INSTANT_PAYMENT_CHAIN_BROADCAST_FAILED);
}
// code === 0: settlement succeeded — only now does the payment proceed.
}
Only a code === 0 result lets the payment advance to FINALIZED; a non-zero code marks it
FAILED and the sender's debit is compensated. The send service also pre-checks the settlement
account balance before debiting anything, but a pre-check cannot eliminate the race (a
concurrent payment from the same account can land in the same block) — the post-commit result
code is the authoritative verdict.
In short: the platform waits for exactly the right event — not extra blocks (pointless under
absolute finality), but the successful execution result inside the one committed block.
Verifying success costs nothing extra: the poll that detects the commit and the code check are
the same step, so it is already inside finalizationMs.
Part 2 — How long does an instant payment take?
Which payments touch the chain at all
INTERNAL(same bank): pure ledger movement in PostgreSQL — no chain transaction. These complete in a few milliseconds.CROSS_ORGANIZATION(different banks): the sender bank's settlement account transfers wholesale CBDC to the recipient bank's settlement account on-chain. These are the payments consensus timing applies to.
The phases the API measures
For a cross-bank payment, InstantPaymentSendService stamps each boundary with a local
wall-clock observation (see payments/timing-collector.ts); the phases always sum to the total:
| Phase | Boundary | Typical cost |
|---|---|---|
preparationMs | request received → tx built | tens of ms (DB work) |
signingMs | tx built → signed | a few ms |
sendMs | signed → accepted into mempool (CheckTx) | a few ms |
finalizationMs | mempool acceptance → commit observed | ~10–700 ms (see below) |
responseMs | commit observed → recipient credited | a few ms |
The block-window model
finalizationMs is governed by where inside the block window the transaction lands. Five
transactions (A–E), five different waits:
Linear time scale; ceremony slivers (~15 ms real) drawn at minimum visible width. Values from the
localnet config (timeout_commit = timeout_propose = 600 ms, single validator). Hover a lane to
isolate it.
The mempool is reaped when a block is proposed — at the end of the 600 ms wait. A, B and C are the same mechanism with different luck: their wait is purely where in the window they landed. Think of a train that departs every 10 minutes: your wait depends on when you reach the platform, and it is almost never the full 10 minutes. D (doors already closed) and E (a failed round) are the two over-interval cases, unpacked in the next section.
Expected distribution: roughly uniform between ~10 ms and ~610 ms, averaging ~300–350 ms. Sub-600 ms inclusion is the majority case, not an anomaly.
The ~15 ms ceremony is a single-machine luxury — proposing and voting are function calls inside one process. With physically distributed validators, the ceremony becomes 3–4 network round trips (proposal gossip, prevote collection, precommit collection), paced by the slowest validator still needed to reach ⅔. Globally scattered sets pay 500 ms–1.5 s per ceremony; a permissioned national CBDC set (central bank + participant banks, domestic data centers, 1–20 ms RTTs) keeps it in the tens of milliseconds. As the ceremony widens, the block interval grows, D's miss-window stops being rare, and E's failed rounds become possible at all.
Why some payments exceed 600 ms
Values above one block interval are also normal. In rough order of frequency:
- Measurement overhead (~0–150 ms). The API detects the commit by polling
getTxevery 100 ms, and the node's transaction indexer is populated slightly after the block commits. So measuredfinalizationMs= true latency + indexing lag + up to one poll interval. A payment in the 600–700 ms band was most likely included within the window, with overhead on top. - Just missing the proposal cut (~600–1250 ms). A transaction admitted after the proposer reaped the mempool but before that block commits must wait for the following block: the in-flight round plus a full window. On the localnet this danger zone is only a few milliseconds wide, so this is an occasional outlier.
- The interval itself stretching. 600 ms is a floor; heavy block execution lengthens the cadence and every pending transaction's wait with it.
- Extra consensus rounds (multi-validator networks only). A slow or offline proposer costs
a
timeout_proposeand a new round; gossip between distributed validators adds tens to hundreds of ms. This is the mechanism behind multi-second tails in production topologies — it cannot happen on a single-validator localnet. - Full blocks. If a block hits its byte/gas limit, overflow transactions wait extra intervals. Not a factor at pilot volume.
The API guards the pathological case (stalled node, evicted tx) with a 20-second finalization
timeout, after which the payment is marked FAILED and the sender is compensated.
Where to see the numbers
- Per payment: the payment detail page in the Portal renders the full phase breakdown (Timing card) plus the total as "Settlement time".
- Aggregated: the Instant Payments dashboard tab shows average and p95 settlement latency, split cross-bank vs same-bank — the two populations have unrelated latency profiles (~milliseconds vs ~hundreds of milliseconds), so they are never blended into one average.
Remember that the UI's "Settlement time" is the end-to-end total (request → recipient credited), so totals modestly above 600 ms are expected even when chain inclusion itself was fast.