Skip to content
Open
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
15 changes: 15 additions & 0 deletions contracts/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,13 @@ Unlike the `CHALLENGED` channel path (rule 6) — where the release issuer **doe
Invariant:
> A single participant with a pending escrow operation can block cooperative closure of an app session for all other participants until the escrow resolves; no participant receives a release credit while the close is blocked.

---

9. The Node processes on-chain channel-lifecycle events with per-channel version monotonicity. An event whose `StateVersion` is strictly less than the row's current `StateVersion` is dropped with a structured warn log (see `nitronode/event_handlers/service.go`). For home-channel events (`ChannelChallenged`, `ChannelCheckpointed`, `ChannelClosed`), a dropped event additionally triggers an on-chain `getChannelData` read via the `ChainStateRefresher` (`pkg/blockchain/evm/chain_state_refresher.go`, interface in `pkg/core/interface.go`) that overwrites the local row's `Status`, `StateVersion`, and `ChallengeExpiresAt`. This is defense-in-depth against out-of-order event delivery from indexer mis-order, reorg replay, or any future contract change that re-introduces a same-transaction event-order quirk. Escrow event handlers enforce the guard without the refresh hook; cross-chain RPC plumbing for escrow refresh is a deferred follow-up item. Pending its arrival, escrow rows can remain divergent from chain across an interim window until the next on-chain event arrives.

Invariant:
> The Node's local `channels.state_version` is monotonic per `channelId`. After any dropped lifecycle event for a home channel, the Node row converges with on-chain authoritative state without manual intervention.

## Invariants

---
Expand Down Expand Up @@ -208,6 +215,12 @@ no funds can be permanently locked if it does.

---

### Reentrancy

26. **Lifecycle reentrancy guard**: Every external/public function in `ChannelHub` that mutates lifecycle state is guarded by `nonReentrant` modifier. This prevents cross-function reentrancy via inbound token hooks (ERC777-style `tokensReceived`, non-standard `transferFrom` callbacks) from interleaving lifecycle operations during a `_pullFunds` call. The outbound side remains additionally protected by the `TRANSFER_GAS_LIMIT = 100_000` cap, which prevents recipient hooks from completing a reentrant lifecycle call within the forwarded gas budget.

---

### ChannelClosed event orientation during abandoned migration

`initiateMigration()` on the new home chain swaps `homeLedger` ↔ `nonHomeLedger` before storing the state, so that `homeLedger` always represents the chain where execution happens. A consequence of this swap is that `meta.lastState` on the new home chain is stored in opposite orientation from what both parties signed.
Expand Down Expand Up @@ -563,6 +576,8 @@ Inbound transfer failures occur during:

**Mitigation**: The Nitronode only processes operations after observing successful on-chain events. If a user signs a deposit state but the transfer fails on-chain, the state is never enforced, and the Node does not provide services based on unconfirmed deposits.

**Reentrancy via inbound hooks**: Tokens whose `transferFrom` invokes a recipient hook (ERC777-style `tokensReceived`, ERC1363 callbacks, or non-standard ERC20 implementations) could in principle re-enter `ChannelHub` lifecycle entrypoints during an inbound pull. The `nonReentrant` guard on every lifecycle entrypoint blocks this class of attack at the contract layer — see invariant 26 above and `contracts/src/ChannelHub.sol`. Coverage splits by deployment vintage: future deployments built from commit `2a6a9f0d` or later carry the guard and are protected at the contract layer; currently-deployed contracts at the addresses listed in `contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md` predate the guard and are protected only by the off-chain "no hook-bearing tokens" onboarding policy enforced by the Node operator.

---

### Outbound Transfer Failures (ChannelHub → User)
Expand Down
36 changes: 36 additions & 0 deletions contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ChannelHub Deployments — Hook-Token Support

The `ChannelHub` deployments listed in the matrix below **do not support
hook-enabled tokens**. The following token classes MUST NOT be onboarded to
any of these deployments:

- **ERC777** (e.g. `imBTC`, legacy `xDAI`)
- **ERC1363 / ERC677**
- **Non-standard ERC20 with re-entrant `transferFrom`** (some rebasing or
fee-on-transfer tokens with callbacks)

Enforcement is the responsibility of the Node operator. This constraint may
be lifted on future deployments to new chains; each new deployment must be
added to the matrix below with its support status recorded explicitly.

Last reviewed: 2026-06-15.

## Matrix

| Chain ID | Chain | ChannelHub Address | Deploy Commit | Deploy Tag |
| ---: | --- | --- | --- | --- |
| 1 | Ethereum | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 14 | Flare | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 56 | BNB Smart Chain | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 137 | Polygon | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 480 | World Chain | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 8453 | Base | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 59144 | Linea | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 80002 | Polygon Amoy | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 |
| 84532 | Base Sepolia | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 |
| 84532 | Base Sepolia | `0x61b9e0767f2eca7e33802e82f9c64b1ebe72ba31` | `9110ba06` | stress v1.3.0 |
| 59141 | Linea Sepolia | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 |
| 1440000 | XRPL EVM | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 |
| 1449000 | XRPL EVM Testnet | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 |
| 11155111 | Sepolia | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 |
Comment thread
ihsraham marked this conversation as resolved.
| 11155111 | Sepolia | `0x7d61ec428cfae560f43647af567ea7c6e2cc5527` | `104c13df` | stress v1.3.0 |
48 changes: 30 additions & 18 deletions contracts/src/ChannelHub.sol
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ contract ChannelHub is ReentrancyGuard {
// inflate _nodeBalances during ERC777/hook callbacks, enabling read-only reentrancy for
// external protocols querying getNodeBalance(). Contrast with withdrawFromNode, which uses
// CEI (decrement before push) to prevent re-entrancy drains.
function depositToNode(address token, uint256 amount) external payable {
function depositToNode(address token, uint256 amount) external payable nonReentrant {
require(amount > 0, IncorrectAmount());

_pullFunds(msg.sender, token, amount);
Expand All @@ -363,7 +363,7 @@ contract ChannelHub is ReentrancyGuard {
emit NodeBalanceUpdated(token, updatedBalance);
}

function withdrawFromNode(address to, address token, uint256 amount) external {
function withdrawFromNode(address to, address token, uint256 amount) external nonReentrant {
require(to != address(0), InvalidAddress());
require(amount > 0, IncorrectAmount());
require(msg.sender == NODE, IncorrectMsgSender());
Expand Down Expand Up @@ -445,7 +445,7 @@ contract ChannelHub is ReentrancyGuard {
}
}

function purgeEscrowDeposits(uint256 maxSteps) external {
function purgeEscrowDeposits(uint256 maxSteps) external nonReentrant {
_purgeEscrowDeposits(maxSteps);
}

Expand Down Expand Up @@ -549,7 +549,7 @@ contract ChannelHub is ReentrancyGuard {
// 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 {
function createChannel(ChannelDefinition calldata def, State calldata initState) external payable nonReentrant {
require(
initState.intent == StateIntent.DEPOSIT || initState.intent == StateIntent.WITHDRAW
|| initState.intent == StateIntent.OPERATE,
Expand Down Expand Up @@ -587,7 +587,7 @@ contract ChannelHub is ReentrancyGuard {
}

// NOTE: For native ETH channels, msg.sender must supply msg.value == deposit amount.
function depositToChannel(bytes32 channelId, State calldata candidate) public payable {
function depositToChannel(bytes32 channelId, State calldata candidate) public payable nonReentrant {
require(candidate.intent == StateIntent.DEPOSIT, IncorrectStateIntent());

ChannelDefinition memory def = _channels[channelId].definition;
Expand All @@ -602,7 +602,7 @@ contract ChannelHub is ReentrancyGuard {
emit ChannelDeposited(channelId, candidate);
}

function withdrawFromChannel(bytes32 channelId, State calldata candidate) public {
function withdrawFromChannel(bytes32 channelId, State calldata candidate) public nonReentrant {
require(candidate.intent == StateIntent.WITHDRAW, IncorrectStateIntent());

ChannelDefinition memory def = _channels[channelId].definition;
Expand All @@ -617,7 +617,7 @@ contract ChannelHub is ReentrancyGuard {
emit ChannelWithdrawn(channelId, candidate);
}

function checkpointChannel(bytes32 channelId, State calldata candidate) external {
function checkpointChannel(bytes32 channelId, State calldata candidate) external nonReentrant {
require(candidate.intent == StateIntent.OPERATE, IncorrectStateIntent()); // Can only checkpoint operate states

ChannelDefinition memory def = _channels[channelId].definition;
Expand All @@ -637,7 +637,7 @@ contract ChannelHub is ReentrancyGuard {
State calldata candidate,
bytes calldata challengerSig,
ParticipantIndex challengerIdx
) external payable {
) external payable nonReentrant {
ChannelMeta storage meta = _channels[channelId];
ChannelDefinition memory def = meta.definition;
ChannelStatus status = meta.status;
Expand Down Expand Up @@ -686,7 +686,7 @@ contract ChannelHub is ReentrancyGuard {
emit ChannelChallenged(channelId, candidate, challengeExpiry);
}

function closeChannel(bytes32 channelId, State calldata candidate) external {
function closeChannel(bytes32 channelId, State calldata candidate) external nonReentrant {
ChannelMeta storage meta = _channels[channelId];
ChannelDefinition memory def = meta.definition;
ChannelStatus status = meta.status;
Expand Down Expand Up @@ -726,7 +726,11 @@ 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 {
function initiateEscrowDeposit(ChannelDefinition calldata def, State calldata candidate)
external
payable
nonReentrant
{
require(candidate.intent == StateIntent.INITIATE_ESCROW_DEPOSIT, IncorrectStateIntent());
_requireValidDefinition(def);

Expand Down Expand Up @@ -757,6 +761,7 @@ contract ChannelHub is ReentrancyGuard {

function challengeEscrowDeposit(bytes32 escrowId, bytes calldata challengerSig, ParticipantIndex challengerIdx)
external
nonReentrant
{
EscrowDepositMeta storage meta = _escrowDeposits[escrowId];
bytes32 channelId = meta.channelId;
Expand All @@ -777,7 +782,10 @@ contract ChannelHub is ReentrancyGuard {
emit EscrowDepositChallenged(escrowId, meta.initState, effects.newChallengeExpiry);
}

function finalizeEscrowDeposit(bytes32 channelId, bytes32 escrowId, State calldata candidate) external {
function finalizeEscrowDeposit(bytes32 channelId, bytes32 escrowId, State calldata candidate)
external
nonReentrant
{
if (_isEscrowDepositHomeChain(channelId, escrowId)) {
// HOME CHAIN: Get user from channel definition
require(candidate.intent == StateIntent.FINALIZE_ESCROW_DEPOSIT, IncorrectStateIntent());
Expand Down Expand Up @@ -828,7 +836,7 @@ contract ChannelHub is ReentrancyGuard {
emit EscrowDepositFinalized(escrowId, channelId, candidate);
}

function initiateEscrowWithdrawal(ChannelDefinition calldata def, State calldata candidate) external {
function initiateEscrowWithdrawal(ChannelDefinition calldata def, State calldata candidate) external nonReentrant {
require(candidate.intent == StateIntent.INITIATE_ESCROW_WITHDRAWAL, IncorrectStateIntent());
_requireValidDefinition(def);

Expand Down Expand Up @@ -858,6 +866,7 @@ contract ChannelHub is ReentrancyGuard {

function challengeEscrowWithdrawal(bytes32 escrowId, bytes calldata challengerSig, ParticipantIndex challengerIdx)
external
nonReentrant
{
EscrowWithdrawalMeta storage meta = _escrowWithdrawals[escrowId];
bytes32 channelId = meta.channelId;
Expand All @@ -878,7 +887,10 @@ contract ChannelHub is ReentrancyGuard {
emit EscrowWithdrawalChallenged(escrowId, meta.initState, effects.newChallengeExpiry);
}

function finalizeEscrowWithdrawal(bytes32 channelId, bytes32 escrowId, State calldata candidate) external {
function finalizeEscrowWithdrawal(bytes32 channelId, bytes32 escrowId, State calldata candidate)
external
nonReentrant
{
if (_isEscrowWithdrawalHomeChain(channelId, escrowId)) {
// HOME CHAIN: Get user from channel definition
require(candidate.intent == StateIntent.FINALIZE_ESCROW_WITHDRAWAL, IncorrectStateIntent());
Expand Down Expand Up @@ -933,7 +945,7 @@ contract ChannelHub is ReentrancyGuard {
emit EscrowWithdrawalFinalized(escrowId, channelId, candidate);
}

function initiateMigration(ChannelDefinition calldata def, State calldata candidate) external {
function initiateMigration(ChannelDefinition calldata def, State calldata candidate) external nonReentrant {
require(candidate.intent == StateIntent.INITIATE_MIGRATION, IncorrectStateIntent());

bytes32 channelId = Utils.getChannelId(def, VERSION);
Expand Down Expand Up @@ -968,7 +980,7 @@ contract ChannelHub is ReentrancyGuard {
}
}

function finalizeMigration(bytes32 channelId, State calldata candidate) external {
function finalizeMigration(bytes32 channelId, State calldata candidate) external nonReentrant {
require(candidate.intent == StateIntent.FINALIZE_MIGRATION, IncorrectStateIntent());

ChannelDefinition memory def = _channels[channelId].definition;
Expand Down Expand Up @@ -1429,7 +1441,7 @@ contract ChannelHub is ReentrancyGuard {
}
}

function _pullFunds(address from, address token, uint256 amount) internal nonReentrant {
function _pullFunds(address from, address token, uint256 amount) internal {
if (amount == 0) return;

_requireMsgValueForPull(token, amount);
Expand All @@ -1441,7 +1453,7 @@ contract ChannelHub is ReentrancyGuard {

/// @dev Reverts if the transfer fails. Used in non-adversarial contexts where atomicity is required
/// (e.g. voluntary vault withdrawals where the caller controls the destination).
function _pushFunds(address to, address token, uint256 amount) internal nonReentrant {
function _pushFunds(address to, address token, uint256 amount) internal {
if (amount == 0) return;

if (token == address(0)) {
Expand All @@ -1454,7 +1466,7 @@ contract ChannelHub is ReentrancyGuard {

/// @dev Never reverts. On failure, accumulates funds in `_reclaims[to]` for later recovery via `claimFunds()`.
/// Used in adversarial contexts (e.g. channel settlement) where a reverting recipient must not block progress.
function _nonRevertingPushFunds(address to, address token, uint256 amount) internal nonReentrant {
function _nonRevertingPushFunds(address to, address token, uint256 amount) internal {
if (amount == 0) return;

if (token == address(0)) {
Expand Down
Loading
Loading