Skip to content

Fix blockchain state mismatch across RPCs for the same head#950

Merged
tcsenpai merged 78 commits into
stabilisationfrom
mempool-mismatch
Jul 1, 2026
Merged

Fix blockchain state mismatch across RPCs for the same head#950
tcsenpai merged 78 commits into
stabilisationfrom
mempool-mismatch

Conversation

@cwilvx

@cwilvx cwilvx commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

This pull request fixes the state mismatch across RPCs for a multinode network. The mismatch occured when the node had more nodes than the ones participating in the consensus, and the non-participants synced the gcr via the sync routine.

What this PR introduces the following behavioral changes to the nodes and the network:

  1. Failed transactions will now be included in a block, with their status set to FAILED and the reason for failure provided as transaction metadata in tx.attrs.code and tx.attrs.message. The user can fetch the transaction by hash and check if the tx was confirmed or it failed.
  2. We now have a .attrs object on both transactions and blocks. The field is currently used to track the reference block and failure reasons for transactions, and applied transactions order for blocks. The field
  3. A block is only valid if forged by 2/3 + 1 of shard and 2/3 + 1 of the shard size. With a shard size of 4, you require at least 3 validator nodes to be online for the network to forge new blocks. This currently fixes our forking problem.
  4. Clients are allowed to submit transactions with a nonce > expected current nonce. If the nonce is less than expected during the next consensus round, the tx will fail. If greater, the tx will be kept in the mempool. Eventually the tx will be finalized or will expire due to the aging reference_block.
  5. A block is now verified against the existing head before insert. If anything in the hashes or signatures doesn't align, the node does not accept the block.
  6. Transactions are now sorted deterministically in order of nonce in the consensus.
  7. Some Mempool.addTransaction calls are gated with a mutex to prevent tampering with the mempool during merging.
  8. I've left process.exit code blocks with a comment NODE_CRITICAL_DEBUG (DO NOT REMOVE COMMENTED OUT CODE) all over the main sync and consensus source files. The code guards against behavior that causes state drift across RPCs. Please do not remove for now.

Summary by CodeRabbit

  • New Features
    • Added support for storing per-block and per-transaction metadata (“attrs”) and expanded transaction status handling (confirmed/failed/pending).
  • Bug Fixes
    • Strengthened validation across block, transaction, and consensus flows, tightening signature/quorum checks and improving coherence/edit verification.
    • Reduced duplicate processing by serializing critical mempool/relay/sync paths with lock-based admission.
    • Updated snapshot data (chain state, block hashes, checksums, and validator set).
    • Adjusted runtime script defaults to disable TLSNotary and monitoring unless overridden.

Comment thread src/libs/blockchain/chainTransactions.ts
Comment thread src/libs/consensus/v2/routines/deterministicOrder.ts Outdated
Comment thread src/libs/blockchain/validation/verifyBlock.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/libs/blockchain/mempool.ts (1)

309-315: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Preserve duplicate-nonce rejection under the future-nonce policy.

The locked re-check now only rejects txNonce <= accountNonce, so two transactions from the same sender with the same future nonce can both pass after serializing through the advisory lock. Keep accepting nonce gaps, but also reject an existing pending transaction for the same sender and nonce before save.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/blockchain/mempool.ts` around lines 309 - 315, The nonce validation
in mempool handling now only checks against the on-chain account nonce, so
duplicate future nonces can slip through after the advisory lock. In the same
flow around the account lookup in mempool.ts, add an additional
pending-transaction check for the sender and txNonce before calling save, while
still allowing nonce gaps greater than accountNonce. Use the existing
senderFrom, txNonce, and GCRMain/repository logic to reject duplicates
consistently under the future-nonce policy.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/libs/blockchain/mempool.ts`:
- Around line 309-315: The nonce validation in mempool handling now only checks
against the on-chain account nonce, so duplicate future nonces can slip through
after the advisory lock. In the same flow around the account lookup in
mempool.ts, add an additional pending-transaction check for the sender and
txNonce before calling save, while still allowing nonce gaps greater than
accountNonce. Use the existing senderFrom, txNonce, and GCRMain/repository logic
to reject duplicates consistently under the future-nonce policy.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 59ce8656-4575-4af6-be9a-7c9903e48a26

📥 Commits

Reviewing files that changed from the base of the PR and between ba44dec and dc0046f.

📒 Files selected for processing (12)
  • data/snapshot/gcr_main.jsonl
  • data/snapshot/gcr_storageprogram.jsonl
  • data/snapshot/identity_commitments.jsonl
  • data/snapshot/manifest.json
  • data/snapshot/validators.jsonl
  • src/libs/blockchain/chainBlocks.ts
  • src/libs/blockchain/chainDb.ts
  • src/libs/blockchain/chainTransactions.ts
  • src/libs/blockchain/gcr/handleGCR.ts
  • src/libs/blockchain/genesis/restoreSnapshot.ts
  • src/libs/blockchain/mempool.ts
  • src/libs/consensus/v2/PoRBFT.ts
✅ Files skipped from review due to trivial changes (1)
  • data/snapshot/validators.jsonl
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/libs/blockchain/gcr/handleGCR.ts
  • src/libs/blockchain/chainTransactions.ts
  • src/libs/blockchain/chainBlocks.ts
  • src/libs/consensus/v2/PoRBFT.ts

+ try: shuffle txs list before sort to test deterministic sort

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/libs/blockchain/routines/Sync.ts (2)

333-341: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Compute missing transactions by hash, not by list length.

A peer can return the right count with the wrong hashes or extra transactions, skipping the signer fallback and driving verifyBlockAttrs into a fatal mismatch. Build the expected-hash set first, discard extras, then fetch any missing hashes.

🐛 Proposed fix
-    if (txs.length < block.content.ordered_transactions.length) {
+    const masterSet = new Set(block.content.ordered_transactions)
+    const txByHash = new Map(
+        txs.filter(tx => masterSet.has(tx.hash)).map(tx => [tx.hash, tx]),
+    )
+    const missingTxs = new Set(
+        [...masterSet].filter(hash => !txByHash.has(hash)),
+    )
+
+    if (missingTxs.size > 0) {
         log.error(
             "[fastSync] No transactions received for block: " + block.hash,
         )
         log.debug("Trying to get transaction from signers")
-
-        const masterSet = new Set(block.content.ordered_transactions)
-        const missingTxs = masterSet.difference(new Set(txs.map(tx => tx.hash)))
             for (const tx of res) {
                 if (missingTxs.has(tx.hash)) {
-                    txs.push(tx)
+                    txByHash.set(tx.hash, tx)
                     missingTxs.delete(tx.hash)
                 }
             }
         if (missingTxs.size > 0) {
             ...
             process.exit(1)
         }
     }
+
+    txs = block.content.ordered_transactions.map(hash => txByHash.get(hash)!)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/blockchain/routines/Sync.ts` around lines 333 - 341, The fast-sync
fallback in Sync.ts is currently gated only on tx count, which can miss cases
where the peer returns the wrong hashes or extra transactions. Update the
transaction reconciliation logic in the block-processing path to build the
expected hash set from block.content.ordered_transactions first, compare against
the returned tx hashes, discard extras, and compute missingTxs strictly by hash
before deciding whether to query signers. Keep the existing logging and signer
fallback flow in the same area, but trigger it based on hash mismatches rather
than txs.length.

522-538: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Verify attrs and transactions before inserting the block.

Chain.insertBlock(...) runs before askTxsForBlock(...) and verifyBlockAttrs(...). If attrs verification exits or fails afterward, the node can restart with the bad block already persisted and skip it via the existence check.

🐛 Safer flow outline
-    await Chain.insertBlock(block, [])
-    log.debug("Block inserted successfully")
+    const txs = await askTxsForBlock(block, peer)
+    const applied = await verifyBlockAttrs(block, txs)
+
+    await Chain.insertBlock(block, [])
+    log.debug("Block inserted successfully")

Apply the same ordering in batchDownloadBlocks: fetch/resolve block transactions, run verifyBlockAttrs, then insert the block. Ideally, commit block, tx, and GCR updates in one storage transaction or rollback on any later failure.

Also applies to: 785-793

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/blockchain/routines/Sync.ts` around lines 522 - 538, Move the block
persistence step in Sync's fast sync flow so `Chain.insertBlock(...)` happens
only after `askTxsForBlock(...)` and `verifyBlockAttrs(...)` succeed, using the
same ordering already expected in `batchDownloadBlocks`. Locate the sequence
around the fastSync block handling in `Sync.ts` and reorder it to fetch
transactions, validate attributes, then insert the block; keep the peer merge
and logging around the validated flow, and prefer a single storage transaction
or rollback-safe path if later updates fail.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/libs/blockchain/routines/Sync.ts`:
- Around line 515-520: The duplicate-block recheck in Sync should not be treated
as a failure when another path inserts the same block during verification. In
the Sync routine’s second existence check around Chain.getBlockByNumber and the
“Block already exists, skipping ...” branch, change the return path so the
concurrent duplicate insert is treated as a successful no-op instead of a sync
failure, and apply the same handling in the other matching duplicate-check
block.
- Around line 48-55: Remove the test-only shuffle from the production sync path
by eliminating the use of shuffleArray in Sync and restoring deterministic
ordering in the block verification flow. Update the relevant sync routine in
Sync.ts, especially the code around the block verification sequence that
currently depends on Math.random(), so it processes items in a stable order
instead of shuffling them. Ensure any calls or helpers tied to shuffleArray are
removed or replaced with deterministic logic.

---

Outside diff comments:
In `@src/libs/blockchain/routines/Sync.ts`:
- Around line 333-341: The fast-sync fallback in Sync.ts is currently gated only
on tx count, which can miss cases where the peer returns the wrong hashes or
extra transactions. Update the transaction reconciliation logic in the
block-processing path to build the expected hash set from
block.content.ordered_transactions first, compare against the returned tx
hashes, discard extras, and compute missingTxs strictly by hash before deciding
whether to query signers. Keep the existing logging and signer fallback flow in
the same area, but trigger it based on hash mismatches rather than txs.length.
- Around line 522-538: Move the block persistence step in Sync's fast sync flow
so `Chain.insertBlock(...)` happens only after `askTxsForBlock(...)` and
`verifyBlockAttrs(...)` succeed, using the same ordering already expected in
`batchDownloadBlocks`. Locate the sequence around the fastSync block handling in
`Sync.ts` and reorder it to fetch transactions, validate attributes, then insert
the block; keep the peer merge and logging around the validated flow, and prefer
a single storage transaction or rollback-safe path if later updates fail.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5cf37eb8-965a-40ae-af5e-082de7886b06

📥 Commits

Reviewing files that changed from the base of the PR and between dc0046f and 637f27a.

📒 Files selected for processing (5)
  • src/libs/blockchain/chain.ts
  • src/libs/blockchain/chainBlocks.ts
  • src/libs/blockchain/routines/Sync.ts
  • src/libs/blockchain/validation/verifyBlock.ts
  • src/libs/consensus/v2/routines/deterministicOrder.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/libs/blockchain/chain.ts
  • src/libs/blockchain/validation/verifyBlock.ts
  • src/libs/blockchain/chainBlocks.ts

Comment thread src/libs/blockchain/routines/Sync.ts Outdated
Comment on lines +515 to +520
// check exists again
exists = await Chain.getBlockByNumber(block.number)
if (exists) {
log.error("Block already exists, skipping ...")
return false
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat a concurrent duplicate insert as success.

The first existence check returns true, but the second check returns false for the same final state. If another guarded path inserts the block during verification, this reports a sync failure even though the chain already has the block.

🐛 Proposed fix
     if (exists) {
-        log.error("Block already exists, skipping ...")
-        return false
+        log.debug("Block already exists, skipping ...")
+        return true
     }

Also applies to: 778-783

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/libs/blockchain/routines/Sync.ts` around lines 515 - 520, The
duplicate-block recheck in Sync should not be treated as a failure when another
path inserts the same block during verification. In the Sync routine’s second
existence check around Chain.getBlockByNumber and the “Block already exists,
skipping ...” branch, change the return path so the concurrent duplicate insert
is treated as a successful no-op instead of a sync failure, and apply the same
handling in the other matching duplicate-check block.

Comment thread src/libs/blockchain/routines/Sync.ts
Comment thread src/libs/blockchain/routines/Sync.ts Fixed
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

cwilvx added 2 commits June 30, 2026 21:37
+ set shard size back to 4
+ reenable tlsn + monitoring
@greptile-apps

greptile-apps Bot commented Jun 30, 2026

Copy link
Copy Markdown

T-Rex pricing update — T-Rex was free through June 2026. Effective July 1, 2026, T-Rex adds 2 credits on top of the standard 1-credit review (3 total). T-Rex settings

Historical/older-binary blocks arrive with attrs === null (nullable per the
AddAttrsToBlocks migration). Dereferencing block.attrs[...] unconditionally
crashed the sync loop with an uncaught TypeError. Treat null attrs as
'no metadata' and skip the gcrAppliedTx* checks, per the migration contract.
@tcsenpai

tcsenpai commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@greptile review

Comment thread src/libs/consensus/v2/PoRBFT.ts
NotInShardError / ForgingEndedError are caught as graceful aborts and return
without finalizing a block, but left exitReason empty. The finally-block
finalized-tx assertion (txs.length !== blockTxs.length) then fired and hit
process.exit(1) even though no block was expected. Tag these aborts with
exitReason = consensusAborted and add it to the assertion's skip allowlist.
@tcsenpai

tcsenpai commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@greptile review

Comment thread src/libs/blockchain/routines/validateTransaction.ts
confirmTransaction normalized only tx.content.from_ed25519_address; a tx that
carries the sender in the legacy content.from field passed undefined into
normalizePubkey -> forgeToHex(undefined), throwing and crashing the validator
RPC before it could return a signed invalid ValidityData. Fall back to
content.from when the ed25519 field is absent.
@tcsenpai

tcsenpai commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

@greptile review

Comment on lines +367 to +370
const txsToRemove = new Set([
...failedTxsToRemove,
...blockTxs.map(tx => tx.hash),
])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Whitelisted txs still removed
The high-nonce retry path is defeated here: failedTxsToRemove excludes whitelisted failures, but txsToRemove then unions in every blockTxs hash, including those same whitelisted transactions. A TX_NONCE_INVALID_HIGH transaction is marked failed and intended to stay queued for a later nonce, but this set removes it from the mempool at finalization, so it never gets retried.

Artifacts

Repro: focused TypeScript harness for whitelisted high-nonce finalization removal

  • Contains supporting evidence from the run (text/typescript; charset=utf-8).

Repro: harness run output showing the whitelisted hash removed

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Comment on lines +727 to +771
for (const edit of nonceEdits) {
const acct = normalizeAccount(edit.account)
const expected = nonces[acct] + 1

if (tx.content.nonce === expected) {
validTxs.push(tx)
nonces[acct]++
continue txLoop
} else if (tx.content.nonce < expected) {
// Update the transaction status as failed, but include in block
tx.status = TRANSACTION_STATUS.FAILED
tx.attrs = {
code: ErrorCode.TX_NONCE_INVALID_LOW,
message: `Invalid nonce edit. Expected ${expected}, got ${tx.content.nonce}`,
reference_block: tx.reference_block,
}
validTxs.push(tx)

log.debug(
`[TX_NONCE_INVALID_LOW] including tx as failed: ${tx.hash}: invalid nonce edit`,
)
log.debug(
"[TX_NONCE_INVALID_LOW] Invalid nonce edit for account: " +
acct,
)
log.debug(
`[TX_NONCE_INVALID_LOW] Expected ${expected}, got ${tx.content.nonce}`,
)
} else {
failedTxs.push({
txhash: tx.hash,
code: ErrorCode.TX_NONCE_INVALID_HIGH,
message: `Invalid nonce edit. Expected ${expected}, got ${tx.content.nonce}`,
})

log.debug(`[TX_NONCE_INVALID_HIGH] keeping tx: ${tx.hash}`)
log.debug(
"[TX_NONCE_INVALID_HIGH] Invalid nonce edit for account: " +
acct,
)
log.debug(
`[TX_NONCE_INVALID_HIGH] Expected ${expected}, got ${tx.content.nonce}`,
)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing continue txLoop in the low-nonce and high-nonce branches allows multi-nonce-edit transactions to be double-inserted

For a tx that carries nonce edits for more than one account, the inner for (const edit of nonceEdits) loop falls through after the low-nonce or high-nonce branch. Concretely:

  • Low-nonce then match (edit A is stale, edit B matches): the tx is marked FAILED and pushed to validTxs on edit A's iteration, then pushed to validTxs a second time on edit B's iteration — the same tx object appears twice in the block.
  • High-nonce then match (edit A is future, edit B matches): the tx is pushed to failedTxs (kept in mempool) on edit A's iteration, then also pushed to validTxs on edit B's iteration — the tx lands in both lists, is inserted into the block and simultaneously kept in the mempool for the next round.

Both cases corrupt the blockTxs set. Each mismatched branch needs continue txLoop immediately after pushing to its destination, mirroring the nonce === expected branch which already does this correctly.

@tcsenpai
tcsenpai merged commit 7295ae3 into stabilisation Jul 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants