Skip to content

fix(validation): snapshot tx.content.gcr_edits BEFORE confirmTransaction so applyGasFeeSeparation prepends don't break hash compare#871

Merged
tcsenpai merged 1 commit into
stabilisationfrom
fix/snapshot-gcr-edits-before-confirm
May 26, 2026
Merged

fix(validation): snapshot tx.content.gcr_edits BEFORE confirmTransaction so applyGasFeeSeparation prepends don't break hash compare#871
tcsenpai merged 1 commit into
stabilisationfrom
fix/snapshot-gcr-edits-before-confirm

Conversation

@tcsenpai
Copy link
Copy Markdown
Contributor

@tcsenpai tcsenpai commented May 26, 2026

Root cause

confirmTransaction runs applyGasFeeSeparation when the gasFeeSeparation fork is active, which PREPENDS node-computed fee edits onto tx.content.gcr_edits in place. The hash comparison ran AFTER that mutation:

tx.content.gcr_edits (mutated):  [fee_a, fee_b, ..., subtract, add, gas, nonce]
GCRGeneration.generate(tx):                          [subtract, add, gas, nonce]

Divergence by EXACTLY the prepended fee edits — a structural mismatch the SDK has no way to predict because the fee distribution is the validator's computation, not the signer's.

dev.node2 reproduced; local devnet did NOT because the devnet boots with gasFeeSeparation.activationHeight: nullapplyGasFeeSeparation gated off → no prepend → #861+#867 normalisation was sufficient.

Fix

Snapshot tx.content.gcr_edits via structured clone BEFORE calling confirmTransaction, then hash against the snapshot instead of the mutated array. The invariant we want to verify is "did the SDK ship the same edits GCRGeneration would regenerate?" — exactly what the snapshot captures.

Fee edits live downstream of this check and become part of the signed validity data via the existing signValidityData flow — no consensus impact.

Trace

  • applyGasFeeSeparation.ts:187 mutates tx.content.gcr_edits in place
  • validateTransaction.ts:133 calls it inside confirmTransaction
  • endpointValidation.ts:35 calls confirmTransaction, then was reading the already-mutated tx.content.gcr_edits

Test plan

  • Local devnet: still passes (regression test for the gasFeeSeparation-inactive path)
  • dev.node2: rebuild + retry BROADCAST=1 bun transfer_10pct.mjs — expect valid: true

Companion PRs in the saga

Summary by CodeRabbit

  • Bug Fixes
    • Fixed transaction validation to ensure accurate hash computations and comparisons throughout the confirmation process, improving transaction integrity check reliability.

Review Change Stack

…ion so applyGasFeeSeparation prepends don't break the hash compare

Root cause of the dev.node2 GCREdit mismatch that survived PRs
#861/#867/#869: confirmTransaction runs `applyGasFeeSeparation` when
the gasFeeSeparation fork is active, which PREPENDS node-computed fee
edits onto `tx.content.gcr_edits` in place. The hash comparison ran
AFTER that mutation, so:

  tx.content.gcr_edits (mutated):  [fee_a, fee_b, ..., subtract, add, gas, nonce]
  GCRGeneration.generate(tx):                          [subtract, add, gas, nonce]

The compare was diverging by exactly the prepended fee edits — a
structural mismatch the SDK has no way to predict because the fee
distribution is the validator's computation, not the signer's.

dev.node2 reproduced and the local devnet did NOT because the local
devnet boots with `gasFeeSeparation.activationHeight: null` →
`applyGasFeeSeparation` is gated off → no prepend → the (already
shipped) #861/#867 normalisation was sufficient.

Fix: snapshot `tx.content.gcr_edits` via structured clone BEFORE
calling `confirmTransaction`, then run the hash compare against the
snapshot instead of the mutated array. The invariant we want to
verify is "did the SDK ship the same edits GCRGeneration would
regenerate?" — that's what the snapshot captures. Fee edits live
downstream of this check and become part of the signed validity data
via the existing `signValidityData` flow.

Verified by tracing call sites:
  - applyGasFeeSeparation.ts:187 mutates tx.content.gcr_edits in place
  - validateTransaction.ts:133 calls it inside confirmTransaction
  - endpointValidation.ts:35 calls confirmTransaction, line ~42 was
    then reading the already-mutated tx.content.gcr_edits

Manual repro from earlier in the session: BROADCAST=1 bun
transfer_10pct.mjs against dev.node2 returned `valid: false` /
"GCREdit mismatch" on every attempt despite the source on disk being
correct (post-#867). Tracing showed regen produced a constant
`16f9495100b98…` hash (the SDK-shipped 4-edit set) while tx-side hash
varied per run — and the varying part was the SHA-256 of the
prepended fee edits, whose contents depend on per-block fee state.
@tcsenpai tcsenpai merged commit 5dfd6cf into stabilisation May 26, 2026
@tcsenpai tcsenpai deleted the fix/snapshot-gcr-edits-before-confirm branch May 26, 2026 16:26
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 26, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0d4a2ced-9bbe-4b51-a821-3430b986f0c2

📥 Commits

Reviewing files that changed from the base of the PR and between f4cfcd8 and d14ea6e.

📒 Files selected for processing (1)
  • src/libs/network/endpointValidation.ts

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.


Walkthrough

The PR modifies transaction validation in handleValidateTransaction to prevent mutations during SDK/node confirmation logic from invalidating GCR edit hash comparisons. A deep snapshot of edits is captured before confirmation and then used consistently for hash computation instead of the original mutable reference.

Changes

Transaction Validation Edit Snapshot

Layer / File(s) Summary
Edit snapshot capture and hash validation
src/libs/network/endpointValidation.ts
txShippedGcrEdits snapshot is created via JSON clone before confirmTransaction to preserve the pre-confirmation edit state, then used during symmetric normalization to compute txGcrEditsHash against GCRGeneration.generate instead of the potentially mutated tx.content.gcr_edits.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • kynesyslabs/node#861: Both PRs modify handleValidateTransaction in src/libs/network/endpointValidation.ts to fix GCR edit hash/mismatch logic by changing how edits and transaction hashes are derived and compared.

Poem

🐰 A snapshot in time, before the change,
Preserves the edits through fork-logic's range,
Hash matches true to the shipped and shipped,
Mutations no longer cause validation to slip!

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/snapshot-gcr-edits-before-confirm

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 26, 2026

Greptile Summary

This PR fixes a hash comparison false-negative in handleValidateTransaction caused by applyGasFeeSeparation mutating tx.content.gcr_edits in-place (prepending node-computed fee edits) before the snapshot was taken, making the tx-side hash diverge from the regenerated hash by exactly those fee edits.

  • Snapshots tx.content.gcr_edits immediately before confirmTransaction is called, then uses that snapshot (instead of the mutated array) as the tx-side input to normaliseGcrEditsForHash.
  • All downstream logic — signValidityData, fee-balance checks — continues to operate on the post-confirmTransaction (mutated) tx object, so consensus and fee collection are unaffected.

Confidence Score: 4/5

The core fix is correct and well-reasoned — snapshotting before the mutation point directly eliminates the divergence. The two findings are non-blocking quality items.

The snapshot placement is correct (before confirmTransaction, inside the try block), the normalization pipeline is symmetric on both sides, and fee/consensus data continues to flow through the mutated tx object as before. The mislabeled debug log and the JSON round-trip vs structuredClone choice are minor issues that don't affect correctness on current data shapes.

The single changed file src/libs/network/endpointValidation.ts is the only file needing attention — specifically the mismatch-dump log label and the clone method.

Important Files Changed

Filename Overview
src/libs/network/endpointValidation.ts Snapshots tx.content.gcr_edits before confirmTransaction to prevent applyGasFeeSeparation's in-place prepend from polluting the hash comparison; a debug mismatch-dump label is now misleading and the clone method diverges from the PR title's stated intent.

Sequence Diagram

sequenceDiagram
    participant SDK as SDK (client)
    participant EV as endpointValidation
    participant CT as confirmTransaction
    participant AGFS as applyGasFeeSeparation
    participant GCRG as GCRGeneration

    SDK->>EV: tx (gcr_edits: [sub, add, gas, nonce])
    Note over EV: SNAPSHOT txShippedGcrEdits = clone(tx.content.gcr_edits)<br/>[sub, add, gas, nonce]
    EV->>CT: confirmTransaction(tx, sender)
    CT->>AGFS: applyGasFeeSeparation(tx)
    Note over AGFS: tx.content.gcr_edits = [fee_a, fee_b, ..., sub, add, gas, nonce]
    AGFS-->>CT: ok
    CT-->>EV: validationData
    EV->>GCRG: GCRGeneration.generate(tx)
    GCRG-->>EV: regenEdits [sub, add, gas, nonce]
    Note over EV: Hash(normalise(txShippedGcrEdits)) == Hash(normalise(regenEdits)) ✓
    EV-->>SDK: valid: true
Loading

Comments Outside Diff (1)

  1. src/libs/network/endpointValidation.ts, line 171-174 (link)

    P2 Misleading debug label on mismatch dump

    The label mismatch dump.rawTx implies the SDK-shipped (pre-mutation) array, but it logs tx.content.gcr_edits — the version that applyGasFeeSeparation has already mutated with prepended fee edits. The actual comparison was made against txShippedGcrEdits (the snapshot). Any developer debugging a future mismatch from logs will see the post-mutation array under a label that suggests the original, making it hard to distinguish what the node added vs what the SDK sent.

Reviews (1): Last reviewed commit: "fix(validation): snapshot tx.content.gcr..." | Re-trigger Greptile

Comment on lines +49 to +51
const txShippedGcrEdits: GCREdit[] = JSON.parse(
JSON.stringify(tx.content.gcr_edits ?? []),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Using JSON.parse(JSON.stringify(...)) for the deep clone works for pure-JSON payloads, but structuredClone (which the PR title explicitly names) is the semantically correct primitive here — it handles the full JS value space without the implicit strip-undefined / throw-on-BigInt behavior of a JSON round-trip. Given GCREdit.amount is sometimes cast to BigInt downstream (line 205), a future schema change storing it as BigInt directly on the edit would silently throw here.

Suggested change
const txShippedGcrEdits: GCREdit[] = JSON.parse(
JSON.stringify(tx.content.gcr_edits ?? []),
)
const txShippedGcrEdits: GCREdit[] = structuredClone(
tx.content.gcr_edits ?? [],
)

@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 →

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