Repository navigation
fix(consensus): appeal claim omits contract_snapshot/consensus_history, wiping contract state on successful validator appeal - #1724
Conversation
…laim_next_appeal
claim_next_appeal's raw UPDATE ... RETURNING omitted the
transactions.contract_snapshot and transactions.consensus_history
columns, so every appeal transaction was rebuilt via
Transaction.from_dict with contract_snapshot=None and an empty
consensus_history. On a successful validator appeal,
process_validator_appeal then fell into its "no snapshot" branch and
restored accepted_state = {} — for non-deploy write transactions this
wiped the contract's accepted state including the code slot, after
which the re-run failed with invalid_contract absent_runner_comment
and subsequent appeals self-confirmed the error. The missing
consensus_history additionally let appeal committees re-draw
already-used validators, and appeal validators verified against
post-execution state instead of the pre-execution snapshot.
Two changes:
- worker.py: add both columns to the RETURNING list and the returned
appeal_data dict (same raw-JSONB shape as _parse_transaction_data).
- base.py: defense in depth in process_validator_appeal — for
non-deploy transactions with a missing snapshot, re-fetch it via
transactions_processor.get_transaction_by_hash (which hydrates
archived snapshots); if it is still unavailable, log an error and
skip the state restore instead of writing {}. Deploy rollback
behavior is unchanged.
Adds unit tests covering the claim dict shape, the RETURNING clause,
Transaction.from_dict hydration of a claimed appeal, and all four
restore branches of the appeal rollback.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This PR targeted I retargeted it to |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
…fest Review follow-ups for this PR, plus the architectural fix for the bug class it exposed (hand-maintained RETURNING lists + silent Transaction.from_dict defaults = omissions indistinguishable from absent values): - worker.py: all three claim queries (transaction, finalization, appeal) now generate their RETURNING clause and row->dict conversion from one shared column manifest, so query and dict can never drift apart. Heavy state columns (contract_snapshot has been observed at tens of MB) are an explicit opt-in group: appeals include them, pending/finalization deliberately omit them. - base.py: PendingState now refreshes the transaction from the DB before computing pre-effects; previously decide_pending_pre received claim-built appeal flags that silently default to False for re-processed appeals (same drift class, latent since the params are currently unused). Renamed previous_contact_state -> previous_contract_state, and the skipped-restore path now emits a consensus_event ERROR so operators see it, not just the log. - tests/unit/consensus/test_claim_column_manifest.py: pins each claim's SQL to its manifest, pins the appeal claim to the state columns (incident regression), and statically extracts every key Transaction.from_dict consumes to enforce that each claim either returns the column or explicitly allowlists the omission with a rationale - a new from_dict field now fails tests until a conscious per-claim decision is made.
|
Pushed Review findings applied
Architecture: shared claim-column manifest — all three claim queries now generate both their RETURNING clause and the row→dict conversion from one manifest ( Same-class hunt found one more latent instance: Class-killer test ( 1033 unit tests pass, black clean. |
…y, wiping contract state on successful validator appeal (#1724) (#1799) * fix(consensus): return contract_snapshot and consensus_history from claim_next_appeal claim_next_appeal's raw UPDATE ... RETURNING omitted the transactions.contract_snapshot and transactions.consensus_history columns, so every appeal transaction was rebuilt via Transaction.from_dict with contract_snapshot=None and an empty consensus_history. On a successful validator appeal, process_validator_appeal then fell into its "no snapshot" branch and restored accepted_state = {} — for non-deploy write transactions this wiped the contract's accepted state including the code slot, after which the re-run failed with invalid_contract absent_runner_comment and subsequent appeals self-confirmed the error. The missing consensus_history additionally let appeal committees re-draw already-used validators, and appeal validators verified against post-execution state instead of the pre-execution snapshot. Two changes: - worker.py: add both columns to the RETURNING list and the returned appeal_data dict (same raw-JSONB shape as _parse_transaction_data). - base.py: defense in depth in process_validator_appeal — for non-deploy transactions with a missing snapshot, re-fetch it via transactions_processor.get_transaction_by_hash (which hydrates archived snapshots); if it is still unavailable, log an error and skip the state restore instead of writing {}. Deploy rollback behavior is unchanged. Adds unit tests covering the claim dict shape, the RETURNING clause, Transaction.from_dict hydration of a claimed appeal, and all four restore branches of the appeal rollback. * refactor(consensus): generate claim queries from a shared column manifest Review follow-ups for this PR, plus the architectural fix for the bug class it exposed (hand-maintained RETURNING lists + silent Transaction.from_dict defaults = omissions indistinguishable from absent values): - worker.py: all three claim queries (transaction, finalization, appeal) now generate their RETURNING clause and row->dict conversion from one shared column manifest, so query and dict can never drift apart. Heavy state columns (contract_snapshot has been observed at tens of MB) are an explicit opt-in group: appeals include them, pending/finalization deliberately omit them. - base.py: PendingState now refreshes the transaction from the DB before computing pre-effects; previously decide_pending_pre received claim-built appeal flags that silently default to False for re-processed appeals (same drift class, latent since the params are currently unused). Renamed previous_contact_state -> previous_contract_state, and the skipped-restore path now emits a consensus_event ERROR so operators see it, not just the log. - tests/unit/consensus/test_claim_column_manifest.py: pins each claim's SQL to its manifest, pins the appeal claim to the state columns (incident regression), and statically extracts every key Transaction.from_dict consumes to enforce that each claim either returns the column or explicitly allowlists the omission with a rationale - a new from_dict field now fails tests until a conscious per-claim decision is made. --------- (cherry picked from commit cccfc70) Co-authored-by: Ivan <raskovsky+git@gmail.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Edgars <edgars@genlayerlabs.com>
Summary
ConsensusWorker.claim_next_appealhand-builds the appeal transaction dict from a rawUPDATE ... RETURNINGwhose column list omittedtransactions.contract_snapshotandtransactions.consensus_history. Every claimed appeal was therefore rebuilt withcontract_snapshot=Noneand an emptyconsensus_history. On a successful validator appeal of a non-deploy write transaction,process_validator_appealthen "restored" the contract's accepted state to{}— deleting the contract's storage including the code slot — after which the appeal re-run fails withinvalid_contract absent_runner_commentand subsequent appeals self-confirm the error.This PR adds the two columns to the claim query (root-cause fix) and hardens the rollback in
process_validator_appealso a missing snapshot can never again translate into a state wipe (defense in depth). Unit tests cover both layers.Root cause
backend/consensus/worker.py:396-406— theRETURNINGlist ofclaim_next_appealselected 25 columns but nottransactions.contract_snapshotortransactions.consensus_history, and the dict built from the row (worker.py:424-455) consequently had neither key.That dict is passed to
Transaction.from_dictinprocess_appeal(backend/consensus/worker.py:1794).from_dicttreats both fields as optional (backend/domain/types.py:284defaultsconsensus_historyto{};backend/domain/types.py:287-289maps a missingcontract_snapshottoNoneviaContractSnapshot.from_dict), so the omission produced no error — just a silently degradedTransactionon every appeal.Note the columns exist in the DB and are populated at acceptance time; only this one claim query dropped them on the floor. The equivalent hand-built dict for fresh transactions (
claim_next_transaction) is unaffected in practice because a pending transaction has no stored snapshot yet.Impact
1. Contract state wipe (data loss). In
process_validator_appeal, when a validator appeal succeeds the code restores the pre-execution contract state fromtransaction.contract_snapshot(backend/consensus/base.py:1196region). With the snapshot alwaysNone, the pre-fixelsebranch setprevious_contact_state = {}and wrote it viacontract_processor.update_contract_state. The{}branch is only correct for deploy transactions (rolling back a deploy legitimately clears the account). For aRUN_CONTRACTwrite transaction, the contract's code lives insidestates["accepted"](seeContractSnapshot.extract_deployed_code_b64,backend/database_handler/contract_snapshot.py:83), so writing{}erased both storage and code. The subsequent appeal re-run then executed against a codeless account and the leader returnedinvalid_contract absent_runner_comment; the merged committee accepted that error result, and each later appeal drew fresh validators who re-executed against the already-wiped state, reproduced the same error, and voted agree — the corruption self-confirms and never heals.Observed on a live localnet:
0xc68a8b2f7552deb2fb1cdd4afc337d3da254a67a09d427c096f109e0069c1531— ACCEPTED, contract registered and working.0x23cb9ee07a6e4de6eef3e2da2f0f0eb3257f0414ae1c491693af4fc5e1967cde— ACCEPTED in round 0, then "Validator Appeal Successful", then the re-run leader errored withinvalid_contract absent_runner_comment, the error outcome was accepted, and two further appeals failed while confirming the error.data.state.accepted = {}whiledata.state.finalizedstill contained the code slot — exactly the signature ofupdate_contract_state(to_address, accepted_state={})on the accepted branch only.2. Biased appeal verification. The appeal committee is supposed to verify the original execution against the transaction's stored pre-execution snapshot. With
contract_snapshot=None,TransactionContext.__init__falls back to the snapshot factory, which loads the contract's current post-execution state from the DB. Appeal validators therefore re-executed the transaction on top of its own effects, systematically biasing votes toward disagree and making successful appeals more likely than the protocol intends — which is also what manufactured the wipe in consequence 1.3. Validator re-draw.
ConsensusAlgorithm.get_extra_validators(backend/consensus/base.py:1282) usesconsensus_historyto exclude validators and leaders already used in prior rounds. With the history always empty on the appeal path, appeal committees could re-draw already-used validators/leaders, weakening the appeal's independence guarantee.The fix
Part 1 — root cause (
backend/consensus/worker.py). Addtransactions.contract_snapshot, transactions.consensus_historyto theRETURNINGlist ofclaim_next_appealand the two corresponding keys to the returned dict. The values are the raw JSONB payloads, the same shapeTransactionsProcessor._parse_transaction_dataproduces andTransaction.from_dictalready consumes on every other code path.Part 2 — defense in depth (
backend/consensus/base.py). The old rollback conflated "no snapshot" with "deploy rollback". The new logic inprocess_validator_appeal:states["accepted"]from it (unchanged);type == DEPLOY_CONTRACT→ restore{}(unchanged deploy semantics, now explicit);transactions_processor.get_transaction_by_hash(which also hydrates snapshots archived to object storage, see_hydrate_archived_contract_snapshot) and restore from the re-fetched snapshot; if it is still unavailable, log an error and skip the restore entirely rather than write{}.The second part exists because the state wipe is destructive and effectively unrecoverable once the error result is accepted, while the failure mode it guards against (a
Transactionobject built without its stored snapshot) has now happened once via this claim query and could be reintroduced by any future caller. Skipping the restore leaves the post-execution state in place — a recoverable inconsistency — instead of destroying the contract.Test coverage
New file:
tests/unit/consensus/test_appeal_state_restore.py(8 tests, mock-based, no DB or containers; follows the patterns of the existingtests/unit/consensus/test_appeal_snapshot.py).TestClaimNextAppealReturnsSnapshot—claim_next_appealrun against a mocked session: the returned dict containscontract_snapshotandconsensus_history; the executed SQL'sRETURNINGclause selects both columns; and a claimed dict round-trips throughTransaction.from_dictinto a non-Nonecontract_snapshotwith the storedconsensus_history(the exact properties the rollback andget_extra_validatorsdepend on).TestValidatorAppealStateRestore— drivesprocess_validator_appealinto theVALIDATOR_APPEAL_SUCCESSFULbranch with a mocked committing state and asserts all four restore branches: missing snapshot + non-deploy re-fetches from the DB and restores the stored state (never{}); missing snapshot + no DB row (or a row without a snapshot) skipsupdate_contract_stateentirely; missing snapshot + deploy still restores{}; in-memory snapshot restores directly without a DB round-trip.Run (mirrors
.github/workflows/unit-tests-pr.yml: Python 3.12,pip install -r backend/requirements.txt):Local result: 8/8 pass; the full
tests/unit/consensus/directory passes (286 tests).Blast radius
claim_next_transactionand the finalization claim query are untouched.Transaction.from_dictalready handled missing/None values for both fields, so rows whose storedcontract_snapshotis genuinely NULL behave exactly as before, except that the rollback now refuses to wipe state for them.Reproduction notes for reviewers
On a localnet built without this patch:
data.state.accepted).data.state.acceptedbecomes{}(code slot gone;finalizedstill intact), the transaction re-runs and the leader errors withinvalid_contract absent_runner_comment, the error result is accepted, and any further appeals fail while re-confirming the error.🤖 Generated with Claude Code