fix: audit findings round final 3#787
Conversation
## Summary - Solidity computes `userNetFlow + nodeNetFlow` as `int256` and `userAllocation + nodeAllocation` as `uint256`. Individually-valid scaled fields can still overflow these aggregates, producing a state the contract will always reject. - Adds aggregate bounds checks in both validation layers so the offchain/onchain mismatch is caught before signing: - `Ledger.Validate` (`pkg/core/types.go`) — checks scaled `sumBalances` against uint256 and `sumNetFlows` against int256. - `contractLedger.Validate` (`pkg/core/state_packer.go`) — checks scaled `*big.Int` sums via existing `checkUint256` / `checkInt256` helpers. - Defense in depth: domain layer fires via `state_advancer` before packing; ABI layer fires in `packSigningData` for any caller bypassing the advancer. ## Test plan - [x] `go test ./pkg/core/... -run "TestLedger|TestContractLedger|TestPackState" -v` - [x] `go test ./...` - [x] `go vet ./pkg/core/...` - [x] `go build ./...` New cases cover net-flow sum overflow / underflow, allocation sum overflow, and a boundary-pass case at exactly `maxInt256`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#780) ## Summary - Drop `HasSignedFinalize` skip in `HandleHomeChannelClosed`. Path-1 timeout close at version Y < F (when a node-signed Finalize exists locally) was silently skipping `challenge_rescue` and under-crediting the user for any receiver/release net accrued strictly above Y. - Source rescue `prev` via `GetLastUserState` across both channel-attached and detached chain entries. `NewChallengeRescueState` now branches on `prev.HomeChannelID`: detached prev → append at `(prev.Epoch, prev.Version+1)` inheriting the ledger; in-channel prev → wrap to `(prev.Epoch+1, 0)` as before. Eliminates the deterministic state-ID collision the old skip was working around. - Drop the redundant `epoch` filter from `SumNetTransitionAmountAfterVersion`. A channel's in-channel rows live at a single epoch; detached rows have `home_channel_id IS NULL`, already excluded by the channel predicate. ## Bug flow 1. User+node sign cooperative-close Finalize at version `F`; channel goes `Closing`. 2. Adversary challenges on-chain with old state at `Y < F` (intent != CLOSE per contract). Local status flips to `Challenged`. Auto-response disabled — operator must counter manually. 3. Operator misses challenge window. `closeChannel` path-1 fires: chain pays `userAlloc(Y)`, emits `ChannelClosed(prevState)`. 4. Reactor forwards version `Y` only. Handler sees `wasChallenged=true` AND `HasSignedFinalize=true` → **skips** rescue. 5. Net of receivers/releases in `(Y, F]` (real off-chain credits the node validated) plus any post-Finalize detached credits → lost. ## Fix verification | Scenario | wasChallenged | Sum >Y | prev | Placement | Outcome | |---|---|---|---|---|---| | Open → cooperative (no challenge race) | false | — | — | — | Skip (Status was `Closing`, not `Challenged`) | | Plain timeout, no Finalize | true | net (Y, head] | in-channel head | `(E+1, 0)` wrap | Unchanged from pre-fix | | **Timeout + offchain Finalize (the bug)** | true | `userAlloc(F) − userAlloc(Y)` | detached tip `(E+1, M)` | `(E+1, M+1)` append | **Fixed**: user credited | | Cooperative close racing challenge, no post-Finalize tx | true | 0 | Finalize at `(E, F)` | `(E+1, 0)` wrap, amt=0 | Harmless zero-rescue | | Cooperative close racing challenge, post-Finalize tx exists | true | 0 | detached tip | `(E+1, M+1)` append, amt=0 | Harmless zero-rescue | ## Test plan - [x] `go build ./...` clean - [x] `go vet ./...` clean - [x] `go test ./...` all packages pass - [x] `TestHandleHomeChannelClosed_TimeoutAfterFinalize_AppendsRescue` pins the bug-fix path: detached prev + non-zero shortfall → rescue appended at `(prev.Epoch, prev.Version+1)` with `prev.UserBalance + shortfall` - [x] `TestNewChallengeRescueState/Success - detached prev appends...` pins the constructor's append branch - [x] Existing `ChallengeRescue_Squash` / `_NoCredits` / `_NegativeNet_ClampsToZero` updated for new prev source + signature; behavior preserved for the in-channel branch 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ts (#786) ## Summary - Promotes the implicit "events processed in strict (block, log_index) order with idempotent resume" property of `pkg/blockchain/evm/listener.go` into a named, citeable invariant. - Adds cross-references at every consumer that depends on it (receiver-credit issuance + close handler), explaining why the MF3-I01 wedge state — a receiver credit persisted with `HomeChannelID` pointing at a Closed channel — is unreachable under the supported event-ingestion path. - Doc-only; no behavior change. Auditor reframed MF3-I01 as Informational defense-in-depth in [the finding thread](https://www.notion.so/guardianaudits/I-01-3688bda5828c81e5b36dc3d6c8a48504). ## Anchors - `pkg/blockchain/evm/listener.go` — canonical "Listener ordering & idempotency invariant" block on `processEvents`, four numbered guarantees plus the Path-1 consequence (`HandleHomeChannelChallenged` precedes `HandleHomeChannelClosed` for any challenge-timeout close). - `nitronode/event_handlers/service.go` — `HandleHomeChannelClosed` doc names itself as the MF3-I01 recovery anchor and cites the invariant. - `nitronode/api/channel_v1/handler.go` — `issueTransferReceiverState` `HomeChannelID!=nil` branch cites the invariant. - `nitronode/api/app_session_v1/handler.go` — `issueReleaseReceiverState` mirror. ## Rationale The exploit chain in the finding requires `ChannelClosed` to be processed without the prior `ChannelChallenged` for the same channel. The listener loop in `processEvents` rules that out: events are delivered in strict chain order, the cursor only advances after a handler returns nil, idempotent resume via `IsContractEventPresent` prevents reprocessing, and reorg-removed logs are dropped (a reorg that removes `ChannelChallenged` also removes the on-chain DISPUTED status, so Path-1 close cannot fire). That invariant was implicit before; downstream comments referenced "challenge-rescue squash at close" without naming what made it sufficient. Future changes that weaken any of the four guarantees now have to update the named consumers. ## Test plan - [x] `go vet ./nitronode/event_handlers/... ./nitronode/api/channel_v1/... ./nitronode/api/app_session_v1/... ./pkg/blockchain/evm/...` — clean. - [x] CI green (no behavior change, but Go test job will run anyway). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
📝 WalkthroughWalkthroughThis PR refactors the challenge-rescue flow and state-query API to support cross-channel user-scoped lookups and simplified net-transition calculations. It adds explicit channel closure tracking to rescue-state construction, introduces per-user state queries, simplifies filtering logic, enforces aggregate overflow validation on ledgers, and documents critical MF3-I01 event-ordering guarantees. ChangesChallenge Rescue & State Query Refactoring
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (1 warning, 1 inconclusive)
✅ Passed checks (3 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 |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
pkg/core/types_test.go (1)
248-277: ⚡ Quick winAssert full ledger inheritance in the detached-path rescue test.
This case is named as “inheriting ledger,” but it currently only checks balances/net-flows. Add assertions for
TokenAddressandBlockchainIDso the intended invariant is pinned.Suggested test tightening
prev := State{ Asset: "USDC", UserWallet: "0xUser", Epoch: 4, Version: 2, HomeChannelID: nil, HomeLedger: Ledger{ + TokenAddress: "0xToken", + BlockchainID: 1, UserBalance: decimal.NewFromInt(10), UserNetFlow: decimal.Zero, NodeBalance: decimal.Zero, NodeNetFlow: decimal.NewFromInt(10), }, } @@ assert.Equal(t, prev.Epoch, rescue.Epoch) assert.Equal(t, prev.Version+1, rescue.Version) assert.Nil(t, rescue.HomeChannelID) + assert.Equal(t, prev.HomeLedger.TokenAddress, rescue.HomeLedger.TokenAddress) + assert.Equal(t, prev.HomeLedger.BlockchainID, rescue.HomeLedger.BlockchainID) assert.True(t, decimal.NewFromInt(52).Equal(rescue.HomeLedger.UserBalance), "got %s", rescue.HomeLedger.UserBalance.String()) assert.True(t, decimal.NewFromInt(52).Equal(rescue.HomeLedger.NodeNetFlow), "got %s", rescue.HomeLedger.NodeNetFlow.String())🤖 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/core/types_test.go` around lines 248 - 277, The test "Success - detached prev appends at next version inheriting ledger" currently only asserts balances/net-flows but claims full ledger inheritance; update the assertions after NewChallengeRescueState to also assert that prev.HomeLedger.TokenAddress and prev.HomeLedger.BlockchainID are preserved on rescue.HomeLedger (e.g., require.Equal(t, prev.HomeLedger.TokenAddress, rescue.HomeLedger.TokenAddress) and require.Equal(t, prev.HomeLedger.BlockchainID, rescue.HomeLedger.BlockchainID)); locate these fields on the State.HomeLedger and the NewChallengeRescueState result to add the two checks.
🤖 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 `@pkg/core/types_test.go`:
- Around line 248-277: The test "Success - detached prev appends at next version
inheriting ledger" currently only asserts balances/net-flows but claims full
ledger inheritance; update the assertions after NewChallengeRescueState to also
assert that prev.HomeLedger.TokenAddress and prev.HomeLedger.BlockchainID are
preserved on rescue.HomeLedger (e.g., require.Equal(t,
prev.HomeLedger.TokenAddress, rescue.HomeLedger.TokenAddress) and
require.Equal(t, prev.HomeLedger.BlockchainID, rescue.HomeLedger.BlockchainID));
locate these fields on the State.HomeLedger and the NewChallengeRescueState
result to add the two checks.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 1ec74c55-61c9-491d-b0f1-acc0da649e37
⛔ Files ignored due to path filters (1)
sdk/mcp/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (16)
nitronode/api/app_session_v1/handler.gonitronode/api/channel_v1/handler.gonitronode/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/blockchain/evm/listener.gopkg/core/interface.gopkg/core/state_packer.gopkg/core/state_packer_test.gopkg/core/types.gopkg/core/types_test.go
Summary
Three audit-finding fixes from the MF3 batch, intended to squash-merge.
Commits
MF3-L01 (
1827cfbf) —pkg/core: validate aggregate net-flow / allocation ranges. Individually-valid scaled balances can still overflow the contract'sint256net-flow sum oruint256allocation sum, producing states the contract will always reject. Adds aggregate bounds checks in bothLedger.Validate(domain) andcontractLedger.Validate(ABI/packing) as defense in depth.MF3-H01 (
064512de) —nitronode/event_handlers: issuechallenge_rescueon Challenged → Closed unconditionally. Drops theHasSignedFinalizeskip that under-credited the user when a Path-1 timeout close landed at versionY < Fdespite a node-signed Finalize existing locally. Sources rescueprevviaGetLastUserStateacross both channel-attached and detached entries;NewChallengeRescueStatenow branches onprev.HomeChannelID(detached → append at(prev.Epoch, prev.Version+1); in-channel → wrap to(prev.Epoch+1, 0)), eliminating the state-ID collision the old skip worked around. Also drops the redundantepochfilter fromSumNetTransitionAmountAfterVersion.MF3-I01 (
c6f945af) — docs only: promotes the implicit "events processed in strict(block, log_index)order with idempotent resume" property ofpkg/blockchain/evm/listener.gointo a named, citeable invariant. Cross-references it at every consumer that relies on it (receiver-credit issuance inchannel_v1/app_session_v1, close handler inevent_handlers), so future changes that weaken any of the four guarantees have to update the named consumers. No behavior change — auditor reframed I01 as Informational defense-in-depth.Test plan
go build ./...cleango vet ./...cleango test ./...all packages pass🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
Bug Fixes
Documentation