Skip to content

MF2-H01: harden receiver-state issuance and dispute resolution#759

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

MF2-H01: harden receiver-state issuance and dispute resolution#759
philanton merged 15 commits into
fix/audit-findings-finalx2from
fix/nitronode-mf2-h01

Conversation

@philanton
Copy link
Copy Markdown
Contributor

@philanton philanton commented May 14, 2026

Summary

Fixes two security findings:

  • MF2-H01 — receiver-credit issuance node-signed newer states for a ChannelStatusChallenged home channel, enabling dispute reset via checkpoint.
  • MF2-H02 — receiver's latest stored state could pin a closed home channel as the active head: request_creation rejected fresh channels with channel is already initialized, follow-up RPCs failed with user has no active channel, and incoming receives could still node-sign credits onto the closed channel. User had to migrate wallets to recover use of the asset.

Five remediations, each adding tests:

  1. Restrict node co-signing of receiver states to Open home channels. issueTransferReceiverState (channel_v1) and issueReleaseReceiverState (app_session_v1) now allowlist via CheckActiveChannel: node-signs only when the receiver's active home channel is Open. Receiver states for Challenged, Closing, and Closed channels are persisted unsigned (still tracked for reconciliation, never returned for onchain submission). Closes the MF2-H01 dust-checkpoint vector and also closes the MF2-H02 vector of incoming sends getting node-signed onto an already-closed home channel.

  2. Backfill node sig on the off-chain head when a challenge clears. HandleHomeChannelCheckpointed now recomputes the node signature for the channel's highest stored version (GetLastStateByChannelID) rather than the on-chain enforced version, so any during-challenge receiver state that became the new latest row is fully co-signed. UpdateStateUserSigIfMissing is generalized to UpdateStateSigsIfMissing(channelID, version, userSig, nodeSig). Open→Open checkpoints stay on the prior user-sig-only path. Defensive guard skips backfill when the head transition is not a receiver-credit type.

  3. Squash challenge-window net channel change into ChallengeRescue on Challenged → Closed; emit unconditionally to clear the closed-channel head (MF2-H02). New DB query SumNetTransitionAmountAfterVersion returns receives − sends above the closure version, where receives = (TransferReceive, Release) and sends = (TransferSend, Commit), across both signed and unsigned rows. Caller clamps net at zero (negative net is reachable only when the user closes at a version where her own channel balance exceeded the off-chain head — adversarial rollback of her own sends/commits; on-chain has already paid above head, rescue must not dock further). HomeChannelClosed emits a single ChallengeRescue (transition + transaction type 201) on the user's ledger — fresh epoch, version 1, HomeChannelID nil, AccountID = closed channel ID — built via core.NewChallengeRescueState. Emission is unconditional on Challenged → Closed (zero-amount allowed): this is the MF2-H02 fix. Without it, the receiver's latest stored state retains the closed HomeChannelID, IsFinal() returns false on the non-finalize head, NextState() inherits the closed channel ID, channels.v1.request_creation fails with channel is already initialized, and submit_state calls fail with user has no active channel. The rescue advances the user's head to HomeChannelID = nil so request_creation proceeds and subsequent receiver issuance produces the "credit a user with no open channel" shape. SumNetTransitionAmountAfterVersion is pinned to the previous head's epoch; the caller asserts prev.Version > closureVersion whenever the sum is non-zero. (Cooperative Open → Closing → Closed closes via Finalize, so IsFinal() is true and no rescue is needed; that path is unaffected.)

  4. Serialize event-handler status mutations with receiver-issuance RPCs via LockUserState. HandleHomeChannelClosed, HandleHomeChannelChallenged, and HandleHomeChannelCheckpointed now acquire LockUserState(user, asset) after type guards and before status mutation / sum / rescue / backfill. Closes a race window where an in-flight RPC reading Status = Open (or Challenged) via CheckActiveChannel could commit a node-signed or unsigned receiver row after the handler had already flipped status, producing orphaned rows (Open channel with unsigned latest head not reached by backfill; node-signed receiver on a disputed channel; receiver row above closure version that the rescue sum already finalized). LockUserState added to core.ChannelHubEventHandlerStore and evm.ChannelHubReactorStore (DBStore already implemented it).

  5. Document the accepted session-key off-chain scope-enforcement gap. SessionKeyValidator on-chain validates cryptographic signatures only; off-chain ValidateChannelSessionKeyForAsset (expiration, asset scope) runs only on the node's acknowledgement path (channels.v1.submit_state with TransitionTypeAcknowledgement). A holder of a session key ever authorized on the channel — including expired, revoked, or retired keys — can fetch the node-signed receive state, sign manually, and submit directly on-chain. Outcome is bounded by states the user already signed and the receive state's strictly-additive allocation effect on the user, so the gap is griefing-only on the user's own sends, not theft; and the direct-submission path is the only recovery when the node is unavailable. Documented in contracts/SECURITY.md and docs/protocol/security-and-limitations.md.

Protocol docs (docs/protocol/enforcement.md, protocol-description.md) updated to describe the broader scope. challenge_rescue added to transition_type and transaction_type enums in docs/api.yaml. contracts/SECURITY.md updated for the net-change rescue scope and the accepted limitation.

Test plan

  • go test ./... — full suite green
  • Unit: receiver-state issuance against a non-Open home channel (Challenged / Closing / Closed) produces unsigned rows in both channel_v1 (transfer) and app_session_v1 (release) flows; Open produces node-signed
  • Unit: HandleHomeChannelCheckpointed re-signs the off-chain head (highest stored version) when challenge clears; no-op when head is already signed; skips when head transition is not a receiver-credit type; no head-sig call on Open→Open
  • Unit + DB: HomeChannelClosed while Challenged emits one ChallengeRescue with max(0, receives − sends) above closure; zero-amount rescue still emitted to advance epoch (MF2-H02 regression); deterministic txID = GetReceiverTransactionID(closedChannelID, newStateID); no rescue on Open→Closed (cooperative finalize)
  • DB: SumNetTransitionAmountAfterVersion — dust receives during challenge; signed pre-challenge receives stranded above closure included; HomeDeposit / finalize / acknowledgement / escrow / migrate excluded; adversarial rollback (negative net) returned; Commit deduction; strict > closure boundary; cross-epoch exclusion
  • Handler: negative-net clamp at caller
  • Concurrency: HandleHomeChannelClosed, HandleHomeChannelChallenged, HandleHomeChannelCheckpointed acquire LockUserState before status mutation / backfill — captured-flag ordering tests on UpdateChannel
  • Core: NewChallengeRescueState accepts amount ≥ 0 (rejects negative), rejects prev.HomeChannelID == nil; produces version=1, empty token/blockchain, balance credited

Postgres FOR UPDATE integration coverage for both lock orders (RPC wins vs event wins) deferred to a follow-up; the lock primitive is proven by DBStore.LockUserState's own tests, and the call sites are pinned by the ordering tests above.

🤖 Generated with Claude Code

nksazonov and others added 5 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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

📝 Walkthrough

Walkthrough

This PR implements a challenge-rescue flow that preserves accrued unsigned receiver credits when a disputed home channel closes. When a channel enters CHALLENGED status, the node stops co-signing receive-state transitions and queues them unsigned; upon closure after challenge expiry, these credits are squashed into a ChallengeRescue state stored off-chain with transaction recording.

Changes

Challenge Rescue Implementation

Layer / File(s) Summary
Specification and challenge-rescue types
contracts/SECURITY.md, docs/api.yaml, docs/protocol/enforcement.md, protocol-description.md, pkg/core/types.go, pkg/core/types_test.go
Specification documents define queued unsigned receive-credit behavior and challenge-resolution paths during CHALLENGED status; core types introduce NewChallengeRescueState constructor, TransitionTypeChallengeRescue/TransactionTypeChallengeRescue enums (value 201), string mappings, and logic to derive rescue transactions; tests validate rescue state construction and error handling.
Storage layer interfaces and implementations
nitronode/store/database/interface.go, nitronode/store/database/state.go, nitronode/store/database/channel.go, nitronode/store/database/channel_test.go, nitronode/store/database/db_store_test.go, pkg/core/interface.go, pkg/blockchain/evm/channel_hub_reactor.go
Database interfaces add GetHomeChannelStatus, UpdateStateSigsIfMissing (dual-signature idempotent backfill), and SumUnsignedReceiverStateAmountsAfterVersion (unsigned-receiver credit aggregation); DBStore implements home-channel status lookup, combined signature backfilling across home/escrow columns, and signed/unsigned receiver-state amount summation; comprehensive tests verify backfill idempotency, credit summation filtering, and channel-ID normalization.
Handler APIs: status-aware state-signing
nitronode/api/app_session_v1/interface.go, nitronode/api/app_session_v1/handler.go, nitronode/api/app_session_v1/testing.go, nitronode/api/channel_v1/interface.go, nitronode/api/channel_v1/handler.go, nitronode/api/channel_v1/testing.go
Handler Store interfaces add GetHomeChannelStatus; app-session and channel handlers check receiver home-channel status and conditionally sign receiver states—signing proceeds normally unless the home channel is in ChannelStatusChallenged, in which case state packing/signing is skipped and logged; mock implementations support test configuration of home-channel status.
Handler tests: challenged receiver status
nitronode/api/app_session_v1/submit_app_state_test.go, nitronode/api/channel_v1/submit_state_test.go
New tests for withdraw-intent and transfer-send flows verify that when receiver home-channel is Challenged, the persisted receiver state includes no node signature while sender state retains it; existing tests updated to mock GetHomeChannelStatus returning ChannelStatusOpen.
Event handler service: wiring and rescue issuance
nitronode/event_handlers/service.go, nitronode/main.go
EventHandlerService gains nodeSigner and statePacker dependencies injected at construction; HandleHomeChannelCheckpointed tracks prior-challenged state and backfills missing node signature on the off-chain head when challenge was cleared; HandleHomeChannelClosed computes unsigned receiver-credit sum via the new database method and issues a ChallengeRescue state with transaction recording when closure resolves a prior challenge.
Lifecycle handler API migration
nitronode/event_handlers/service.go, nitronode/event_handlers/testing.go
All home and escrow channel lifecycle handlers (Created, Checkpointed, Challenged, Closed, escrow deposit/withdrawal initiated/challenged/finalized) updated from UpdateStateUserSigIfMissing to UpdateStateSigsIfMissing to support both user and node signature backfilling; mock store updated with new methods for dual-signature backfill, unsigned-receiver aggregation, user-state persistence, and transaction recording.
Event handler comprehensive testing
nitronode/event_handlers/service_test.go
All existing lifecycle tests updated to use UpdateStateSigsIfMissing; new tests validate head-node signature backfill with on-the-fly verification, no re-signing when head is already node-signed, rescue-state emission with unsigned-receiver credit squashing, rescue suppression when no credits accrue, and deterministic transaction recording; uses real signer and mocked asset store for cryptographic validation.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • layer-3/nitrolite#753: Both PRs modify the escrow "challenged" event handling in nitronode/event_handlers/service.go (notably HandleEscrowDepositChallenged): the main PR changes the state-signature backfill semantics during disputes, while the retrieved PR adds scheduling of a home-chain Challenge via ScheduleChallenge, so the changes are directly related at the handler/code-path level.

Suggested reviewers

  • dimast-x
  • nksazonov
  • ihsraham

Poem

🐇 When channels face dispute, we queue without haste,
No signatures needed while challenges taste
The off-chain delays, then when closures align,
A rescue state squashes the credits so fine.
The rabbit approves! 🎯

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.41% 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 clearly and concisely summarizes the main security remediation focus: hardening receiver-state issuance and dispute resolution mechanisms.
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-h01

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.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
nitronode/event_handlers/service.go (2)

22-30: 💤 Low value

Doc comment understates NewEventHandlerService responsibilities.

The signer/packer are also used to construct the rescue path on HomeChannelClosed, not just the checkpoint backfill. Worth a small expansion so future readers don't assume the deps are checkpoint-only.

📝 Proposed doc tweak
 // NewEventHandlerService creates a new EventHandlerService instance.
-// nodeSigner and statePacker are used to backfill the node signature on the
-// checkpointed head state when it is missing from the local record.
+// nodeSigner and statePacker are used to backfill the node signature on the
+// off-chain head state when a challenge clears, and (via the rescue path)
+// to produce signed state material during HomeChannelClosed handling.
 func NewEventHandlerService(nodeSigner *core.ChannelDefaultSigner, statePacker core.StatePacker) *EventHandlerService {
🤖 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/event_handlers/service.go` around lines 22 - 30, The doc comment
for NewEventHandlerService currently says nodeSigner and statePacker are used to
backfill the node signature on the checkpointed head state; update it to also
state these dependencies are used to construct the rescue path on
HomeChannelClosed. Locate the NewEventHandlerService constructor and the
EventHandlerService type and expand the comment to mention both responsibilities
(checkpoint backfill and rescue-path construction for HomeChannelClosed) and
reference nodeSigner and statePacker by name so future readers aren’t misled.

152-174: 💤 Low value

Helper looks correct; verify it tolerates head.Version == event.StateVersion.

When a challenge clears without any during-challenge receiver state, the head equals the on-chain checkpointed row, which was just user-sig-backfilled on lines 126–130. In that case the row at head.Version may already be node-signed (if the original RPC path signed it) or still missing a node sig (if it was originally a receiver-only state checkpointed on chain). The current logic correctly node-signs the missing case and is a no-op when head.NodeSig != nil, so this is well-behaved. The doc comment on lines 132–136 ("the off-chain head may sit above event.StateVersion") reads as if head.Version > event.StateVersion is the only invocation case — consider rewording slightly so the head-equals-checkpoint case is documented as the "no-op" path explicitly.

🤖 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/event_handlers/service.go` around lines 152 - 174, Update the
function comment for backfillOffChainHeadNodeSig to clarify that the off-chain
head can be equal to event.StateVersion (not only greater), and explicitly
document that when head.Version equals the checkpointed on-chain state the
function is a no-op if head.NodeSig is already present; mention that the
function will still sign the row if the checkpointed head is missing the node
signature. Reference the backfillOffChainHeadNodeSig function and its behavior
around head.Version and head.NodeSig when rewording.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@nitronode/api/channel_v1/submit_state_test.go`:
- Around line 240-261: The test setup currently ignores errors returned from
core.NewChannelDefaultSigner for nodeSigner and userWalletSigner; update these
calls to capture the error values and fail the test on error (e.g., check err
and call t.Fatalf/t.Fatal or require.NoError) so setup failures are not
swallowed. Locate the two NewChannelDefaultSigner calls (assigning nodeSigner
and userWalletSigner) and replace the blank-identifier ignores with proper error
variables and assertions that abort the test if an error is returned.

In `@nitronode/event_handlers/service_test.go`:
- Around line 1433-1475: The test comment for
TestHandleHomeChannelClosed_OpenChannel_NoRescue is inaccurate: the code sets
channel.Status = core.ChannelStatusOpen and asserts that
SumUnsignedReceiverStateAmountsAfterVersion is not called, so this is an
Open→Closed happy-path test, not a "Finalize on Challenged" race; either update
the function comment to describe the Open→Closed scenario (referencing
TestHandleHomeChannelClosed_OpenChannel_NoRescue, channel.Status, and the mocked
calls: GetChannelByID, UpdateChannel, RefreshUserEnforcedBalance,
UpdateStateSigsIfMissing and the negative assertions on
SumUnsignedReceiverStateAmountsAfterVersion/StoreUserState/RecordTransaction),
or modify the test to exercise the Challenged→Finalize race by setting
channel.Status = core.ChannelStatusChallenged, stubbing
mockStore.SumUnsignedReceiverStateAmountsAfterVersion(channelID, closureVersion)
to return 0, and then keeping the same assertions that StoreUserState and
RecordTransaction are not called; pick one and make the corresponding change so
comment, setup, and assertions align.

In `@nitronode/event_handlers/service.go`:
- Around line 295-318: Clarify and harden issueChallengeRescueIfNeeded:
explicitly document/verify SumUnsignedReceiverStateAmountsAfterVersion's ">"
semantics so we don't silently exclude unsigned receiver states at version ==
closureVersion (add a comment and/or an explicit DB check that no unsigned
receiver-state rows exist with version == closureVersion if the protocol forbids
them), and add an assertion or explicit error check after retrieving prev with
GetLastStateByChannelID(channel.ChannelID, false) that prev.Version >
closureVersion (or return a clear error/log if not) before using prev as the
predecessor for core.NewChallengeRescueState to surface DB/invariant violations
early.

---

Nitpick comments:
In `@nitronode/event_handlers/service.go`:
- Around line 22-30: The doc comment for NewEventHandlerService currently says
nodeSigner and statePacker are used to backfill the node signature on the
checkpointed head state; update it to also state these dependencies are used to
construct the rescue path on HomeChannelClosed. Locate the
NewEventHandlerService constructor and the EventHandlerService type and expand
the comment to mention both responsibilities (checkpoint backfill and
rescue-path construction for HomeChannelClosed) and reference nodeSigner and
statePacker by name so future readers aren’t misled.
- Around line 152-174: Update the function comment for
backfillOffChainHeadNodeSig to clarify that the off-chain head can be equal to
event.StateVersion (not only greater), and explicitly document that when
head.Version equals the checkpointed on-chain state the function is a no-op if
head.NodeSig is already present; mention that the function will still sign the
row if the checkpointed head is missing the node signature. Reference the
backfillOffChainHeadNodeSig function and its behavior around head.Version and
head.NodeSig when rewording.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1f3e5a54-b3a6-42c6-85ab-3f16330c5f7d

📥 Commits

Reviewing files that changed from the base of the PR and between 80af194 and aeb0a6f.

📒 Files selected for processing (26)
  • contracts/SECURITY.md
  • docs/api.yaml
  • docs/protocol/enforcement.md
  • nitronode/api/app_session_v1/handler.go
  • nitronode/api/app_session_v1/interface.go
  • nitronode/api/app_session_v1/submit_app_state_test.go
  • nitronode/api/app_session_v1/testing.go
  • nitronode/api/channel_v1/handler.go
  • nitronode/api/channel_v1/interface.go
  • nitronode/api/channel_v1/submit_state_test.go
  • nitronode/api/channel_v1/testing.go
  • nitronode/event_handlers/service.go
  • nitronode/event_handlers/service_test.go
  • nitronode/event_handlers/testing.go
  • nitronode/main.go
  • nitronode/store/database/channel.go
  • nitronode/store/database/channel_test.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
  • pkg/core/types.go
  • pkg/core/types_test.go
  • protocol-description.md

Comment thread nitronode/api/channel_v1/submit_state_test.go Outdated
Comment thread nitronode/event_handlers/service_test.go Outdated
Comment thread nitronode/event_handlers/service.go Outdated
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>
@philanton philanton changed the base branch from main to fix/audit-findings-finalx2 May 14, 2026 12:37
- 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>
Comment thread nitronode/api/channel_v1/submit_state_test.go Outdated
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>
Comment thread nitronode/event_handlers/service.go Outdated
Comment thread nitronode/store/database/channel.go Outdated
Comment thread pkg/core/types.go Outdated
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.

Nice work! The three security remediations described in the added docs are all correctly implemented, well-tested, and match the spec. All findings below are informational.

Comment thread nitronode/event_handlers/service.go
Comment thread nitronode/api/channel_v1/handler.go Outdated
Comment thread nitronode/api/app_session_v1/handler.go Outdated
Comment thread nitronode/store/database/state.go Outdated
Comment thread nitronode/event_handlers/service_test.go
philanton and others added 5 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>
… 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>
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.

I would hold approval for now. The receiver-state signing rule looks right in the steady state, but challenge enter/clear is not serialized with the receiver issuance path. That leaves a race where a receive/release RPC can make a node-signed state after the challenge is observed onchain but before the local DB status update is visible, which is enough to recreate the H-01 reset precondition.

}

if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil {
if err := tx.UpdateStateSigsIfMissing(event.ChannelID, event.StateVersion, event.UserSig, ""); err != nil {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P1/blocking] This handler needs to take the same LockUserState(channel.UserWallet, channel.Asset) lock before mutating the channel to Challenged.

The receiver issuance paths already lock that row, then call CheckActiveChannel and may attach NodeSig when they still see Open. Without the same lock here, an in-flight transfer receive or app-session release can read Open, sign the receiver state, and commit after this challenge event, leaving a node-signed higher-version receiver state for a disputed channel.

I would make challenge entry use the same serialization pattern as HandleHomeChannelClosed: load the channel, lock the user/asset row, then update status/backfill signatures while still inside the transaction.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, good catch — same race class as the one 24cdaff1 closed for HandleHomeChannelClosed. Fixed in 8d914687: LockUserState(channel.UserWallet, channel.Asset) acquired right after the nil/type guards, before any mutation. RPC paths block until the challenge event commits and then re-check Status via CheckActiveChannel.

Added TestHandleHomeChannelChallenged_AcquiresUserLockBeforeMutation to pin the ordering via a captured-flag guard on UpdateChannel)

// channel's actual latest state. Backfill the node signature on that head so future
// flows treat it as fully co-signed. On normal Open→Open checkpoints the head row
// is already node-signed via the RPC path and this is a no-op.
if wasChallenged {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[P2] Challenge clearance should also take the user-state lock before flipping back to Open and calling backfillOffChainHeadNodeSig.

Otherwise a receiver RPC can read Challenged, choose to store an unsigned receiver state, then this handler can backfill the old head and commit before that RPC stores a newer unsigned head. The channel is now open, but the latest local head remains unsigned.

One way to close this is to lock (channel.UserWallet, channel.Asset) before UpdateChannel and the backfill. Then the RPC either commits before the backfill and is included, or waits and sees Open and signs normally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yep, same fix applied to HandleHomeChannelCheckpointed in 8d914687. LockUserState goes in after the nil/type guards and before UpdateChannel + backfillOffChainHeadNodeSig, so an RPC that read Status=Challenged either commits its unsigned row before us (and the backfill picks it as the head) or blocks until we set Status=Open and then sees that via its own re-check.

Took the lock unconditionally rather than only on wasChallenged — the Open→Open path still mutates StateVersion and calls UpdateStateSigsIfMissing, and uniform pattern across the three handlers is easier to reason about. Added TestHandleHomeChannelCheckpointed_AcquiresUserLockBeforeMutation to pin the ordering)

…nelCheckpointed

Same race class as MF2-H01 fixed in HandleHomeChannelClosed: receiver-issuance
RPC paths (issueTransferReceiverState / issueReleaseReceiverState) lock the
user/asset row and re-check channel status, but the challenge/checkpoint event
handlers mutated status without taking that lock. An in-flight RPC could sign
a receiver state for a status the channel no longer holds:

1. HandleHomeChannelChallenged: RPC reads Status=Open via CheckActiveChannel,
   node-signs a receiver state, commits after the handler flips status to
   Challenged. Result: node-signed higher-version receiver row on a disputed
   channel.

2. HandleHomeChannelCheckpointed: RPC reads Status=Challenged, decides to
   store an unsigned receiver row, commits after the handler flips back to
   Open and backfills the prior head via backfillOffChainHeadNodeSig. Result:
   Open channel with an unsigned latest head that the backfill never reached.

Serialize both handlers on LockUserState matching HandleHomeChannelClosed,
acquired after the nil/type guards and before any status mutation or backfill.
Lock is released when the surrounding event-processing transaction commits.

Existing handler tests gain the lock expectation. Two new
_AcquiresUserLockBeforeMutation tests pin the ordering via a captured-flag
guard on UpdateChannel.

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

Re-reviewed the latest head, the blocking race is addressed and focused checks pass.

@philanton philanton merged commit a8baab9 into fix/audit-findings-finalx2 May 18, 2026
7 checks passed
@philanton philanton deleted the fix/nitronode-mf2-h01 branch May 18, 2026 12:14
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