Skip to content

M2-H01: fix(contracts/ChannelHub): add finalize escrow home chain intent check#704

Merged
nksazonov merged 2 commits into
fix/audit-findings-r2from
fix/m2-h01
Apr 27, 2026
Merged

M2-H01: fix(contracts/ChannelHub): add finalize escrow home chain intent check#704
nksazonov merged 2 commits into
fix/audit-findings-r2from
fix/m2-h01

Conversation

@nksazonov
Copy link
Copy Markdown
Contributor

@nksazonov nksazonov commented Apr 24, 2026

Description

finalizeEscrowDeposit and finalizeEscrowWithdrawal route home-chain calls before checking the candidate state intent. When escrow metadata is absent and the channel is home on the current chain, the functions call _processHomeChainEscrowFinalize, which validates signatures and forwards the candidate to ChannelEngine.validateTransition.

        if (_isEscrowDepositHomeChain(channelId, escrowId)) {
            // HOME CHAIN: Get user from channel definition
            ChannelMeta storage channelMeta = _channels[channelId];
            _processHomeChainEscrowFinalize(channelId, candidate, channelMeta.definition.user);
            emit EscrowDepositFinalizedOnHome(escrowId, channelId, candidate);
            return;
        }

The intent check is only reached on the non-home path. The home path therefore accepts any valid next signed channel transition supported by ChannelEngine. This breaks the intended escrow deposit flow. The user receives a co-signed INITIATE_ESCROW_DEPOSIT state so they can submit it on the non-home chain and lock their funds in escrow. The home-chain submission of that same state is intentionally restricted to the node.

        if (_isChannelHomeChain(channelId)) {
            require(msg.sender == NODE, IncorrectMsgSender());
            _processHomeChainEscrowInitiate(channelId, candidate);
            emit EscrowDepositInitiatedOnHome(escrowId, channelId, candidate);
        }

However, the user can submit that signed initiation state through finalizeEscrowDeposit on the home chain. Since home-chain escrow metadata is empty,  _isEscrowDepositHomeChain returns true and the candidate is processed as a generic channel transition. As a result, the home channel advances to the INITIATE_ESCROW_DEPOSIT state and the node’s matching liquidity is moved from the node vault into the channel’s locked funds, even if the non-home escrow was never created and the user never locked the corresponding funds.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced validation of state intent across escrow initiation and finalization operations, ensuring correct operation types are supplied.
    • Strengthened signature validation for home-chain escrow finalizations using channel definition validators.
  • Tests

    • Added comprehensive unit test coverage validating incorrect state intent rejection across all core ChannelHub operations.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 2026

📝 Walkthrough

Walkthrough

Home-chain escrow deposit and withdrawal processing consolidates into a single _processHomeChainEscrow(...) function. Finalization paths now validate expected intent and signatures at entry points rather than in separate helpers. Test coverage expands to validate intent checks across multiple ChannelHub functions.

Changes

Cohort / File(s) Summary
Core Escrow Processing
contracts/src/ChannelHub.sol
Consolidated _processHomeChainEscrowInitiate(...) into _processHomeChainEscrow(...) and removed _processHomeChainEscrowFinalize(...). Moved signature validation from finalize helper into finalization entry points (finalizeEscrowDeposit, finalizeEscrowWithdrawal). Relocated escrow metadata loading to non-home branch after preload.
Intent Validation Tests
contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol, ...ChannelHub_checkpointChannel.t.sol, ...ChannelHub_closeChannel.t.sol, ...ChannelHub_createChannel.t.sol, ...ChannelHub_depositToChannel.t.sol, ...ChannelHub_finalizeEscrowDeposit.t.sol, ...ChannelHub_finalizeEscrowWithdrawal.t.sol, ...ChannelHub_finalizeMigration.t.sol, ...ChannelHub_initiateEscrowDeposit.t.sol, ...ChannelHub_initiateEscrowWithdrawal.t.sol, ...ChannelHub_initiateMigration.t.sol, ...ChannelHub_withdrawFromChannel.t.sol
Added unit tests across multiple ChannelHub functions verifying that calls revert with IncorrectStateIntent when invoked with wrong intent values. Tests follow consistent pattern: construct state with incorrect intent and assert revert selector. New contract fixtures added for escrow finalization tests.
Test Utilities & Lint
contracts/test/ChannelEngine/ChannelEngine_validateTransition.t.sol, contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol, contracts/test/ChannelHub_emitsNodeBalanceUpdated.t.sol, contracts/test/TestChannelHub.sol, contracts/test/WadMath.t.sol
Added forge-lint directives disabling unsafe-typecast and mixed-case-function warnings. Removed unused ChannelHub import. Replaced hardcoded EMPTY_LEDGER with TestUtils.emptyLedger() utility call.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • ihsraham
  • philanton
  • dimast-x

Poem

🐰 Through refactored paths, escrows now find their way,
One function consolidates what once was split in day,
Intent validation guards each finalization call,
Tests verify the safeguards covering all!
Hop along, dear chain, your deposits are secure,
With signatures and intents—of this we're sure!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: adding intent validation for finalize escrow on the home chain, which directly addresses the M2-H01 audit finding.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/m2-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.

@nksazonov
Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 24, 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.

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 (5)
contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol (1)

162-168: Test is fine as a single-case regression.

Exercises the require(intent == DEPOSIT || WITHDRAW || OPERATE, IncorrectStateIntent()) guard at the top of createChannel. If you want stronger coverage of the negative space (e.g., CLOSE, INITIATE_ESCROW_DEPOSIT, FINALIZE_ESCROW_WITHDRAWAL, INITIATE_MIGRATION, FINALIZE_MIGRATION), a tiny table-driven helper or per-intent tests would be cheap to add — but not required for this PR.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol` around lines
162 - 168, Test test_revert_ifDisallowedIntent correctly asserts createChannel
reverts for StateIntent.CLOSE, but to improve negative-space coverage add
additional cases: create a table-driven test or separate tests that iterate
through other disallowed StateIntent values (INITIATE_ESCROW_DEPOSIT,
FINALIZE_ESCROW_WITHDRAWAL, INITIATE_MIGRATION, FINALIZE_MIGRATION) and assert
vm.expectRevert(ChannelHub.IncorrectStateIntent.selector) when calling
ChannelHub.createChannel with each; keep the existing test as-is if you prefer
single-case regression.
contracts/src/ChannelHub.sol (1)

736-747: Fix correctly restores the missing intent + signature validation on the home-chain finalize path.

The added candidate.intent == StateIntent.FINALIZE_ESCROW_DEPOSIT check and explicit _validateSignatures call at the entry point close the described hole: previously a co-signed INITIATE_ESCROW_DEPOSIT could be submitted through finalizeEscrowDeposit on the home chain and applied to the channel, advancing state and pulling node liquidity into locked funds. With the intent constraint the candidate must be a FINALIZE_ESCROW_DEPOSIT transition (which ChannelEngine.validateTransition further validates against prevState), and with sig validation preceding _processHomeChainEscrow the helper no longer needs to re-validate.

Minor optional refactor: _channels[channelId].definition is read as storage here and then re-read as memory inside _processHomeChainEscrow (line 1057). You could pass the already-loaded ChannelDefinition memory (or storage pointer) down to avoid the extra SLOAD of user/node/approvedSignatureValidators/etc. Same applies to the withdrawal symmetric path below.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/src/ChannelHub.sol` around lines 736 - 747, finalizeEscrowDeposit
lacked intent and signature checks on the home-chain path; enforce
candidate.intent == StateIntent.FINALIZE_ESCROW_DEPOSIT and call
_validateSignatures(channelId, candidate, metaDef.user,
metaDef.approvedSignatureValidators) before invoking _processHomeChainEscrow to
ensure only properly-intended, co-signed transitions are applied; optionally, to
save an SLOAD, pass the already-read ChannelDefinition (metaDef) into
_processHomeChainEscrow instead of re-reading _channels[channelId].definition.
contracts/test/ChannelHub_units/ChannelHub_initiateEscrowWithdrawal.t.sol (1)

12-19: Covers the first-line intent guard — LGTM.

The intent check is the very first require in initiateEscrowWithdrawal, so this test hits it independent of definition/signature validity. Same suggestion as on createChannel: consider additional wrong-intent cases (e.g., INITIATE_ESCROW_DEPOSIT, FINALIZE_*, OPERATE) if you want full negative-space coverage, but not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/test/ChannelHub_units/ChannelHub_initiateEscrowWithdrawal.t.sol`
around lines 12 - 19, Test only covers one wrong intent; add additional
negative-case tests that set State.intent to other invalid values (e.g.,
StateIntent.INITIATE_ESCROW_DEPOSIT, StateIntent.FINALIZE_* variants,
StateIntent.OPERATE) and assert
vm.expectRevert(ChannelHub.IncorrectStateIntent.selector) before calling
cHub.initiateEscrowWithdrawal(def, state). Add separate test functions (e.g.,
test_revert_ifWrongIntent_INITIATE_ESCROW_DEPOSIT,
test_revert_ifWrongIntent_FINALIZE, test_revert_ifWrongIntent_OPERATE) in
ChannelHub_initiateEscrowWithdrawal.t.sol to exercise those intent guards and
keep the existing test as-is.
contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol (2)

59-73: Consider a regression test that uses the actual exploit intent (INITIATE_ESCROW_DEPOSIT).

Per the PR description, the specific bug being fixed is that a co-signed INITIATE_ESCROW_DEPOSIT state could be submitted via finalizeEscrowDeposit on the home chain, reaching _processHomeChainEscrowFinalize and moving node funds. The two tests here only use StateIntent.DEPOSIT, which would likely have reverted even before the fix (e.g., downstream validation in the engine).

To directly lock in the fix, add a regression test that builds a valid home-chain INITIATE_ESCROW_DEPOSIT state (like escrowState in ChannelHub_initiateEscrowDeposit.t.sol), co-signed, and asserts finalizeEscrowDeposit(channelId, ..., state) reverts with IncorrectStateIntent — proving the home-chain guard rejects the previously-exploitable payload.

Also, since the entry-point intent guard runs before any channel lookup, the existing setUp scaffolding (channel creation, signed init state) is unused by the two new tests. Either drop the scaffolding for these tests or, preferably, keep it and add the regression test above which actually needs it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol`
around lines 59 - 73, Update the tests so one explicitly reproduces the
exploited input: in test_revert_homeChain_ifWrongIntent (or add a new regression
test) build a valid co-signed home-chain State with intent
StateIntent.INITIATE_ESCROW_DEPOSIT (similar to escrowState in
ChannelHub_initiateEscrowDeposit.t.sol), then call
cHub.finalizeEscrowDeposit(channelId, bytes32(0), state) and expect a revert
with ChannelHub.IncorrectStateIntent.selector; keep the existing setUp
scaffolding (channel creation and signed init state) so the test uses realistic
data rather than the current DEPOSIT-only inputs that wouldn’t have exercised
the regression.

67-73: Minor: test_revert_nonHomeChain_ifWrongIntent is functionally equivalent to the home-chain test.

Since the intent check is an entry-point require that runs before _isChannelHomeChain is consulted, passing bytes32(0) vs channelId makes no difference — both hit the same require statement. The "nonHomeChain" vs "homeChain" naming suggests differentiated code paths that aren't actually exercised. Consider either:

  • merging into a single test_revert_ifWrongIntent, or
  • replacing this test with one that exercises the non-home-chain path with a valid intent but some other failure mode (e.g., missing escrow record), so the "nonHomeChain" label is meaningful.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol`
around lines 67 - 73, The test test_revert_nonHomeChain_ifWrongIntent is
redundant because finalizeEscrowDeposit checks intent before checking
home-chain, so passing bytes32(0) vs channelId yields the same require hit;
either merge this with test_revert_ifWrongIntent (remove the duplicate and keep
one test invoking finalizeEscrowDeposit with StateIntent.DEPOSIT and expecting
ChannelHub.IncorrectStateIntent) or replace this test to actually exercise the
non-home-chain path: call finalizeEscrowDeposit with a valid intent (not
StateIntent.DEPOSIT), a non-home channelId (use channelId), and set up ledger
state so the intent passes but the non-home-chain branch runs and then assert
the expected failure (e.g., missing escrow record) using
cHub.finalizeEscrowDeposit and vm.expectRevert for the specific error you
expect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@contracts/src/ChannelHub.sol`:
- Around line 736-747: finalizeEscrowDeposit lacked intent and signature checks
on the home-chain path; enforce candidate.intent ==
StateIntent.FINALIZE_ESCROW_DEPOSIT and call _validateSignatures(channelId,
candidate, metaDef.user, metaDef.approvedSignatureValidators) before invoking
_processHomeChainEscrow to ensure only properly-intended, co-signed transitions
are applied; optionally, to save an SLOAD, pass the already-read
ChannelDefinition (metaDef) into _processHomeChainEscrow instead of re-reading
_channels[channelId].definition.

In `@contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol`:
- Around line 162-168: Test test_revert_ifDisallowedIntent correctly asserts
createChannel reverts for StateIntent.CLOSE, but to improve negative-space
coverage add additional cases: create a table-driven test or separate tests that
iterate through other disallowed StateIntent values (INITIATE_ESCROW_DEPOSIT,
FINALIZE_ESCROW_WITHDRAWAL, INITIATE_MIGRATION, FINALIZE_MIGRATION) and assert
vm.expectRevert(ChannelHub.IncorrectStateIntent.selector) when calling
ChannelHub.createChannel with each; keep the existing test as-is if you prefer
single-case regression.

In `@contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol`:
- Around line 59-73: Update the tests so one explicitly reproduces the exploited
input: in test_revert_homeChain_ifWrongIntent (or add a new regression test)
build a valid co-signed home-chain State with intent
StateIntent.INITIATE_ESCROW_DEPOSIT (similar to escrowState in
ChannelHub_initiateEscrowDeposit.t.sol), then call
cHub.finalizeEscrowDeposit(channelId, bytes32(0), state) and expect a revert
with ChannelHub.IncorrectStateIntent.selector; keep the existing setUp
scaffolding (channel creation and signed init state) so the test uses realistic
data rather than the current DEPOSIT-only inputs that wouldn’t have exercised
the regression.
- Around line 67-73: The test test_revert_nonHomeChain_ifWrongIntent is
redundant because finalizeEscrowDeposit checks intent before checking
home-chain, so passing bytes32(0) vs channelId yields the same require hit;
either merge this with test_revert_ifWrongIntent (remove the duplicate and keep
one test invoking finalizeEscrowDeposit with StateIntent.DEPOSIT and expecting
ChannelHub.IncorrectStateIntent) or replace this test to actually exercise the
non-home-chain path: call finalizeEscrowDeposit with a valid intent (not
StateIntent.DEPOSIT), a non-home channelId (use channelId), and set up ledger
state so the intent passes but the non-home-chain branch runs and then assert
the expected failure (e.g., missing escrow record) using
cHub.finalizeEscrowDeposit and vm.expectRevert for the specific error you
expect.

In `@contracts/test/ChannelHub_units/ChannelHub_initiateEscrowWithdrawal.t.sol`:
- Around line 12-19: Test only covers one wrong intent; add additional
negative-case tests that set State.intent to other invalid values (e.g.,
StateIntent.INITIATE_ESCROW_DEPOSIT, StateIntent.FINALIZE_* variants,
StateIntent.OPERATE) and assert
vm.expectRevert(ChannelHub.IncorrectStateIntent.selector) before calling
cHub.initiateEscrowWithdrawal(def, state). Add separate test functions (e.g.,
test_revert_ifWrongIntent_INITIATE_ESCROW_DEPOSIT,
test_revert_ifWrongIntent_FINALIZE, test_revert_ifWrongIntent_OPERATE) in
ChannelHub_initiateEscrowWithdrawal.t.sol to exercise those intent guards and
keep the existing test as-is.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 224538f2-ea76-49fa-afee-abec9d2f2117

📥 Commits

Reviewing files that changed from the base of the PR and between d715b5f and 54bd94d.

📒 Files selected for processing (18)
  • contracts/src/ChannelHub.sol
  • contracts/test/ChannelEngine/ChannelEngine_validateTransition.t.sol
  • contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol
  • contracts/test/ChannelHub_emitsNodeBalanceUpdated.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_checkpointChannel.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_closeChannel.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_depositToChannel.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_finalizeMigration.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_initiateEscrowDeposit.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_initiateEscrowWithdrawal.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_initiateMigration.t.sol
  • contracts/test/ChannelHub_units/ChannelHub_withdrawFromChannel.t.sol
  • contracts/test/TestChannelHub.sol
  • contracts/test/WadMath.t.sol
💤 Files with no reviewable changes (1)
  • contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol

@nksazonov nksazonov merged commit 6a6c125 into fix/audit-findings-r2 Apr 27, 2026
3 checks passed
@nksazonov nksazonov deleted the fix/m2-h01 branch April 27, 2026 07:30
nksazonov added a commit that referenced this pull request Apr 28, 2026
M2-I02: fix(clearnode/api): normalize participant addresses consistently across endpoints (#712)
M2-L01: docs(protocol): clarify session-key authorization scope and metadata extensibility (#711)
M2-M01: fix(clearnode): alert on home channel challenge instead of auto-checkpoint (#710)
M2-H01: fix(contracts/ChannelHub): add finalize escrow home chain intent check (#704)
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