Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions contracts/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ e.g. when processing "receive X, withdraw Y", increase `lockedFunds` (and "lock"
17. **Latest-state challenge rule**: A challenge must reference a state with `version ≥ lastEnforcedVersion`; if higher, that state is enforced first.
18. **Challenge resolution**: Any strictly newer valid state supersedes an active challenge and returns the channel to `OPERATING`.
19. **Challenge finality**: If no newer state is enforced before challenge expiry, the channel may be unilaterally closed using the last enforced state.
20. **INITIATE_ESCROW_DEPOSIT home-chain caller restriction**: `INITIATE_ESCROW_DEPOSIT` on the home chain may only be submitted by the Node, via either `initiateEscrowDeposit` or `challengeChannel`. This invariant holds despite the general principle that any party may enforce any valid signed state: the home-chain path of `INITIATE_ESCROW_DEPOSIT` exclusively adjusts Node allocations (`userNfDelta == 0`, `userFundsDelta == 0`), so it removes no user right. The restriction closes a DDoS vector where an attacker with zero capital could lock arbitrary Node liquidity by submitting signed initiation states directly on the home chain without first locking funds on the non-home chain. A user who needs to dispute a channel whose latest on-chain state is `INITIATE_ESCROW_DEPOSIT` can challenge with the immediate predecessor: because the user's allocation is unchanged across that transition, the fund distribution is identical.

---

Expand Down
7 changes: 7 additions & 0 deletions contracts/src/ChannelHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ contract ChannelHub is ReentrancyGuard {
error NoChannelIdFoundForEscrow();
error IncorrectChannelId();
error IncorrectNode();
error IncorrectMsgSender();

struct ChannelMeta {
ChannelStatus status;
Expand Down Expand Up @@ -608,6 +609,11 @@ contract ChannelHub is ReentrancyGuard {
!(status == ChannelStatus.OPERATING && candidate.intent == StateIntent.FINALIZE_MIGRATION),
IncorrectStateIntent()
);
// INITIATE_ESCROW_DEPOSIT on the home chain may only be submitted by the node
require(
!(candidate.intent == StateIntent.INITIATE_ESCROW_DEPOSIT && msg.sender != NODE),
IncorrectMsgSender()
);

_validateSignatures(channelId, candidate, user, approvedSignatureValidators);

Expand Down Expand Up @@ -681,6 +687,7 @@ contract ChannelHub is ReentrancyGuard {
bytes32 escrowId = Utils.getEscrowId(channelId, candidate.version);

if (_isChannelHomeChain(channelId)) {
require(msg.sender == NODE, IncorrectMsgSender());
_processHomeChainEscrowInitiate(channelId, candidate);
emit EscrowDepositInitiatedOnHome(escrowId, channelId, candidate);
} else {
Expand Down
2 changes: 1 addition & 1 deletion contracts/test/ChannelHub_allFlowsInvokePurge.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ contract ChannelHubTest_allFlowsInvokePurge is ChannelHubTest_Base {
);
initState = mutualSignStateBothWithEcdsaValidator(initState, channelId, ALICE_PK);
escrowId = Utils.getEscrowId(channelId, initState.version);
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initState);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -415,7 +415,7 @@ contract ChannelHubTest_Challenge_HomeChain_EscrowDeposit is ChannelHubTest_Chal
}

function test_challenge_initiateEscrowDeposit_asExisting() public {
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initiateEscrowDepositState);

// Challenge with already enforced initiateEscrowDepositState state
Expand Down Expand Up @@ -445,7 +445,7 @@ contract ChannelHubTest_Challenge_HomeChain_EscrowDeposit is ChannelHubTest_Chal
cHub.challengeChannel(channelId, initState, challengerSig, ParticipantIndex.NODE);

// Resolve challenge with newer initiateEscrowDepositState state (before timeout)
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initiateEscrowDepositState);

// Verify challenge was resolved
Expand All @@ -462,7 +462,7 @@ contract ChannelHubTest_Challenge_HomeChain_EscrowDeposit is ChannelHubTest_Chal

function test_challenge_finalizeEscrowDeposit_asNew() public {
// First enforce INITIATE_ESCROW_DEPOSIT on-chain (required for FINALIZE to be valid)
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initiateEscrowDepositState);

// Now challenge with FINALIZE_ESCROW_DEPOSIT
Expand Down Expand Up @@ -490,7 +490,7 @@ contract ChannelHubTest_Challenge_HomeChain_EscrowDeposit is ChannelHubTest_Chal

function test_challenge_finalizeEscrowDeposit_asExisting() public {
// First enforce INITIATE_ESCROW_DEPOSIT on-chain
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initiateEscrowDepositState);

// Then enforce FINALIZE_ESCROW_DEPOSIT on-chain
Expand Down Expand Up @@ -519,7 +519,7 @@ contract ChannelHubTest_Challenge_HomeChain_EscrowDeposit is ChannelHubTest_Chal

function test_challenge_finalizeEscrowDeposit_resolve() public {
// First enforce INITIATE_ESCROW_DEPOSIT on-chain
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initiateEscrowDepositState);

// Challenge with older initiate state
Expand Down Expand Up @@ -571,7 +571,7 @@ contract ChannelHubTest_Challenge_HomeChain_EscrowDeposit is ChannelHubTest_Chal

function test_revert_onChallengeEscrowDeposit() public {
// First enforce INITIATE_ESCROW_DEPOSIT on-chain
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, initiateEscrowDepositState);

// Challenge with INITIATE_ESCROW_DEPOSIT state
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ contract ChannelHubTest_CrossChain_Lifecycle is ChannelHubTest_Base {

// initiate escrow deposit on home chain
// Expected: user allocation = 958, user net flow = 1000, node allocation = 0, node net flow = -42
vm.prank(alice);
vm.prank(node);
cHub.initiateEscrowDeposit(def, state);
verifyChannelState(
channelId, [uint256(958), uint256(500)], [int256(1000), int256(458)], "after cross chain deposit"
Expand Down
100 changes: 100 additions & 0 deletions contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {ChannelHubTest_Base} from "../ChannelHub_Base.t.sol";
import {TestUtils} from "../TestUtils.sol";
import {Utils} from "../../src/Utils.sol";
import {ChannelHub} from "../../src/ChannelHub.sol";
import {State, ChannelDefinition, StateIntent, Ledger, ChannelStatus, ParticipantIndex} from "../../src/interfaces/Types.sol";

contract ChannelHubTest_challenge is ChannelHubTest_Base {
ChannelDefinition internal def;
bytes32 internal channelId;
State internal initState;
State internal escrowState;

uint256 constant ESCROW_AMOUNT = 500;
uint64 constant NON_HOME_CHAIN_ID = 42;
address constant NON_HOME_TOKEN = address(42);

function setUp() public override {
super.setUp();

def = ChannelDefinition({
challengeDuration: CHALLENGE_DURATION,
user: alice,
node: node,
nonce: NONCE,
approvedSignatureValidators: 0,
metadata: bytes32(0)
});
channelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION);

initState = State({
version: 0,
intent: StateIntent.DEPOSIT,
metadata: bytes32(0),
homeLedger: Ledger({
chainId: uint64(block.chainid),
token: address(token),
decimals: 18,
userAllocation: DEPOSIT_AMOUNT,
userNetFlow: int256(DEPOSIT_AMOUNT),
nodeAllocation: 0,
nodeNetFlow: 0
}),
nonHomeLedger: TestUtils.emptyLedger(),
userSig: "",
nodeSig: ""
});
initState = mutualSignStateBothWithEcdsaValidator(initState, channelId, ALICE_PK);

vm.prank(alice);
cHub.createChannel(def, initState);

// Build INITIATE_ESCROW_DEPOSIT: node locks ESCROW_AMOUNT on home chain,
// user will lock ESCROW_AMOUNT on non-home chain.
escrowState = TestUtils.nextState(
initState,
StateIntent.INITIATE_ESCROW_DEPOSIT,
[uint256(DEPOSIT_AMOUNT), uint256(ESCROW_AMOUNT)],
[int256(DEPOSIT_AMOUNT), int256(ESCROW_AMOUNT)],
NON_HOME_CHAIN_ID,
NON_HOME_TOKEN,
[uint256(ESCROW_AMOUNT), uint256(0)],
[int256(ESCROW_AMOUNT), int256(0)]
);
escrowState = mutualSignStateBothWithEcdsaValidator(escrowState, channelId, ALICE_PK);
}

function test_revert_initiateEscrowDeposit_homeChain_callerNotNode() public {
bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(channelId, escrowState, ALICE_PK);

vm.expectRevert(ChannelHub.IncorrectMsgSender.selector);
vm.prank(alice);
cHub.challengeChannel(channelId, escrowState, challengerSig, ParticipantIndex.USER);
}

function test_initiateEscrowDeposit_homeChain_nodeCanChallenge() public {
uint256 nodeBalanceBefore = cHub.getNodeBalance(address(token));

bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(channelId, escrowState, NODE_PK);

vm.prank(node);
cHub.challengeChannel(channelId, escrowState, challengerSig, ParticipantIndex.NODE);

// State is enforced and channel enters DISPUTED
verifyChannelData(
channelId,
ChannelStatus.DISPUTED,
1,
block.timestamp + CHALLENGE_DURATION,
"Channel should be DISPUTED with escrow state enforced"
);
assertEq(
cHub.getNodeBalance(address(token)),
nodeBalanceBefore - ESCROW_AMOUNT,
"Node balance should decrease by escrow amount"
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.30;

import {ChannelHubTest_Base} from "../ChannelHub_Base.t.sol";
import {TestUtils} from "../TestUtils.sol";
import {Utils} from "../../src/Utils.sol";
import {ChannelHub} from "../../src/ChannelHub.sol";
import {State, ChannelDefinition, StateIntent, Ledger, ChannelStatus} from "../../src/interfaces/Types.sol";

contract ChannelHubTest_initiateEscrowDeposit is ChannelHubTest_Base {
ChannelDefinition internal def;
bytes32 internal channelId;
State internal initState;
State internal escrowState;

uint256 constant ESCROW_AMOUNT = 500;
uint64 constant NON_HOME_CHAIN_ID = 42;
address constant NON_HOME_TOKEN = address(42);

function setUp() public override {
super.setUp();

def = ChannelDefinition({
challengeDuration: CHALLENGE_DURATION,
user: alice,
node: node,
nonce: NONCE,
approvedSignatureValidators: 0,
metadata: bytes32(0)
});
channelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION);

initState = State({
version: 0,
intent: StateIntent.DEPOSIT,
metadata: bytes32(0),
homeLedger: Ledger({
chainId: uint64(block.chainid),
token: address(token),
decimals: 18,
userAllocation: DEPOSIT_AMOUNT,
userNetFlow: int256(DEPOSIT_AMOUNT),
nodeAllocation: 0,
nodeNetFlow: 0
}),
nonHomeLedger: TestUtils.emptyLedger(),
userSig: "",
nodeSig: ""
});
initState = mutualSignStateBothWithEcdsaValidator(initState, channelId, ALICE_PK);

vm.prank(alice);
cHub.createChannel(def, initState);

// Build INITIATE_ESCROW_DEPOSIT: node locks ESCROW_AMOUNT on home chain,
// user will lock ESCROW_AMOUNT on non-home chain.
escrowState = TestUtils.nextState(
initState,
StateIntent.INITIATE_ESCROW_DEPOSIT,
[uint256(DEPOSIT_AMOUNT), uint256(ESCROW_AMOUNT)],
[int256(DEPOSIT_AMOUNT), int256(ESCROW_AMOUNT)],
NON_HOME_CHAIN_ID,
NON_HOME_TOKEN,
[uint256(ESCROW_AMOUNT), uint256(0)],
[int256(ESCROW_AMOUNT), int256(0)]
);
escrowState = mutualSignStateBothWithEcdsaValidator(escrowState, channelId, ALICE_PK);
}

function test_revert_homeChain_callerNotNode() public {
vm.expectRevert(ChannelHub.IncorrectMsgSender.selector);
vm.prank(alice);
cHub.initiateEscrowDeposit(def, escrowState);
}

function test_homeChain_nodeCanSubmit() public {
uint256 nodeBalanceBefore = cHub.getNodeBalance(address(token));

vm.prank(node);
cHub.initiateEscrowDeposit(def, escrowState);

// Channel state advanced and node funds locked
verifyChannelData(channelId, ChannelStatus.OPERATING, 1, 0, "State should advance after node submits");
assertEq(
cHub.getNodeBalance(address(token)),
nodeBalanceBefore - ESCROW_AMOUNT,
"Node balance should decrease by escrow amount"
);
}
}
3 changes: 2 additions & 1 deletion protocol-description.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ It does not reconstruct intent — it **verifies and enforces signed states** by

While operating:

* Any **newer signed state** may be enforced on-chain.
* Any **newer signed state** may be enforced on-chain by any party, with one exception: `INITIATE_ESCROW_DEPOSIT` on the home chain may only be submitted by the Node (see [Challenge rules](#challenge-rules)).
* Enforcement may:

* pull funds from User,
Expand Down Expand Up @@ -258,6 +258,7 @@ Challenges protect against:

* `CLOSE` — channel closure is a terminal operation; enforcing it leaves no live channel to dispute. Parties holding a valid CLOSE state should call `closeChannel` directly instead.
* `FINALIZE_MIGRATION` on the **old home chain** (channel status `OPERATING`/`DISPUTED`) — this would release the node's funds and move the channel to `MIGRATED_OUT`, which is incompatible with entering `DISPUTED` state.
* `INITIATE_ESCROW_DEPOSIT` on the **home chain** by a non-Node caller — only the Node may submit this intent on the home chain, whether via `initiateEscrowDeposit` or `challengeChannel`. This prevents a DDoS attack in which an attacker can lock Node liquidity without committing any funds. The user retains full fund-recovery ability: `INITIATE_ESCROW_DEPOSIT` leaves the user's home-chain allocation unchanged, so challenging the home-chain channel with the immediate predecessor state produces an identical fund distribution.

Invariant:

Expand Down