Skip to content

feat(forks): register nonceEnforcement fork + ship assignNonce validation infra (batch C PR 1)#884

Merged
tcsenpai merged 3 commits into
stabilisationfrom
bugfix/audit-sweep-batch-c-assignnonce-2026-05-30
May 30, 2026
Merged

feat(forks): register nonceEnforcement fork + ship assignNonce validation infra (batch C PR 1)#884
tcsenpai merged 3 commits into
stabilisationfrom
bugfix/audit-sweep-batch-c-assignnonce-2026-05-30

Conversation

@tcsenpai
Copy link
Copy Markdown
Contributor

@tcsenpai tcsenpai commented May 30, 2026

Summary

PR 1 of the 3-PR batch C nonce-replay-protection sequence specified in docs/specs/audit-sweep-batch-c-nonce.md.

This commit ships the validation infrastructure behind the (still commented-out) caller in confirmTransaction. No live behaviour change in this PR.

4 files, +154 / -6.

Background

The 2026-05-28 bug-hunt audit flagged assignNonce in validateTransaction.ts:362 as a hardcoded stub returning true (// TODO Override for testing). Investigation during PR scoping found the nonce gap is 3-layered:

  1. No tx-level nonce comparison anywhere in src/libs/. SDK sends tx.content.nonce = currentNonce + 1, node persists it, never validates.
  2. No nonce-edit emissionGCRNonceRoutines.ts correctly applies nonce-typed GCREdits, but no code emits one. Account nonce stays at 0 forever.
  3. No consensus-time nonce validationhandleGCR.ts:910 blindly applies nonce edits. Two block-included txs from the same sender both carrying tx.content.nonce = N+1 both succeed (double-spend window for cross-RPC replays).

Mempool hash-dedup blocks identical re-broadcasts only — signature is part of the tx hash. Does not block cross-RPC replay before block inclusion.

Full design + PR breakdown is in the spec doc landed by the prior commit on this branch.

What this PR ships

New fork: nonceEnforcement

  • src/forks/forkConfig.ts — new ForkName + NonceEnforcementConfig (alias of BaseForkConfig; no extra payload).
  • Default registry entry with activationHeight: null so existing chains stay bit-identical until governance flips it.
  • writeForkConfig + validateForkEntry switch branches updated. The never exhaustiveness guard catches future ForkName additions at compile time.
  • isForkActive is generic, picks up the new name automatically.

Real assignNonce implementation (validateTransaction.ts)

  • Pre-fork: always returns true. Bit-identical to the previous hardcoded stub. Re-syncing pre-fork blocks is unaffected.
  • Post-fork: strict equality check tx.content.nonce === account.nonce + 1 on the sender's GCR account.
  • Read-only — no GCR mutation. The increment is emitted as a +1 nonce GCREdit by HandleNativeOperations.handle() in PR 3, applied at consensus time by GCRNonceRoutines.
  • Caller in confirmTransaction (lines 77-86) remains commented out in PR 1. PR 3 uncomments it once the consensus-side rejection (GCREdit.expectedPrior) is in place, so validation and the apply-time check ship together behind the same fork gate.

Devnet

  • testing/devnet/genesis.devnet.json gains a nonceEnforcement entry with activationHeight: 0 so fresh devnet docker boots already post-fork. Matches the existing osDenomination pattern; wipe_and_reboot.sh / docker --clean flow needs no changes.

Out of scope (subsequent PRs in this sequence)

  • PR 2: mempool-aware pending-count lookahead in assignNonce so back-to-back submissions from one sender are accepted in order.
  • PR 3: GCREdit.expectedPrior field, nonce-edit emission in HandleNativeOperations, consensus-side rejection in GCRNonceRoutines, caller uncomment. Requires a single SDK type publish before merge (adds optional expectedPrior?: number to the nonce-typed GCREdit variant; runtime SDK behaviour unchanged).

Verification

  • tsc --noEmit produces zero new errors in the four changed files.
  • No new code paths reach confirmTransaction because the caller remains commented out — the function is reachable only via direct import (currently none in src/libs/).
  • Pre-fork path is the same single-line return true the previous stub had.

Test plan

  • bun install and confirm clean tsc
  • Boot fresh devnet via wipe_and_reboot.sh (or docker --clean); confirm [FORKS] Loaded fork nonceEnforcement activationHeight=0 appears in logs
  • Confirm existing native-tx flow still works on devnet (pre-fork code path unchanged; post-fork path unreachable until PR 3 uncomments the caller)
  • On a non-devnet branch (no fork active), confirm assignNonce(tx) returns true regardless of tx.content.nonce value (legacy preservation)

Summary by CodeRabbit

  • Documentation

    • Added detailed specification for nonce-based replay protection covering sequential per-sender nonce validation, fork activation parameters, RPC ingress validation requirements, consensus rules, and a comprehensive multi-stage test plan.
  • New Features

    • Implemented fork-gated nonce enforcement mechanism to prevent transaction replay attacks using sequential nonce semantics with backwards-compatible configuration.
  • Tests

    • Added devnet genesis configuration to enable testing and validation of nonce enforcement functionality.

Review Change Stack

tcsenpai added 2 commits May 30, 2026 16:38
The 2026-05-28 bug-hunt audit flagged assignNonce as a hardcoded stub.
Investigation during scoping found the nonce gap is 3-layered: RPC
validation missing, GCREdit emission missing, consensus-time
validation missing. Cross-RPC replay through different nodes is
currently possible — two block-included txs from the same sender both
carrying tx.content.nonce = N+1 would both succeed because
GCRNonceRoutines blindly applies its add edit.

Spec captures:
  - Strict sequential semantics
    (tx.content.nonce === account.nonce + 1 + pending_mempool_count)
  - New nonceEnforcement fork, gated like gasFeeSeparation
  - GCREdit nonce variant gains optional `expectedPrior: number` for
    consensus-time check
  - 3-PR sequence (validation infra, mempool lookahead,
    consensus rule + fork activation)
  - Single SDK publish before PR 3 lands; PRs 1+2 need no SDK touches
  - Devnet activates fork at block 0 to keep the existing
    wipe_and_reboot.sh / docker --clean flow working unchanged

This commit is the design lock-in before any code lands. PR 1 ships
next with the validation infra behind the (still commented-out)
caller.
…tion infra (batch C PR 1)

PR 1 of the 3-PR batch C sequence specified in
`docs/specs/audit-sweep-batch-c-nonce.md`. Lands the validation infra
behind the (still commented-out) caller in confirmTransaction — no
live behaviour change in this PR.

Fork registration:
  - New `nonceEnforcement` ForkName + NonceEnforcementConfig (alias of
    BaseForkConfig; no payload beyond activationHeight + description).
  - Default registry entry with activationHeight: null so existing
    chains stay bit-identical.
  - writeForkConfig + validateForkEntry switch branches updated; the
    exhaustiveness `never` guard catches future ForkName additions
    at compile time.
  - isForkActive is generic, picks up the new name automatically.

assignNonce real implementation (validateTransaction.ts):
  - Pre-fork: always returns true. Bit-identical to the previous
    hardcoded stub. Re-syncing pre-fork blocks is unaffected.
  - Post-fork: strict equality check
      `tx.content.nonce === account.nonce + 1`
    on the sender's GCR account.
  - Read-only — no GCR mutation. Increment is emitted as a +1 nonce
    GCREdit by HandleNativeOperations.handle() in PR 3, applied at
    consensus time by GCRNonceRoutines.
  - Caller in confirmTransaction (lines 77-86) remains commented out
    in PR 1. PR 3 uncomments it once the consensus-side rejection
    (GCREdit expectedPrior) is in place, so validation and the
    apply-time check ship together behind the same fork gate.

Devnet:
  - testing/devnet/genesis.devnet.json gains a `nonceEnforcement`
    fork entry with activationHeight: 0 so fresh devnet docker boots
    already post-fork. Matches the existing osDenomination pattern;
    `wipe_and_reboot.sh` / docker --clean flow needs no changes.

Out of scope (per design doc):
  - PR 2: mempool-aware pending-count lookahead in assignNonce so
    back-to-back submissions from one sender are accepted in order.
  - PR 3: GCREdit `expectedPrior` field, nonce-edit emission in
    HandleNativeOperations, consensus-side rejection in
    GCRNonceRoutines, caller uncomment. Requires SDK type update.
@qodo-code-review
Copy link
Copy Markdown
Contributor

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 30, 2026

Warning

Review limit reached

@tcsenpai, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 52 minutes and 25 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7299525e-988f-4272-861f-eb77bd7a3f2c

📥 Commits

Reviewing files that changed from the base of the PR and between 465b2b9 and 84a541a.

📒 Files selected for processing (2)
  • docs/specs/audit-sweep-batch-c-nonce.md
  • src/libs/blockchain/routines/validateTransaction.ts

Walkthrough

This PR implements fork-gated sequential nonce validation to prevent transaction replay. It adds a new nonceEnforcement fork to the configuration registry, wires up genesis loading, implements consensus-time nonce checking via GCR account state, and provides specification and devnet configuration for testing.

Changes

Nonce-based Replay Protection (nonceEnforcement Fork)

Layer / File(s) Summary
Fork configuration and type registry
src/forks/forkConfig.ts
New NonceEnforcementConfig type extends the fork system. ForkConfig and ForkName unions, ForkConfigByName interface, DEFAULT_FORK_CONFIG, and cloneDefaultForkConfig are all updated to include the nonceEnforcement fork with base configuration (activationHeight, optional description).
Genesis fork loading and validation
src/forks/loadForkConfig.ts
Type imports and fork-dispatch logic extended to parse and validate the nonceEnforcement fork entry during genesis initialization, storing validated config into shared state.
Transaction nonce validation implementation
src/libs/blockchain/routines/validateTransaction.ts
assignNonce stub replaced with fork-gated implementation: pre-fork always returns true; post-fork loads sender's GCR account and verifies tx.content.nonce == account.nonce + 1, returning false on mismatch or load failure.
Specification and devnet genesis
docs/specs/audit-sweep-batch-c-nonce.md, testing/devnet/genesis.devnet.json
Audit spec documents end-to-end nonce enforcement semantics, optional expectedPrior schema extension, validation rules, rollout phases, and risks. Devnet genesis file provides complete network configuration with nonceEnforcement fork present but disabled (activationHeight: null).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 A forking path protects the flow,
where nonces march in sequence, high and low,
each sender gets their ordered dance,
no replays get a second chance!
From config fork to validation's gleam,
replay protection lives the dream. 🌙

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main feature: registering a new nonceEnforcement fork and implementing nonce validation infrastructure, which matches the core changes across forkConfig.ts, loadForkConfig.ts, validateTransaction.ts, and the devnet genesis config.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bugfix/audit-sweep-batch-c-assignnonce-2026-05-30

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 30, 2026

Greptile Summary

This PR registers the nonceEnforcement fork and ships a real assignNonce implementation in validateTransaction.ts, replacing a hardcoded return true stub with a fork-gated, read-only GCR nonce check. The caller in confirmTransaction deliberately remains commented out pending PR 3's consensus-side expectedPrior guard.

  • Fork wiring (forkConfig.ts, loadForkConfig.ts): adds NonceEnforcementConfig, extends ForkName/ForkConfigByName, updates switch exhaustiveness guards in writeForkConfig and validateForkEntry, and seeds the default registry with activationHeight: null.
  • Validation infra (validateTransaction.ts): post-fork path performs a direct GCRMain.findOne lookup (correctly avoiding the account-provisioning side-effect of ensureGCRForUser), canonicalises the sender address to lowercase, and rejects any tx whose nonce ≠ account.nonce + 1.
  • Devnet genesis (testing/devnet/genesis.devnet.json): new file seeding a five-validator devnet chain with nonceEnforcement at activationHeight: 0, matching the osDenomination pattern.

Confidence Score: 5/5

Safe to merge — the live transaction path is unchanged because the assignNonce caller remains commented out, and all new code paths are behind a fork gate that defaults to inactive on non-devnet chains.

All four changed source files are internally consistent. The assignNonce implementation correctly addresses phantom-account creation and attacker-controlled height selection from prior review rounds. The fork wiring follows the established pattern and exhaustiveness guards will catch any future ForkName additions at compile time.

No files require special attention before merge. The height-source note in validateTransaction.ts is worth revisiting when PR 3 uncomments the assignNonce caller.

Important Files Changed

Filename Overview
src/forks/forkConfig.ts Adds NonceEnforcementConfig type alias, extends ForkName/ForkConfig/ForkConfigByName unions, and updates DEFAULT_FORK_CONFIG and cloneDefaultForkConfig — clean, consistent with prior fork additions.
src/forks/loadForkConfig.ts Adds nonceEnforcement cases to writeForkConfig and validateForkEntry switch; exhaustiveness guards and validator unchanged and correct.
src/libs/blockchain/routines/validateTransaction.ts Replaces stub assignNonce with a fork-gated read-only implementation; height source and account lookup are correctly hardened. Minor inconsistency: uses getSharedState.lastBlockNumber while the wrapping confirmTransaction uses await Chain.getLastBlockNumber() for other fork gates.
testing/devnet/genesis.devnet.json New devnet genesis with nonceEnforcement at activationHeight 0 and funded validator set; matches existing osDenomination pattern.
docs/specs/audit-sweep-batch-c-nonce.md Full design spec for nonce replay protection; PR breakdown table still shows scope mismatches with the actual PR 1 diff (fork gate present, HandleNativeOperations.ts untouched, no tests added).

Sequence Diagram

sequenceDiagram
    participant RPC as RPC Ingress
    participant CT as confirmTransaction
    participant AN as assignNonce (PR 1 — dormant)
    participant GCR as GCRMain (DB)
    participant FG as isForkActive
    participant CSS as Consensus (PR 3)

    RPC->>CT: submit tx
    CT->>CT: Chain.getLastBlockNumber() → referenceBlock
    note over CT,AN: Nonce caller commented out in PR 1
    CT--xAN: (commented) assignNonce(tx)
    AN->>FG: isForkActive(nonceEnforcement, lastBlockNumber)
    alt pre-fork
        FG-->>AN: false → return true
    else post-fork
        AN->>GCR: findOne pubkey senderAddress
        GCR-->>AN: account or null
        AN->>AN: "check tx.nonce === account.nonce + 1"
        AN-->>CT: true / false
    end
    CT->>FG: isForkActive(gasFeeSeparation, referenceBlock)
    CT->>CT: signature verify, runTypeDispatcher
    CT-->>RPC: ValidityData signed
    RPC->>CSS: broadcast tx
    CSS->>CSS: PR 3 GCREdit.expectedPrior check at apply-time
Loading

Reviews (2): Last reviewed commit: "fix(audit-sweep): address Greptile + Cod..." | Re-trigger Greptile

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@docs/specs/audit-sweep-batch-c-nonce.md`:
- Around line 74-77: The fenced code block containing the line "incoming tx is
valid iff" and the expression "tx.content.nonce === account.nonce + 1 +
pending_count(sender_in_mempool)" should include a language identifier to
satisfy markdownlint MD040; update the opening fence from ``` to ```text (or
another appropriate language) so the block becomes "```text" and leave the block
contents unchanged.
- Around line 126-130: The PR breakdown incorrectly states that PR 3 "includes
'new fork'" while the code already registers the nonceEnforcement fork; update
the table/description to reflect that nonceEnforcement is already registered in
this PR rather than being introduced later. Edit the entry referencing PR 3 and
mention that nonceEnforcement is registered here (point to GCRNonceRoutines.ts,
HandleNativeOperations.ts emit-side changes, and validateTransaction.ts
uncommented caller) and adjust the risk/rollout note to avoid implying the fork
registration is pending. Ensure the language clarifies sequencing for reviewers
and matches the actual changes in validateTransaction.ts and related files.

In `@src/libs/blockchain/routines/validateTransaction.ts`:
- Around line 393-395: The code currently uses tx.blockNumber to compute
blockHeight (const blockHeight = tx.blockNumber ??
getSharedState.lastBlockNumber ?? 0) which allows a caller to spoof a stale
block and bypass fork-based nonce enforcement; change the logic to never trust
tx.blockNumber for fork gating—derive blockHeight solely from the node/shared
state (use getSharedState.lastBlockNumber ?? 0) and update any uses (e.g.,
isForkActive calculation) to reference that trusted value instead of
tx.blockNumber.
- Around line 402-406: The senderAddress value (computed in
validateTransaction.ts as senderAddress and using forgeToHex(tx.content.from))
must be canonicalized before any GCR/nonce lookup to avoid casing-based key
divergence; update the assignment that computes senderAddress to normalize both
string inputs and forgeToHex outputs to a consistent canonical form (e.g.,
.toLowerCase() or your project's canonicalAddress helper) so subsequent
nonce/account lookups use the normalized key.
🪄 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: 68bdf26a-d359-4be8-938f-aad9239a6ff3

📥 Commits

Reviewing files that changed from the base of the PR and between 3a084cc and 465b2b9.

📒 Files selected for processing (5)
  • docs/specs/audit-sweep-batch-c-nonce.md
  • src/forks/forkConfig.ts
  • src/forks/loadForkConfig.ts
  • src/libs/blockchain/routines/validateTransaction.ts
  • testing/devnet/genesis.devnet.json

Comment thread docs/specs/audit-sweep-batch-c-nonce.md Outdated
Comment thread docs/specs/audit-sweep-batch-c-nonce.md Outdated
Comment thread src/libs/blockchain/routines/validateTransaction.ts Outdated
Comment thread src/libs/blockchain/routines/validateTransaction.ts Outdated
Comment thread src/libs/blockchain/routines/validateTransaction.ts Outdated
Comment thread src/libs/blockchain/routines/validateTransaction.ts Outdated
Comment thread docs/specs/audit-sweep-batch-c-nonce.md
…eploop iter 1)

Four code findings + three doc findings from the iter-1 review of
PR #884.

Code — validateTransaction.ts assignNonce:

  Critical (CodeRabbit / Greptile P2 boundary):
    Fork gate must not use tx.blockNumber. The caller is RPC-facing,
    so tx.blockNumber is attacker-controlled — a forged tx with a
    stale block height could select pre-fork behaviour and bypass
    the nonce check entirely once the caller is uncommented in PR 3.
    Drop the `tx.blockNumber ??` clause; gate on
    `getSharedState.lastBlockNumber ?? 0` only. Add a comment
    documenting the one-block boundary window where the gate reads
    inactive at ingress but active at inclusion; PR 3's
    consensus-side `expectedPrior` check is the safety net for
    that exact case.

  P1 (Greptile):
    `ensureGCRForUser` calls `HandleGCR.createAccount` for unknown
    pubkeys. Once the caller is uncommented in PR 3, validation
    runs before signature verification, so any syntactically valid
    `from` would provision a phantom account row as a side effect of
    nonce validation (DB bloat + ahead of the crypto check). Replace
    with a direct `gcrRepository.findOne` lookup. Unknown sender
    returns false (a real sender that has never transacted still
    has a row at `nonce === 0` from genesis seeding or a prior
    received tx — never having a row means they can't have a valid
    nonce to submit either).

  Major (CodeRabbit):
    Sender address not canonicalised before lookup. Two submissions
    that differ only in case would target different DB rows and
    weaken replay protection. Lowercase the resolved hex string
    before the findOne call.

  Cleanup: drop the unused `ensureGCRForUser` import. Replace with
  direct `Datasource` + `GCRMain` imports mirroring the existing
  pattern in `identityHandlers.ts`. Update the JSDoc to document
  the read-only contract and the rationale for the node-local
  block-height read.

Docs — audit-sweep-batch-c-nonce.md:

  Minor (CodeRabbit):
    Fenced code block at line 74 missing a language tag — markdownlint
    MD040. Add `text`.

  P2 (Greptile + CodeRabbit):
    PR breakdown table claimed PR 1 was "Validation infra (no fork
    gate)", listed `HandleNativeOperations.ts` and tests as PR 1
    files, and reserved "new fork" for PR 3. Reality: PR 1 already
    registers the fork (gated default `activationHeight: null`),
    touches `forkConfig.ts` / `loadForkConfig.ts` / devnet genesis,
    and does not touch `HandleNativeOperations.ts` or ship tests.
    Rewrite the row to match the actual scope. Move "new fork"
    language out of PR 3 — it's already done here.

Bug-class scope note: the iter-1 critical (tx.blockNumber for fork
gating) appeared only because of the new code path; nothing else in
the codebase reads `tx.blockNumber` for fork-gating purposes (grep
across `src/` confirms). PR 2 and PR 3 inherit the same node-local
block-height read.
@tcsenpai tcsenpai merged commit 55ce990 into stabilisation May 30, 2026
4 checks passed
tcsenpai added a commit that referenced this pull request May 31, 2026
…tch C PR 3) [DRAFT - review needed] (#886)

* feat(consensus): nonceEnforcement consensus rule + caller wire-up (batch C PR 3 / Path A)

Final PR of the 3-PR batch C sequence specified in
`docs/specs/audit-sweep-batch-c-nonce.md`. Closes the cross-RPC
replay window end-to-end and activates the `nonceEnforcement` fork
on devnet (already active at block 0 since PR 1's genesis fixture).

Path A — SDK ships type-only, node populates `expectedPrior` at
apply time. Chosen over Path B (SDK-side emit) and Path C
(SDK fork introspection) for zero breaking-change risk: old SDK
clients continue working post-fork without re-publishing. See spec
doc §"Emission-site rationale" for the trade-off matrix.

Changes:

  src/libs/blockchain/gcr/gcr_routines/GCRNonceRoutines.ts
    - When `expectedPrior` is set on a non-rollback nonce edit,
      reject the apply iff `accountGCR.nonce !== expectedPrior`.
    - Rollback path skips the check (unwinding, not validating
      forward progress).
    - Cross-RPC double-spend safety net: two competing same-nonce
      txs bundled into the same block from different RPCs both pass
      per-node validation but only one survives consensus apply.

  src/libs/blockchain/gcr/handleGCR.ts
    - In `applyTransaction`'s per-edit loop, populate `expectedPrior`
      on every `nonce` edit when `nonceEnforcement` is active at the
      apply block. Read live from `entities.accounts` so a
      hypothetical second nonce edit in the same tx sees the
      post-first-apply value.
    - Block height: `tx.blockNumber ?? lastBlockNumber ?? 0`.
    - Skip on rollback. Skip if `expectedPrior` is already set (defence
      against future SDK-side population).

  src/libs/network/endpointValidation.ts
    - Symmetric strip of `expectedPrior` on both
      `gcrEdits` (regen) and `txEditsBlanked` (SDK-shipped) before
      hash compare. Mirrors the existing `txhash` blanking pattern.
    - SDK never writes the field today; the strip is defence in depth
      against future SDK-side emission and any non-conformant clients.

  src/libs/blockchain/routines/validateTransaction.ts
    - Uncomment `assignNonce` caller (lines 80-89). Pre-fork it
      short-circuits to true (PR 1). Post-fork enforces strict
      sequential semantics with mempool lookahead (PR 2) + the
      matching consensus-side rejection above.

  package.json
    - Bump `@kynesyslabs/demosdk` from 4.0.3 to 4.0.5 — the
      published SDK that adds the `expectedPrior?: number` field to
      `GCREditNonce` (type-only, runtime unchanged).

  docs/specs/audit-sweep-batch-c-nonce.md
    - Update PRs list (#885 merged, #884 merged, #TBD = this PR).
    - Add `sdk_publish: 4.0.5` to frontmatter.
    - Rewrite §Fork activation Post-fork bullet to describe the
      Path A flow (node populates, not SDK emits).
    - Add §Emission-site rationale documenting why Path A was
      chosen over B / C.
    - Update PR 3 row in the breakdown to list actual files +
      summarise the SDK pin bump.

Verification:
  - `tsc --noEmit` clean on all six changed files.
  - SDK 4.0.5 verified locally: `expectedPrior?: number` present in
    `node_modules/@kynesyslabs/demosdk/build/types/blockchain/GCREdit.d.ts`.
  - No new e2e test in this commit; will follow up in a separate
    PR with the devnet two-RPC double-broadcast scenario once the
    base wires are merged.

Cross-RPC double-spend scenario (post-fork):
  1. Client signs tx with `content.nonce = N+1`.
  2. Broadcasts to RPC-A and RPC-B in parallel.
  3. Both RPCs pass per-node validation (mempool lookahead is
     single-node).
  4. Block-formation gathers both → applies in deterministic order.
  5. First apply: account.nonce goes N → N+1. `expectedPrior` matched
     (N).
  6. Second apply: `expectedPrior = N` (populated at apply time from
     pre-first-apply snapshot), but `accountGCR.nonce = N+1`. Mismatch
     → `GCRNonceRoutines` rejects → whole tx rolls back at consensus.

* fix: move expectedPrior populate from apply-time to validation-time (PR #886 self-review)

Caught during draft-PR review: the apply-time populate site I shipped
in the initial commit was incorrect.

Bug:
  Populating `expectedPrior` inside `HandleGCR.applyTransaction`'s
  per-edit loop reads `entities.accounts` which already reflects
  prior in-block applies. For two competing same-nonce txs in the
  same block:
    1. Tx A applies. Edit populates `expectedPrior = N` from live
       state. Apply check passes (N === N). Account → N+1.
    2. Tx B (replay) applies next. Edit populates `expectedPrior =
       N+1` (live state already advanced). Apply check passes
       (N+1 === N+1). Account → N+2. Both succeed.
  This defeats the entire cross-RPC safety net.

Fix:
  Move populate into `assignNonce` itself (validation time), using
  the already-loaded account row + pending-mempool-count:
    `expectedPrior = account.nonce + pendingCount`
  This locks the value to the pre-block snapshot the sender's nonce
  was supposed to be at, NOT what it becomes at apply time.

  For the same scenario:
    1. Tx A's edit carries `expectedPrior = 0`. Apply: account=0,
       expectedPrior=0 → match. Account → 1.
    2. Tx B's edit also carries `expectedPrior = 0` (validated on a
       node that didn't yet see Tx A). Apply: account=1,
       expectedPrior=0 → MISMATCH → reject. Whole tx rolls back.

  For back-to-back same-sender submissions (k-th tx):
    `expectedPrior = account.nonce + (k-1)`
  matching the apply order because PR 2's mempool lookahead ensures
  each pending tx applies before this one.

Hash compare safety:
  `endpointValidation` snapshots `tx.content.gcr_edits` at line 48
  BEFORE `confirmTransaction` runs (which invokes `assignNonce`).
  My mutation hits the real array but the snapshot is the
  pre-mutation shape. Both sides of the compare are still stripped
  of `expectedPrior`, so signature integrity is preserved. The
  mutated array flows downstream into the apply pipeline carrying
  the validation-time-locked `expectedPrior`.

Files:
  - src/libs/blockchain/routines/validateTransaction.ts: walk
    `tx.content.gcr_edits`, set `expectedPrior` on the matching
    `nonce` edit (sender address compared case-insensitive).
  - src/libs/blockchain/gcr/handleGCR.ts: revert the apply-time
    populate code added in the previous commit. Also drop the now-
    unused `isForkActive` and `getSharedState` imports.
  - docs/specs/audit-sweep-batch-c-nonce.md: rewrite the fork
    activation Post-fork bullet to describe the validation-time
    populate, plus explicit rationale for why apply-time is wrong.
    Update PR 3 file list to reflect the moved populate site.

Verification:
  - tsc --noEmit clean.
  - Manual trace of the cross-RPC scenario now correctly rejects
    the second replay.

* fix(audit-sweep): address Greptile + CodeRabbit review on PR #886 (greploop iter 1)

Three real findings + three doc/comment fixes.

Critical (CodeRabbit) — fork-gate the consensus rule
  GCRNonceRoutines.apply enforced the `expectedPrior` mismatch check
  unconditionally whenever the field was present. The spec says
  pre-fork nodes must ignore it, and the field is stripped from the
  hash compare in endpointValidation — so a client could attach
  `expectedPrior` on a pre-fork chain without breaking signature
  validation, and upgraded nodes would start rejecting edits that
  older nodes still accepted. Validator-split risk before the fork
  activates.

  Fix: wrap the mismatch check in `isForkActive(nonceEnforcement,
  blockHeight)` using `getSharedState.lastBlockNumber` (same gate
  source `assignNonce` uses). Pre-fork ignores the field entirely;
  post-fork enforces. Imports added: `isForkActive`,
  `getSharedState`.

Major (CodeRabbit) / P1 (Greptile) — forge-account comparison bug
  In `assignNonce`'s populate loop, `edit.account` can be a
  forge-key object (non-string), but the comparison was
  `editAccount === senderAddress` where `senderAddress` is a
  lowercase hex string. Strict equality between an object and a
  string is always false, so the populate would silently skip for
  forge-format accounts and the cross-RPC safety net would never
  engage for those transactions.

  Fix: mirror the normalization already used in `GCRNonceRoutines`:
  `typeof edit.account === "string" ? edit.account :
  forgeToHex(edit.account)`, then `.toLowerCase()`. Now matches the
  sender regardless of how the SDK ships the account field.

Doc / comment cleanup
  Three locations still carried "node fills it in at apply time"
  wording, contradicting the validation-time populate site. Updated
  in:
    - `GCRNonceRoutines.ts`: comment now describes the fork gate
      + validation-time source.
    - `endpointValidation.ts`: comment now points at `assignNonce`
      as the populate site.
    - `docs/specs/audit-sweep-batch-c-nonce.md`: Path A header
      rewritten as "node populates at validation time"; rationale
      paragraph now explains the apply-time draft was rejected,
      with the cross-RPC reasoning inline.

Doc — PR 3 row scope
  The scope-table row for PR 3 listed "e2e test" as a deliverable,
  but the diff ships no test. Removed the "e2e test" item and
  added an explicit note that cross-RPC e2e is tracked as a
  follow-up PR. Also reworded "apply-time rejection" to "consensus-
  side rejection" to match what `GCRNonceRoutines` actually does
  (rejection at apply, populate at validation).

Out of scope for this iter
  TOCTOU advisory-lock wrapper documented in `assignNonce`'s
  PR 2 docstring is still not landed; flagged in this review round
  but deferred to a follow-up PR since wrapping the validate +
  `Mempool.addTransaction` sequence in `pg_advisory_xact_lock` is
  its own structural change that affects every native-tx ingress
  path. Filed as PR 4 in the follow-up tracker.

Verification:
  - `tsc --noEmit` clean.
  - Pre-fork trace: `expectedPrior` present on edit, fork inactive,
    `isForkActive` returns false → mismatch check skipped → legacy
    apply behaviour preserved bit-identically for re-sync.
  - Post-fork trace: forge-format `edit.account` now hex-coerces
    and matches the lowercased sender → `expectedPrior` populates
    correctly → `GCRNonceRoutines` enforces the mismatch reject.

---------

Co-authored-by: tcsenpai <tcsenpai@discus.sh>
@Shitikyan Shitikyan mentioned this pull request Jun 1, 2026
Shitikyan added a commit that referenced this pull request Jun 1, 2026
…ork testing pass

Single document covering everything surfaced during the testing pass that
ran 2026-05-26 → 2026-05-31 against dev.node2. Organised by topic, not
chronology, so the team has one place to look up what was found, what
was fixed, and what is still pending — without scrolling Telegram or
hopping between PRs.

Sections:
- L2PS encryption nonce reuse (SDK #87 + 3 follow-up security findings)
- Governance hash mismatch on dev.node2 (still broken; real fix in DEM-727)
- Test-coverage gap that allowed the governance break to slip through
- Side finding — nonces were not checked or incremented per tx (Hovhannes's batch C #884#887, batch D #888 in flight)
- UX surfacing for L2PS (Demo #11)
- Operational risks still open
- Linear ticket map across DEM-722 epic
- Source material cross-links

Cross-links the raw battery output and serializer analysis already in
flight via Node #876. Closes DEM-729.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
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.

1 participant