diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index 88b852802..d709e14ac 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -377,6 +377,32 @@ Fee-on-transfer tokens are **not supported**. The amount received by the contrac --- +## Native ETH vs ERC20 Deposit Asymmetry + +When pulling funds from a user, ERC20 and native ETH behave differently: + +- **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**: 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 that pulls funds from the user: + +| Function | Context | +|----------|---------| +| `createChannel` | Initial deposit on channel creation (`DEPOSIT` intent) | +| `depositToChannel` | Channel deposit | +| `initiateEscrowDeposit` | Escrow deposit initiation (non-home chain) | + +### 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/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); 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)