Skip to content

MF2-C01: preserve post-Finalize closing marker across challenge cycle#762

Merged
philanton merged 20 commits into
fix/audit-findings-finalx2from
fix/nitronode-mf2-c01
May 18, 2026
Merged

MF2-C01: preserve post-Finalize closing marker across challenge cycle#762
philanton merged 20 commits into
fix/audit-findings-finalx2from
fix/nitronode-mf2-c01

Conversation

@philanton
Copy link
Copy Markdown
Contributor

@philanton philanton commented May 14, 2026

Findings addressed

C-01: post-Finalize submission gate erased across challenge cycle

When SubmitState() signs a TransitionTypeFinalize state, it updates the channel status to ChannelStatusClosing. CheckActiveChannel() then excludes Closing channels because it only returns home channels with status <= ChannelStatusOpen, preventing further user-initiated state submissions.

However, the event handlers can later regress the local lifecycle status. HandleHomeChannelChallenged() unconditionally sets channel.Status = core.ChannelStatusChallenged, even if the channel was already marked ChannelStatusClosing because the node signed a newer finalized state. If a later ChannelCheckpointed event is processed, HandleHomeChannelCheckpointed() changes ChannelStatusChallenged back to ChannelStatusOpen.

This allows the Closing marker to be erased by delayed onchain events. For example, a user can checkpoint state N, obtain signed states N + 1 and N + 2, challenge onchain with N + 1, and immediately request a finalized offchain state N + 3. The node marks the channel Closing. When the ChannelChallenged event for N + 1 is processed, the DB status becomes Challenged. When the user checkpoints N + 2 onchain, the ChannelCheckpointed event moves the DB status back to Open.

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 because CheckActiveChannel() sees an Open channel, and StateAdvancerV1.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 clearing Challenged, derive the post-Finalize state from channel_states via a new HasSignedFinalize lookup. Restore Closing instead of Open when 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 in channel_states and reapplied on resolution. No new ChannelStatus enum value, no DB migration, no API/SDK fan-out.

A second vector through the same regression — HandleHomeChannelCreated replaying over an initialized channel (reorg / indexer restart re-fires Created over a current Closing and erases the Finalize marker) — is guarded by a Status >= ChannelStatusOpen short-circuit with a warning log.

L-02: stale ChallengeExpiresAt after challenge resolution

HandleHomeChannelCheckpointed() clears a challenged home channel by changing channel.Status from core.ChannelStatusChallenged to core.ChannelStatusOpen, but it does not clear channel.ChallengeExpiresAt. Since HandleHomeChannelChallenged() persists channel.ChallengeExpiresAt when 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 than ChallengeExpiresAt when 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 sets channel.ChallengeExpiresAt = nil alongside the status flip when clearing Challenged. HandleHomeChannelClosed() mirrors the same clear on terminal close so a lingering expiry never trails a closed channel.

I-01: misleading HandleNodeBalanceUpdated doc comment

The comment for HandleNodeBalanceUpdated() states that the handler updates the user's staked balance for the specified blockchain. The implementation actually calls SetNodeBalance() with event.BlockchainID, event.Asset, and event.Balance, which stores the node's onchain liquidity metric under node_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 HandleNodeBalanceUpdated to 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 ./...
  • New unit test TestHandleHomeChannelCheckpointed_FromChallengedWithSignedFinalize exercises the C-01 audit scenario: status restored to Closing when a signed Finalize exists.
  • New integration test TestDBStore_HasSignedFinalize covers the storage primitive (present / absent / present-but-unsigned).
  • Existing wasChallenged-path tests updated to mock the new lookup and assert ChallengeExpiresAt == nil after resolution (L-02 coverage).

Summary by CodeRabbit

  • Bug Fixes
    • Improved handling of challenged channels when disputes are resolved through checkpoints; channels now correctly transition to "Closing" if a finalization has been signed, or "Open" otherwise.
    • Fixed channel state tracking to properly clear challenge expiry information when channels transition to closed or reopened states.

Review Change Stack

nksazonov and others added 9 commits May 14, 2026 13:43
…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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

📝 Walkthrough

Walkthrough

This 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).

Changes

Challenge Finalize State Detection and Cleanup

Layer / File(s) Summary
Interface contracts for HasSignedFinalize
pkg/core/interface.go, nitronode/store/database/interface.go, pkg/blockchain/evm/channel_hub_reactor.go
Define the new HasSignedFinalize(channelID string) (bool, error) method across public store interfaces to query whether a node-signed Finalize state exists.
Database implementation of HasSignedFinalize
nitronode/store/database/state.go, nitronode/store/database/db_store_test.go
Implement HasSignedFinalize in DBStore by counting Finalize-transition rows, with integration test validating the query.
Event handler challenge resolution logic
nitronode/event_handlers/service.go
Update HandleHomeChannelCheckpointed to consult HasSignedFinalize when restoring from Challenged status (Closing if finalized, Open otherwise), clear ChallengeExpiresAt, and update related documentation; also clear ChallengeExpiresAt in home/escrow close handlers.
Event handler tests with challenge clearing assertions
nitronode/event_handlers/service_test.go
Expand test suite with assertions on ChallengeExpiresAt cleanup, mock HasSignedFinalize expectations for status-restoration paths, add regression test for signed-Finalize checkpoint handling, and update escrow-finalization tests.
Test doubles for HasSignedFinalize
nitronode/event_handlers/testing.go, pkg/blockchain/evm/channel_hub_reactor_test.go
Implement HasSignedFinalize mock methods in test stores to support realistic test scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • layer-3/nitrolite#720: Introduces ChallengeExpiresAt field and sets it when handling challenged events; this PR clears and uses that same field during downstream checkpoint/close transitions.
  • layer-3/nitrolite#754: Updates escrow challenge-expiry clearing on cooperative finalization in Solidity; this PR mirrors that cleanup in Go event handlers for both home and escrow channels.

Suggested reviewers

  • ihsraham
  • nksazonov
  • dimast-x

🐰 A finalize state once signed remains forever known,
Yet challenges may cloud the truth till checkpoints are shown.
When dispute clears, we peek at what's been locked away,
And restore the channel's heart to Closing—or Open—today!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.46% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'MF2-C01: preserve post-Finalize closing marker across challenge cycle' directly and accurately summarizes the main change: implementing logic to preserve the post-Finalize Closing status when challenges are resolved via Checkpointed events, addressing audit finding MF2-C01.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ 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/nitronode-mf2-c01

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.

philanton added 2 commits May 14, 2026 18:05
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.
@nksazonov
Copy link
Copy Markdown
Contributor

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 15, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Comment thread nitronode/store/database/state.go
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.

🧹 Nitpick comments (4)
nitronode/store/database/db_store_test.go (1)

1128-1202: ⚡ Quick win

Consider 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 NULL in 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 value

Remove unnecessary Limit(1) before Count.

The Limit(1) clause has no effect on Count() 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 win

Consider 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 NULL in 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 value

Consider aligning the doc comment with the other interfaces.

The doc comment for HasSignedFinalize in this interface is shorter than in pkg/core/interface.go and nitronode/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

📥 Commits

Reviewing files that changed from the base of the PR and between 6f38d21 and 8619685.

📒 Files selected for processing (9)
  • 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

philanton and others added 4 commits May 15, 2026 13:26
…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>
Copy link
Copy Markdown
Contributor

@nksazonov nksazonov left a comment

Choose a reason for hiding this comment

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

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.

Comment thread nitronode/store/database/state.go Outdated
Comment thread nitronode/store/database/db_store_test.go
Comment thread nitronode/event_handlers/service.go
philanton and others added 3 commits May 15, 2026 14:48
… 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>
@philanton
Copy link
Copy Markdown
Contributor Author

Thanks for catching the HandleHomeChannelCreated replay path — same regression vector through a different door (Void→Open is supposed to be one-shot; reorg/indexer-restart re-fires Created over a current Closing and erases the Finalize marker). Guarded with Status >= ChannelStatusOpen + warning log in f4a32bc, with a table-driven test across Open/Challenged/Closing/Closed.

On gating backfillOffChainHeadNodeSig on !finalized: I'd leave it. The function's invariant is "node-sign the head if missing", not "node-sign unless we're in Closing" — folding the Finalize semantics into the caller spreads the lifecycle concern. The existing head.NodeSig != nil early-return already covers the Finalize-as-head case explicitly, and gating on finalized would still call GetLastStateByChannelID in the non-finalized branch, so the perf delta is zero.

…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
Copy link
Copy Markdown
Contributor

@nksazonov nksazonov left a comment

Choose a reason for hiding this comment

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

Great work!

Copy link
Copy Markdown
Collaborator

@ihsraham ihsraham left a comment

Choose a reason for hiding this comment

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

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.

Base automatically changed from fix/nitronode-mf2-h01 to fix/audit-findings-finalx2 May 18, 2026 12:14
… 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
@philanton philanton merged commit 514a31e into fix/audit-findings-finalx2 May 18, 2026
7 checks passed
@philanton philanton deleted the fix/nitronode-mf2-c01 branch May 18, 2026 12:43
nksazonov added a commit that referenced this pull request May 19, 2026
- 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)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants