MF2-C01: preserve post-Finalize closing marker across challenge cycle#762
Conversation
…challenge Receiver-credit issuance (transfer_receive, release) was advancing the home channel head with a node-signed state regardless of channel status. Combined with the onchain SessionKeyValidator's missing expiry/scope checks, this could let an attacker turn a dust credit into a checkpoint that resets a dispute timer. The receiver state is still persisted while a channel is Challenged so amounts can be reconciled later, but the node no longer co-signs it. The new DB helper GetHomeChannelStatus performs the lookup inside the same transaction as the rest of the state-issuance flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generalize UpdateStateUserSigIfMissing into UpdateStateSigsIfMissing so the event reactor can repair both user and node sides of a stored state row. When a Checkpointed event lifts a channel out of ChannelStatusChallenged, the head row may have been persisted unsigned during the dispute window (any receiver state issued under the no-sign-while-challenged rule). The handler now recomputes the node signature locally for that head state so future flows treat it as fully co-signed. Open->Open checkpoints stay on the previous user-sig-only backfill path. Higher-version unsigned receiver states accrued during the challenge are intentionally left in place; they are reconciled when the channel closes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngeRescue When a home channel is closed onchain after being Challenged, any unsigned receiver-state credits accrued during the dispute window (the no-sign-while- challenged rows) would otherwise be orphaned. The HomeChannelClosed handler now sums those credits and emits a single ChallengeRescue state on the user's ledger via core.NewChallengeRescueState: HomeChannelID is nil, AccountID is the closed channel ID, version 1 of a fresh epoch, and the amount is the sum of credits being rescued. The rescue state mirrors the "credit a user with no open channel" shape — stored without a node signature, to be folded into a properly signed state when the user next opens a channel. Includes new transition and transaction types (201), DB helper SumUnsignedReceiverStateAmountsAfterVersion, and unit + DB + handler tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…allenge clearance The Checkpointed handler previously re-signed the row at event.StateVersion (the on-chain enforced version). After a challenge that accrued unsigned receiver states, the actual off-chain head sits above that version, and the now-cleared channel's "real" latest state would have stayed unsigned, wedging the user's state chain on the next RPC. Backfill the node signature on whichever row is the highest stored version for the channel via GetLastStateByChannelID. The user-signature backfill keeps targeting event.StateVersion, since that is what the on-chain event proves was co-signed by the user. Also update the protocol docs (enforcement.md, protocol-description.md) to match: only the head queued "receive" state is signed on clearance, with earlier queued entries left unsigned as history; and surface challenge_rescue in the api.yaml transition_type and transaction_type enums so schema consumers know the new value exists. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the rescue state was skipped when no unsigned receiver credits had accrued during the dispute. That left the user's latest stored state pointing at the closed channel with a non-finalize transition, which: - caused subsequent receiver-state issuance to inherit the closed HomeChannelID via NextState() (IsFinal() returns false), node-signing credits onto a closed channel that the user can no longer enforce; and - blocked channels.v1.request_creation with "channel is already initialized", forcing the user off the wallet to recover usage of the asset. Emit the rescue state unconditionally on Challenged -> Closed. When no credits were queued, the rescue state carries amount = 0 — its purpose then is purely to advance the user's state chain to a fresh epoch with HomeChannelID nil so the closed-channel binding does not leak into future operations. core.NewChallengeRescueState now accepts amount >= 0 (still rejects negative). The handler helper is renamed issueChallengeRescueIfNeeded -> issueChallengeRescue to match the unconditional behavior. Doc rewording in enforcement.md, protocol-description.md, and contracts/SECURITY.md follows suit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- nitronode/api/channel_v1/submit_state_test.go: capture errors from core.NewChannelDefaultSigner instead of discarding with `_`, matching the repo Go rule on error handling. - nitronode/event_handlers/service_test.go: rewrite the doc comment above TestHandleHomeChannelClosed_OpenChannel_NoRescue so it describes the actual Open → Closed scenario (channel was never Challenged) instead of the stale Finalize-while-Challenged wording. - nitronode/event_handlers/service.go: document at the issueChallengeRescue call site why SumUnsignedReceiverStateAmountsAfterVersion is strict `>` on closureVersion, and tighten the prev-state-missing error message. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review: the repo has no centralized security-findings index, so referencing "MF2-H01" is opaque to future readers. Replace with a short description of the dust-credit checkpoint-reset / dispute-timer attack the guard is preventing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e cycle A co-signed Finalize sets ChannelStatusClosing, which CheckActiveChannel excludes. A subsequent on-chain Challenge → Checkpointed sequence used to overwrite Closing → Challenged → Open, erasing the post-Finalize gate and letting SubmitState advance past the finalized state. On Checkpointed, when clearing Challenged, derive the post-Finalize state from channel_states via the new HasSignedFinalize lookup: if a Finalize row exists for the channel, restore Closing instead of Open. The signed Finalize row is the authoritative marker since Finalize rows are only written after the node signs.
📝 WalkthroughWalkthroughThis PR extends challenge-resolution logic with a query to detect node-signed Finalize states. When checkpointed events clear a Challenged status, the handler now restores channels to Closing (if Finalize exists) or Open, and explicitly clears challenge-expiry timestamps during all terminal-state transitions (Closed, Closing, Open). ChangesChallenge Finalize State Detection and Cleanup
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
Event handlers that resolve a challenged channel (Checkpointed) or move it to a terminal status (HomeChannelClosed, EscrowDepositFinalized, EscrowWithdrawalFinalized) flipped channel.Status but left channel.ChallengeExpiresAt populated. The stale deadline surfaced through the channel API and could mislead clients, dashboards, and operators into believing a now-open or closed channel still had an active challenge. Clear ChallengeExpiresAt alongside the status update in all four handlers and tighten the corresponding test matchers to assert the cleared field.
The handler records the node's on-chain liquidity via SetNodeBalance; the previous comment misleadingly described it as updating user staked balance.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
🧹 Nitpick comments (4)
nitronode/store/database/db_store_test.go (1)
1128-1202: ⚡ Quick winConsider adding test coverage for unsigned Finalize edge case.
The test covers the happy path (node-signed Finalize exists → true) and the negative case (no Finalize → false). If you adopt the recommendation to explicitly check
node_sig IS NOT NULLin the query, consider adding a test case that stores a Finalize state without a node signature and verifies the method returns false. This would ensure the defensive check works as intended.🧪 Example test case
t.Run("False when unsigned Finalize exists", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() store := NewDBStore(db) setupChannel(t, store) // Store Finalize without node signature storeState(t, store, 7, core.TransitionTypeFinalize, false) got, err := store.HasSignedFinalize(homeChannelID) require.NoError(t, err) assert.False(t, got, "unsigned Finalize should not be detected") })🤖 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 `@nitronode/store/database/db_store_test.go` around lines 1128 - 1202, Add a test case to TestDBStore_HasSignedFinalize that verifies an unsigned Finalize is not counted: use the existing setupChannel helper and the storeState helper to store a Finalize (core.TransitionTypeFinalize) with hasNodeSig=false, call HasSignedFinalize(homeChannelID) and assert it returns false (no error); this ensures the DBStore.HasSignedFinalize implementation correctly requires a non-null node signature.nitronode/store/database/state.go (2)
264-264: 💤 Low valueRemove unnecessary Limit(1) before Count.
The
Limit(1)clause has no effect onCount()in SQL—Count still processes all matching rows. This line is misleading and can be removed for clarity.♻️ Proposed cleanup
err := s.db.Model(&State{}). Where("home_channel_id = ? AND transition_type = ?", strings.ToLower(channelID), uint8(core.TransitionTypeFinalize)). - Limit(1). Count(&count).Error🤖 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 `@nitronode/store/database/state.go` at line 264, Remove the unnecessary Limit(1) call that precedes a Count() on the DB query in state.go; locate the query chain that calls Limit(1).Count(...) (within the method using the store/database state) and delete the Limit(1) invocation so that the Count call is not misleading and accurately reflects that it counts all matching rows.
253-270: ⚡ Quick winConsider explicitly checking node_sig IS NOT NULL for defensive programming.
The implementation relies on the invariant stated in the doc comment (line 257-258) that Finalize rows are only written after the node signs. However, for an audit-finding fix, it would be safer to explicitly check
node_sig IS NOT NULLin the WHERE clause rather than relying on an application-level invariant that could be violated through bugs, data migrations, or manual database changes.🛡️ Proposed defensive query
func (s *DBStore) HasSignedFinalize(channelID string) (bool, error) { var count int64 err := s.db.Model(&State{}). - Where("home_channel_id = ? AND transition_type = ?", - strings.ToLower(channelID), uint8(core.TransitionTypeFinalize)). + Where("home_channel_id = ? AND transition_type = ? AND node_sig IS NOT NULL", + strings.ToLower(channelID), uint8(core.TransitionTypeFinalize)). Limit(1). Count(&count).Error🤖 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 `@nitronode/store/database/state.go` around lines 253 - 270, HasSignedFinalize currently counts Finalize rows relying on the invariant that node_sig is set; update the DB query in HasSignedFinalize (on the State model) to explicitly require node_sig IS NOT NULL in the WHERE clause in addition to home_channel_id = strings.ToLower(channelID) and transition_type = uint8(core.TransitionTypeFinalize), so the count only includes rows with a non-null node_sig; keep the Count(&count) logic and existing error handling.pkg/blockchain/evm/channel_hub_reactor.go (1)
79-81: 💤 Low valueConsider aligning the doc comment with the other interfaces.
The doc comment for
HasSignedFinalizein this interface is shorter than inpkg/core/interface.goandnitronode/store/database/interface.go, which both explain the use case (detecting post-Finalize lifecycle when status is overwritten by challenge). Adding this context here would improve consistency and help future maintainers understand the purpose.📝 Suggested doc comment alignment
- // HasSignedFinalize reports whether a node-signed Finalize state exists for the given - // home channel. + // HasSignedFinalize reports whether a node-signed Finalize state exists for the given + // home channel. Used by event handlers to detect the post-Finalize lifecycle when the + // channel status field has been temporarily overwritten by an on-chain challenge. HasSignedFinalize(channelID string) (bool, error)🤖 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 `@pkg/blockchain/evm/channel_hub_reactor.go` around lines 79 - 81, Update the doc comment for HasSignedFinalize to match the other interfaces by adding the use-case description: explain that HasSignedFinalize(channelID string) reports whether a node-signed Finalize state exists for the given home channel and is used to detect the post-Finalize lifecycle when a channel's status may be overwritten by a later challenge; keep the signature and return types unchanged and ensure the comment wording mirrors the explanatory intent used in the other interface declarations.
🤖 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.
Nitpick comments:
In `@nitronode/store/database/db_store_test.go`:
- Around line 1128-1202: Add a test case to TestDBStore_HasSignedFinalize that
verifies an unsigned Finalize is not counted: use the existing setupChannel
helper and the storeState helper to store a Finalize
(core.TransitionTypeFinalize) with hasNodeSig=false, call
HasSignedFinalize(homeChannelID) and assert it returns false (no error); this
ensures the DBStore.HasSignedFinalize implementation correctly requires a
non-null node signature.
In `@nitronode/store/database/state.go`:
- Line 264: Remove the unnecessary Limit(1) call that precedes a Count() on the
DB query in state.go; locate the query chain that calls Limit(1).Count(...)
(within the method using the store/database state) and delete the Limit(1)
invocation so that the Count call is not misleading and accurately reflects that
it counts all matching rows.
- Around line 253-270: HasSignedFinalize currently counts Finalize rows relying
on the invariant that node_sig is set; update the DB query in HasSignedFinalize
(on the State model) to explicitly require node_sig IS NOT NULL in the WHERE
clause in addition to home_channel_id = strings.ToLower(channelID) and
transition_type = uint8(core.TransitionTypeFinalize), so the count only includes
rows with a non-null node_sig; keep the Count(&count) logic and existing error
handling.
In `@pkg/blockchain/evm/channel_hub_reactor.go`:
- Around line 79-81: Update the doc comment for HasSignedFinalize to match the
other interfaces by adding the use-case description: explain that
HasSignedFinalize(channelID string) reports whether a node-signed Finalize state
exists for the given home channel and is used to detect the post-Finalize
lifecycle when a channel's status may be overwritten by a later challenge; keep
the signature and return types unchanged and ensure the comment wording mirrors
the explanatory intent used in the other interface declarations.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e32fe25a-36d3-4b9d-9141-22a1e0eb2f86
📒 Files selected for processing (9)
nitronode/event_handlers/service.gonitronode/event_handlers/service_test.gonitronode/event_handlers/testing.gonitronode/store/database/db_store_test.gonitronode/store/database/interface.gonitronode/store/database/state.gopkg/blockchain/evm/channel_hub_reactor.gopkg/blockchain/evm/channel_hub_reactor_test.gopkg/core/interface.go
…etup Address remaining nksazonov review nits on PR 759: - `issueChallengeRescue`: clarify negative-sum error mentions receive states - `NewTransactionFromTransition`: extend nil-sender error to call out challenge_rescue - `TestHandleHomeChannelCheckpointed_Success`: swap bare struct literal for `newTestEventHandlerService(t)` so future mock returns of an unsigned head don't crash on a nil statePacker / nodeSigner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d sits above closure Two defensive hardenings on the squash that runs when a Challenged home channel is closed onchain: 1. SumUnsignedReceiverStateAmountsAfterVersion now requires an epoch arg and filters rows by it. Channel IDs are unique per nonce so cross-epoch rows shouldn't share a home_channel_id today, but pinning the epoch keeps the invariant explicit and would surface any future DB inconsistency that reused an ID across epochs. The caller in issueChallengeRescue passes prev.Epoch (the closed channel's last off-chain state), so the sum can never reach above the closed channel's natural lifetime. 2. The caller now asserts prev.Version > closureVersion whenever the sum is non-zero. Contributing rows live strictly above closureVersion by the query's WHERE clause, so the head must too; a violation means the state chain disagrees with itself and is worth surfacing before issuing a rescue built on a stale predecessor. Reorders the prev fetch above the sum call so its epoch can flow in cleanly. Updates all three SumUnsigned... interfaces (store, core handler, evm reactor), both mocks, and the DB-level test (adds a cross-epoch exclusion case). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, drop GetHomeChannelStatus issueTransferReceiverState (channel_v1) and issueReleaseReceiverState (app_session_v1) previously gated node-signing on a denylist (skip when status == Challenged, sign otherwise). That left Closing- and Closed-status channels signed: a Closing channel has a co-signed Finalize in flight and a Closed channel will never settle, so attaching a node sig to a fresh receiver state on either is pointless and on Closing can produce a row that races the cooperative close. Switch to an allowlist anchored to the receiver's *active* home channel: CheckActiveChannel already returns a non-nil status only for Open or Void channels and nil for Challenged / Closing / Closed. Sign only when the returned status is Open; otherwise persist the receiver row unsigned. The Challenged path still falls into the unsigned branch (the squash at close picks it up), Closing / Closed avoid producing a doomed node-signed credit, and the new check needs no additional DB method. That also lets us delete GetHomeChannelStatus end-to-end — it was added earlier in this PR purely to power the denylist, and CheckActiveChannel covers the new check. Also tighten backfillOffChainHeadNodeSig: after a challenge clears it now skips (with a debug log) when the off-chain head is not a receiver-credit transition, matching the spec wording. User ops are rejected upstream while the channel is Challenged so this should never fire, but a defensive guard avoids node-signing an unexpected transition kind if that invariant ever breaks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…serialize with receiver issuance issueTransferReceiverState (channel_v1) and issueReleaseReceiverState (app_session_v1) acquire LockUserState(receiver, asset) at the start of their RPC transactions, before reading channel status. HandleHomeChannelClosed previously didn't take that lock, leaving a window where an RPC receiver- issuance reading "channel is Challenged" could commit an unsigned receiver row after the event handler had already summed and issued the ChallengeRescue: that row would be orphaned with home_channel_id = closed channel, version above the closure version, and would never be redeemed. Acquire the same lock at the top of HandleHomeChannelClosed (after the channel-type guards, before UpdateChannel / sum / rescue). The two paths now serialize on the user_balances row: - If the RPC tx wins: it commits its unsigned receiver row first; the event tx then sees that row in the rescue sum. - If the event tx wins: it sets channel status to Closed and issues the rescue (which advances the user state head to HomeChannelID = nil); the RPC tx unblocks, picks up the post-rescue head via GetLastUserState, and produces a new state with HomeChannelID = nil — the "credit to a user with no open home channel" shape, no orphan possible. Add LockUserState to core.ChannelHubEventHandlerStore and evm.ChannelHubReactorStore (DBStore already implements it), wire the mocks, and add the expected call to all four HandleHomeChannelClosed_* tests. Concurrent integration coverage for both lock orders (Postgres FOR UPDATE serialization) is deferred to a follow-up — the lock primitive itself is already proven by LockUserState's own DBStore tests, and this commit only adds the call site. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nksazonov
left a comment
There was a problem hiding this comment.
Solid fix for the core Closing → Challenged → Open regression — the HasSignedFinalize lookup in HandleHomeChannelCheckpointed is the right mechanism. Two concerns require attention before landing: a gap in query defensiveness (see inline comments) and a structurally identical regression path via event replay that this fix does not cover.
Out-of-diff: HandleHomeChannelCreated can regress Closing → Open via event replay (service.go:66)
HandleHomeChannelCreated unconditionally sets channel.Status = core.ChannelStatusOpen. If the HomeChannelCreated on-chain event is replayed — chain reorg, indexer restart, or block reprocessing — a channel already in ChannelStatusClosing is reset to Open, re-arming CheckActiveChannel and the submission gate. This is structurally the same regression path this PR closes in HandleHomeChannelCheckpointed. The system already treats replay as a real concern (UpdateStateSigsIfMissing is explicitly idempotent). The same HasSignedFinalize guard, or a simpler if channel.Status >= core.ChannelStatusClosing { return nil } early-out, should be applied here.
Out-of-diff: backfillOffChainHeadNodeSig runs unconditionally after restoring Closing (service.go:158–159)
backfillOffChainHeadNodeSig is called in the wasChallenged branch regardless of the HasSignedFinalize result. In practice this is safe — the Finalize state is the head and already has NodeSig != nil, so the function exits early. But the intent is non-obvious: a reader must trace two functions to confirm no post-Finalize state ever receives a node signature here. The finalized variable is already in scope; gating the call on !finalized makes the invariant explicit and eliminates the silent dependency on the Finalize always being the head.
… closure Previous scope (MF2-H01 narrow): sum unsigned (TransferReceive, Release) amounts strictly above closureVersion. Covered only the dust-during- challenge attack; left two soundness gaps surfaced during review: 1. Signed receives above closure stranded. When the user challenges with an older state (e.g. to dispute a poisoned head), legitimate receives that landed pre-challenge sit above closureVersion in the DB but were excluded by `node_sig IS NULL` because the channel was Open at issuance, so the row is node-signed. User walked away undercredited. 2. Sends above closure never deducted. The recipient is credited offchain by the Node when the send is acknowledged; rescuing the user for receives without docking sends double-pays the hub (recipient gets the offchain credit AND the user keeps the higher onchain balance from rolling back). Replace the predicate with a net-change query: receives (TransferReceive, Release) above closure minus sends (TransferSend, Commit) above closure Drop `node_sig IS NULL` — signed pre-challenge transitions are real value the state-advancer already validated at submit time. HomeDeposit / HomeWithdrawal / escrow / migrate / finalize / acknowledgement stay excluded: they require onchain backing the chain didn't enforce at this closure or settle on a different ledger. Commit is included as a debit because the user committed funds to an app session whose release won't land if the channel closed stale, mirroring the TransferSend case. Caller (issueChallengeRescue) clamps the net at zero. Negative net is only reachable when the user closes at a version where her own channel balance was higher than the off-chain head — adversarial rollback of her own sends/commits. Onchain has already paid above the head value; rescue must not dock further. Rename SumUnsignedReceiverStateAmountsAfterVersion -> SumNetTransitionAmountAfterVersion across: - nitronode/store/database (impl, interface, sqlite test) - pkg/core (ChannelHubEventHandlerStore) - pkg/blockchain/evm (ChannelHubReactorStore) - mocks in nitronode/event_handlers/testing.go and pkg/blockchain/evm/channel_hub_reactor_test.go New DB-level test cases pin every scenario from the design discussion: pure dust during challenge, signed pre-challenge receives stranded, HomeDeposit poison exclusion, adversarial rollback (negative net), Commit deduction, excluded transition kinds, strict-> closure boundary, and the pre-existing cross-epoch exclusion. New event-handler test pins the negative-net clamp. Docs (docs/protocol/enforcement.md, protocol-description.md, contracts/SECURITY.md) updated to describe the broader scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the Finalize lookup self-enforcing instead of relying on the SubmitState invariant that node_sig is always set before the row is written. Drops the no-op Limit(1) before Count, and pins the contract with a test case that stores a Finalize row without a node signature and asserts the method returns false. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nnel HomeChannelCreated fires once per channel, transitioning the local row from Void to Open. Indexer restart, chain reorg, or block reprocessing can re-fire the event after the channel has advanced — most damagingly after the node has signed a Finalize and the row is in Closing. The unconditional Status = Open write would erase that marker and re-arm the submission gate past a finalized state, mirroring the Closing -> Challenged -> Open regression already fixed on the HandleHomeChannelCheckpointed path. Guard the handler with a Status >= Open early-return and a warning log so replays are observable. Covered by a table-driven test across Open, Challenged, Closing, Closed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Thanks for catching the On gating |
…nitronode-mf2-c01 # Conflicts: # nitronode/event_handlers/service_test.go # nitronode/store/database/interface.go # nitronode/store/database/state.go # pkg/blockchain/evm/channel_hub_reactor.go # pkg/core/interface.go
There was a problem hiding this comment.
Approving after clarification. The future C-01 path this PR targets appears covered.
Given the only current environment is stress, and stress does not contain the Open + signed Finalize edge state, I do not see a migration or repair blocker here.
The Open + signed Finalize case can stay as a P2 hardening follow-up for corrupted or future persistent state.
… fix/nitronode-mf2-c01 # Conflicts: # nitronode/event_handlers/service.go # nitronode/event_handlers/service_test.go # nitronode/event_handlers/testing.go # nitronode/store/database/db_store_test.go # nitronode/store/database/interface.go # nitronode/store/database/state.go # pkg/blockchain/evm/channel_hub_reactor.go # pkg/blockchain/evm/channel_hub_reactor_test.go # pkg/core/interface.go
- MF2-I02: fix: add SESSION_KEY_AUTH_TYPEHASH to session key authorization payload (#767) - MF2-M02: accept pre-finalize escrow version in ongoing check (#765) - MF2-L03: docs: document home chain migration as not yet active off-chain (#769) - MF2-I03: reject reused home channel ID explicitly (#766) - MF2-H03: guard challenge_rescue against post-Finalize close (#768) - MF2-C02: bound ledger values to Solidity uint256/int256 ranges (#764) - MF2-C01: preserve post-Finalize closing marker across challenge cycle (#762) - MF2-M01: fix(sdk): use keccak256(utf8) for session key application ID hashing (#761) - MF2-H01: harden receiver-state issuance and dispute resolution (#759) - MF2-L01: gate transfer_send during channel creation (#763)
Findings addressed
C-01: post-Finalize submission gate erased across challenge cycle
When
SubmitState()signs aTransitionTypeFinalizestate, it updates the channel status toChannelStatusClosing.CheckActiveChannel()then excludesClosingchannels because it only returns home channels withstatus <= ChannelStatusOpen, preventing further user-initiated state submissions.However, the event handlers can later regress the local lifecycle status.
HandleHomeChannelChallenged()unconditionally setschannel.Status = core.ChannelStatusChallenged, even if the channel was already markedChannelStatusClosingbecause the node signed a newer finalized state. If a laterChannelCheckpointedevent is processed,HandleHomeChannelCheckpointed()changesChannelStatusChallengedback toChannelStatusOpen.This allows the
Closingmarker to be erased by delayed onchain events. For example, a user can checkpoint stateN, obtain signed statesN + 1andN + 2, challenge onchain withN + 1, and immediately request a finalized offchain stateN + 3. The node marks the channelClosing. When theChannelChallengedevent forN + 1is processed, the DB status becomesChallenged. When the user checkpointsN + 2onchain, theChannelCheckpointedevent moves the DB status back toOpen.At that point, the database again contains an active/open channel even though the latest offchain state signed by the node is finalized. The original post-finalize path can become reachable again:
SubmitState()can accept a new transition after the finalized state becauseCheckActiveChannel()sees anOpenchannel, andStateAdvancerV1.ValidateAdvancement()can advance from the finalized state into a new epoch. This undermines the remediation for the critical post-finalize funds-theft issue.Fix. On
ChannelCheckpointed, when clearingChallenged, derive the post-Finalize state fromchannel_statesvia a newHasSignedFinalizelookup. RestoreClosinginstead ofOpenwhen a node-signed Finalize row exists for the channel. The status field carries onchain reality while the dispute is live; the binding offchain Finalize fact is shadowed inchannel_statesand reapplied on resolution. No newChannelStatusenum value, no DB migration, no API/SDK fan-out.A second vector through the same regression —
HandleHomeChannelCreatedreplaying over an initialized channel (reorg / indexer restart re-firesCreatedover a currentClosingand erases the Finalize marker) — is guarded by aStatus >= ChannelStatusOpenshort-circuit with a warning log.L-02: stale
ChallengeExpiresAtafter challenge resolutionHandleHomeChannelCheckpointed()clears a challenged home channel by changingchannel.Statusfromcore.ChannelStatusChallengedtocore.ChannelStatusOpen, but it does not clearchannel.ChallengeExpiresAt. SinceHandleHomeChannelChallenged()persistschannel.ChallengeExpiresAtwhen a challenge begins, a later checkpoint that resolves the challenge can leave the local database with an open channel that still reports a stale challenge expiry.This does not appear to block normal state submission, because
CheckOpenChannel()uses the channel status rather thanChallengeExpiresAtwhen deciding whether a home channel is open. However, the stale timestamp is exposed through the channel API and can mislead clients, dashboards, or operators into believing an open channel still has an active or historical challenge deadline attached to it.Fix.
HandleHomeChannelCheckpointed()now setschannel.ChallengeExpiresAt = nilalongside the status flip when clearingChallenged.HandleHomeChannelClosed()mirrors the same clear on terminal close so a lingering expiry never trails a closed channel.I-01: misleading
HandleNodeBalanceUpdateddoc commentThe comment for
HandleNodeBalanceUpdated()states that the handler updates the user's staked balance for the specified blockchain. The implementation actually callsSetNodeBalance()withevent.BlockchainID,event.Asset, andevent.Balance, which stores the node's onchain liquidity metric undernode_balance. This can mislead maintainers into thinking the event affects user staking state, while it only updates observability data for node liquidity.Fix. Corrected the doc comment on
HandleNodeBalanceUpdatedto describe the actual semantics: it upserts the node's onchain liquidity for the given blockchain/asset pair, not user staking state.Test plan
go vet ./...go test ./...TestHandleHomeChannelCheckpointed_FromChallengedWithSignedFinalizeexercises the C-01 audit scenario: status restored toClosingwhen a signed Finalize exists.TestDBStore_HasSignedFinalizecovers the storage primitive (present / absent / present-but-unsigned).wasChallenged-path tests updated to mock the new lookup and assertChallengeExpiresAt == nilafter resolution (L-02 coverage).Summary by CodeRabbit