From d29d099feb6af8bbbbfb25add3617a0fd706bcfd Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 14 Apr 2026 17:17:41 +0300 Subject: [PATCH 1/2] M-L05: docs(contracts): document native token deposits --- contracts/SECURITY.md | 28 ++++++++++++++++++++++++++++ protocol-description.md | 2 ++ 2 files changed, 30 insertions(+) diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index 88b852802..d02224454 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -377,6 +377,34 @@ Fee-on-transfer tokens are **not supported**. The amount received by the contrac --- +## Native ETH vs ERC20 Deposit Asymmetry + +`_pullFunds(from, token, amount)` enforces different funding mechanics depending on the asset type: + +- **ERC20 (`token != address(0)`)**: Calls `IERC20.safeTransferFrom(from, address(this), amount)`. Funds are pulled from the `from` address using a prior ERC20 allowance. Any caller can submit a signed state that triggers a deposit, and the funds come from the user's approval — the caller does not need to supply capital. + +- **Native ETH (`token == address(0)`)**: Requires `msg.value == amount`. The **caller** must attach the exact ETH amount, regardless of who the logical `from` address is. The `from` parameter is effectively ignored for the funding step. + +### Affected operations + +This asymmetry applies to every operation where `_pullFunds` is called with `from = user`: + +| Call site | Context | +|-----------|---------| +| `_applyEffects` | Channel deposits (`DEPOSIT` intent) | +| `_applyEscrowDepositEffects` | Escrow deposit initiation (user funds on non-home chain) | +| `_applyEscrowWithdrawalEffects` | Escrow withdrawal finalization (user funds on non-home chain) | + +`depositToNode()` is not affected — it always uses `from = msg.sender`. + +### Practical consequence + +For ERC20 channels, any party holding a valid signed state that requires a user deposit can submit it on-chain, and the user's pre-approved funds are pulled automatically. For native ETH channels, only a caller willing to supply the required `msg.value` can submit such a state. In practice, this means native ETH deposit states must be submitted by the user themselves (or by a party willing to front the ETH on their behalf). + +Integrators building relayers or third-party submission flows should account for this difference: ERC20 state submissions are permissionless given prior user approval, while native ETH state submissions that require user funds are not. + +--- + ## ERC20 Transfer Failure Attack Vectors ### Background diff --git a/protocol-description.md b/protocol-description.md index 857d29ab9..6c63f4425 100644 --- a/protocol-description.md +++ b/protocol-description.md @@ -211,6 +211,8 @@ Off-chain activity can continue indefinitely between enforcements. * funds are pulled from User, * locked into the channel. +**Native ETH vs ERC20 deposit mechanics:** For ERC20 tokens, the contract pulls funds from the user's address via `safeTransferFrom` using a prior allowance — any party can submit the signed state and the user's approved funds are transferred. For native ETH (`token = address(0)`), the **caller** must attach the required amount as `msg.value`. This means native ETH deposit states must be submitted by the user (or by a party willing to supply the ETH on their behalf). This asymmetry also applies to escrow deposit initiation and escrow withdrawal finalization on the non-home chain. + --- ### 4. Withdrawal (single-chain) From 30526ac9bc6a798560b3402e4a634f5b929a6383 Mon Sep 17 00:00:00 2001 From: nksazonov Date: Wed, 15 Apr 2026 08:16:32 +0200 Subject: [PATCH 2/2] docs(contracts): clarify ETH deposit comments --- contracts/SECURITY.md | 20 +++++++++----------- contracts/src/ChannelHub.sol | 9 +++++++++ 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index d02224454..d709e14ac 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -379,23 +379,21 @@ Fee-on-transfer tokens are **not supported**. The amount received by the contrac ## Native ETH vs ERC20 Deposit Asymmetry -`_pullFunds(from, token, amount)` enforces different funding mechanics depending on the asset type: +When pulling funds from a user, ERC20 and native ETH behave differently: -- **ERC20 (`token != address(0)`)**: Calls `IERC20.safeTransferFrom(from, address(this), amount)`. Funds are pulled from the `from` address using a prior ERC20 allowance. Any caller can submit a signed state that triggers a deposit, and the funds come from the user's approval — the caller does not need to supply capital. +- **ERC20**: Funds are pulled via `transferFrom` using a prior user allowance. Any caller can submit a signed state that triggers a deposit — the funds come from the user's approval. -- **Native ETH (`token == address(0)`)**: Requires `msg.value == amount`. The **caller** must attach the exact ETH amount, regardless of who the logical `from` address is. The `from` parameter is effectively ignored for the funding step. +- **Native ETH**: The caller must attach the exact `msg.value`. Whoever submits the transaction must supply the ETH, regardless of who the logical depositor is. ### Affected operations -This asymmetry applies to every operation where `_pullFunds` is called with `from = user`: +This asymmetry applies to every operation that pulls funds from the user: -| Call site | Context | -|-----------|---------| -| `_applyEffects` | Channel deposits (`DEPOSIT` intent) | -| `_applyEscrowDepositEffects` | Escrow deposit initiation (user funds on non-home chain) | -| `_applyEscrowWithdrawalEffects` | Escrow withdrawal finalization (user funds on non-home chain) | - -`depositToNode()` is not affected — it always uses `from = msg.sender`. +| Function | Context | +|----------|---------| +| `createChannel` | Initial deposit on channel creation (`DEPOSIT` intent) | +| `depositToChannel` | Channel deposit | +| `initiateEscrowDeposit` | Escrow deposit initiation (non-home chain) | ### Practical consequence diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index 3b8ac5476..6446d7175 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -44,6 +44,12 @@ import {EcdsaSignatureUtils} from "./sigValidators/EcdsaSignatureUtils.sol"; * There is no hard-coded guardrail preventing deposit of these tokens — the contract will accept * them, but any discrepancy will produce undefined accounting behavior for all users of that token. * Enforcement is off-chain: the Node will not sign states that reference unsupported token types. + * + * NATIVE ETH vs ERC20 DEPOSIT ASYMMETRY: + * + * When user funds are pulled, ERC20 uses `transferFrom` (caller-agnostic, requires prior approval), + * while native ETH requires `msg.value == amount` (the transaction submitter must supply the ETH). + * For native ETH channels, deposit states must therefore be submitted by the user or a willing proxy. */ contract ChannelHub is ReentrancyGuard { using EnumerableSet for EnumerableSet.Bytes32Set; @@ -500,6 +506,7 @@ contract ChannelHub is ReentrancyGuard { // Create channel with DEPOSIT, WITHDRAW, or OPERATE intent // This enables users who already have off-chain virtual states with non-zero version // to create a channel and perform initial operation simultaneously + // NOTE: For native ETH channels with DEPOSIT intent, msg.sender must supply msg.value == deposit amount. function createChannel(ChannelDefinition calldata def, State calldata initState) external payable { require( initState.intent == StateIntent.DEPOSIT || initState.intent == StateIntent.WITHDRAW @@ -537,6 +544,7 @@ contract ChannelHub is ReentrancyGuard { emit ChannelCreated(channelId, user, def, initState); } + // NOTE: For native ETH channels, msg.sender must supply msg.value == deposit amount. function depositToChannel(bytes32 channelId, State calldata candidate) public payable { require(candidate.intent == StateIntent.DEPOSIT, IncorrectStateIntent()); @@ -674,6 +682,7 @@ contract ChannelHub is ReentrancyGuard { // ========= Cross-Chain Functions ========== + // NOTE: On non-home chain, user funds are pulled. For native ETH, msg.sender must supply msg.value == deposit amount. function initiateEscrowDeposit(ChannelDefinition calldata def, State calldata candidate) external payable { require(candidate.intent == StateIntent.INITIATE_ESCROW_DEPOSIT, IncorrectStateIntent()); _requireValidDefinition(def);