Nitrolite is an extended state-channel protocol that enables:
- continuous off-chain transfers and application interactions,
- frequent on-chain settlement (deposit / withdrawal),
- cross-chain liquidity movement (bridging),
- without locking all funds until channel closure.
The protocol trades atomic cross-chain guarantees for optimistic enforcement with challenge recovery, relying on cryptographic authorization and game-theoretic incentives.
A channel between a User and a Node is represented by a monotonically increasing sequence of Cross-Chain States.
Each state:
-
has a strictly increasing
version, -
is signed by both User and Node,
-
encodes the net result of:
- on-chain operations (deposit / withdrawal / migration),
- off-chain transfers,
- off-chain application sessions,
- escrow preparation and execution.
A state may refer to multiple chains, but at any time only at most two per-chain sub-states exist (home and non-home).
Each per-chain sub-state represents accounting on a specific chain and consists of:
-
absolute allocations (
userAllocation,nodeAllocation) that must be fully backed by collateral locked on that chain, and -
cumulative net flows (
userNetFlow,nodeNetFlow) that encode the aggregate effect of deposits, withdrawals, off-chain transfers, and app-session lock/unlock events since channel creation.
The difference between successive states’ net flows determines how much value must be pulled from or pushed to each party during on-chain enforcement.
- User — owns funds and initiates actions.
- Node (Broker) — provides liquidity, routing, and coordination.
The off-chain protocol is responsible for:
-
State construction
-
The Node aggregates:
- off-chain transfers,
- app-session lock/unlock events,
- pending on-chain actions.
-
These are netted into a new
Stateby updating per-chain allocations and cumulative net flows.
-
-
State authorization
-
Both User and Node sign the full state:
(channelId, version, intent, homeLedger, nonHomeLedger) -
A party never signs two different states with the same version.
-
-
Liquidity enforcement (Node responsibility)
-
The Node must ensure it has enough liquidity to back absolute allocations:
- between normal operations,
- except during explicitly allowed escrow or migration phases.
-
If liquidity drops below a threshold, the User may:
- checkpoint the latest state on-chain,
- withdraw,
- or migrate the channel.
-
-
Node liquidity monitoring
-
The Node continuously monitors its on-chain vault balance (
_nodeBalances) across all supported chains. -
When the available vault balance on a given chain approaches or falls below the amount required to back pending off-chain allocations, the Node takes corrective action:
- enforce states that release locked Node funds back to the vault (e.g. checkpoint or close idle channels),
- rebalance liquidity across chains via cross-chain operations,
- or fire alerts for operator intervention.
-
The Node must not co-sign states that would require locking more vault funds than are currently available, as such states would fail on-chain enforcement.
NOTE: Node liquidity monitoring is not yet enforced in the Nitronode implementation but will be introduced in the near future.
If Node liquidity monitoring is absent or fails, no user funds are at risk. On-chain enforcement always relies on the latest mutually signed state, and the previous on-chain state remains valid and enforceable at all times. The practical consequence is operational: a co-signed state that requires Node vault funds may fail when submitted on-chain if the vault has been depleted in the interim. In such cases, the Node simply replenishes its vault and resubmits. Users retain full access to their funds through challenge and closure paths based on the last successfully enforced state.
-
-
Flow control
-
When a cross-chain escrow or migration is in progress:
- the Node stops issuing new states,
- until the process completes or is challenged.
This is an off-chain responsibility enforced by the Node. The on-chain contract cannot enforce operation ordering because it has no visibility into pending operations on other chains. Instead, the contract is designed to handle concurrent cross-chain operations correctly — for example, escrow operations remain reachable even after a subsequent migration on the same chain. Such concurrent code paths should never be reached under correct Node behavior, but the on-chain contract handles them safely to guarantee fund recovery in all cases.
-
-
Optimistic bridging
-
Cross-chain actions are not atomically verifiable on-chain.
-
Correctness is ensured by:
- signed states,
- cumulative net-flow accounting,
- timeouts,
- challenge rights.
-
-
When a User sends funds off-chain:
- user allocation decreases,
- node net flow decreases.
-
When a User receives funds off-chain:
- user allocation increases,
- node net flow increases.
These changes are reflected only in cumulative net flows until enforced on-chain.
-
App sessions are off-chain sub-channels governed by an external server.
-
Funds may be:
- locked into a session (flow to Node),
- unlocked from a session (flow to User).
-
Only signatures are required for persistence.
-
Session effects are netted into cumulative net flows of the next enforceable state.
Closing an app session distributes locked allocations back to each participant by issuing a new release receive-state on every participant's home channel. The operation is atomic across all participants — if the Node cannot issue a release for any one participant, the entire close aborts.
In particular, the Node refuses to issue a release to a recipient whose latest signed state encodes an escrow operation that the off-chain gate (EnsureNoOngoingEscrowOperation) does not yet treat as safely settled — covering any pending escrow_lock/mutual_lock, plus escrow_deposit or escrow_withdraw states the gate still treats as unsafe (broadly, those whose on-chain escrow channel has not caught up, with a narrow one-version-behind allowance for escrow_deposit during normal finalize/purge transitions). A single such participant blocks cooperative closure for everyone else in the session until their escrow resolves. See contracts/SECURITY.md Behavior rule 8 for the full rationale and recovery paths.
The on-chain contract is the final arbiter of correctness.
It does not reconstruct intent — it verifies and enforces signed states by:
- validating signatures and monotonic versioning,
- applying the delta between the last enforced state and the submitted state,
- pulling or pushing funds according to net-flow differences,
- updating locked collateral to match absolute allocations.
-
A channel is created with an initial signed state:
- version = 0,
- intent = CREATE,
- funds pulled from the User (home chain).
-
Channel enters
OPERATING.
While operating:
-
Any newer signed state may be enforced on-chain by any party, with one exception:
INITIATE_ESCROW_DEPOSITon the home chain may only be submitted by the Node (see Challenge rules). -
Enforcement may:
- pull funds from User,
- push funds to User,
- lock or unlock Node liquidity.
-
Enforcement may occur for:
- deposit,
- withdrawal,
- checkpoint,
- escrow execution,
- migration execution.
Off-chain activity can continue indefinitely between enforcements.
-
User signs a state with intent = DEPOSIT.
-
User net flow becomes positive.
-
On enforcement:
- 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.
-
User signs a state with intent = WITHDRAW.
-
User net flow becomes negative.
-
On enforcement:
- funds are pushed to User,
- channel locked funds decrease.
-
A state with intent = OPERATE.
-
User net flow delta must be zero.
-
Used to:
- acknowledge off-chain transfers,
- clear challenges,
- synchronize cumulative net-flow accounting.
Challenges protect against:
- submission of outdated states,
- malicious or crashed counterparties,
- incomplete cross-chain operations.
-
Only channels in
OPERATINGorMIGRATING_INcan be challenged. -
A challenge references a signed state.
-
If the challenged state is older than the latest signed state:
- the newest valid signed state must be enforced first, regardless of its intent.
-
The following intents cannot be submitted via
challengeChannel:CLOSE— channel closure is a terminal operation; enforcing it leaves no live channel to dispute. Parties holding a valid CLOSE state should callcloseChanneldirectly instead.FINALIZE_MIGRATIONon the old home chain (channel statusOPERATING/DISPUTED) — this would release the node's funds and move the channel toMIGRATED_OUT, which is incompatible with enteringDISPUTEDstate.INITIATE_ESCROW_DEPOSITon the home chain by a non-Node caller — only the Node may submit this intent on the home chain, whether viainitiateEscrowDepositorchallengeChannel. 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_DEPOSITleaves the user's home-chain allocation unchanged, so challenging the home-chain channel with the immediate predecessor state produces an identical fund distribution.
Invariant:
Dispute resolution always requires processing the newest valid signed state, even if that state represents escrow execution or migration rather than deposit or withdrawal.
-
Any party may submit a strictly newer signed state.
-
If valid:
- it is enforced,
- net-flow deltas are applied,
- the challenge is cleared,
- channel returns to
OPERATING.
-
If no newer state is submitted before expiry:
- channel may be closed unilaterally,
- allocations are paid out according to the last enforced state.
While a channel is in CHALLENGED (i.e. on-chain DISPUTED) status, the Node and the user follow additional off-chain rules:
- Node stops signing receive states. Incoming off-chain transfer credits and app-session release credits targeting the channel are not co-signed. They are appended as unsigned entries to the channel's state history (a per-channel queue).
- User-initiated operations are blocked. The user cannot deposit, withdraw, transfer, or lock funds into an app session. Only receiving funds (which the Node queues per the previous rule) is permitted.
- Operations remain blocked even after the challenge timer expires, until the channel is explicitly closed and
ChannelClosedevent is seen by the Node.
Two resolution paths:
- Challenge cleared (a newer mutually signed state is enforced, returning the channel to
OPERATING): the Node signs only the off-chain head — the highest-version queued "receive" state — so the channel's actual latest state is fully co-signed; the user countersigns and acknowledges. Earlier queued entries remain unsigned in history; the head carries forward their cumulative balance impact, so normal flow resumes with no gap and no credit lost. - Challenge expires and the channel is closed: a single
challenge_rescuestate is committed to the user's next epoch, detached from the closed channel — equivalent to the funds arriving while the user did not have a channel. Its amount is the net effect on the home-channel balance of transitions stored strictly above the closure version at the closed channel's epoch: receives (transfer_receive,release) contribute positively; sends (transfer_send,commit) contribute negatively; other transition kinds (deposit, withdrawal, escrow, migrate, finalize) are excluded because they require onchain backing the chain did not enforce. Signed (pre-challenge) and unsigned (during-challenge) rows both contribute. The result is clamped at zero so an adversarial close at a version where the user's own balance was higher than the off-chain head cannot dock the user further. When no relevant transitions exist above closure, the state carries zero credit and serves only to advance the state chain off the closed channel so future operations are not wedged on it.
The purpose of these rules is to ensure that a CHALLENGED channel cannot have its dispute cleared as a side effect of incoming third-party transfers, and that the user is made whole for net real value owed by the Node regardless of which resolution path is taken.
A channel can be closed:
-
Cooperatively
- via a signed CLOSE state.
-
Unilaterally
- after a challenge expires.
Closure:
- Both parties encode their final payouts as negative net flow deltas (equivalent to a full withdrawal), so the CLOSE state must have
userAllocation == 0andnodeAllocation == 0. - The net flow deltas push all remaining funds to User and Node.
- Sets channel status to CLOSED.
Cross-chain actions are two-phase and optimistic.
Because:
- one chain cannot directly observe or verify another chain’s state,
- atomic enforcement is impossible without foreign-chain verification.
The protocol deliberately does not rely on light clients (on-chain verification of foreign headers, proofs, and validator signatures), as they are complex, expensive, and chain-specific.
The two phases are:
-
Preparation phase
- liquidity is locked on chains where needed,
- an escrow object (possible with timeouts) is created,
- Node stops issuing new states.
-
Execution phase
- an execution state that updates allocations and net flows is issued and signed
- this state may be enforced immediately or later, but is enforceable to resolve disputes.
- User locks funds on the non-home chain.
- Node locks equal liquidity on the home chain.
- An escrow object with timeouts is created.
-
A signed execution state updates allocations and net flows:
- User’s non-home allocation decreases,
- Node’s home allocation decreases,
- corresponding net flows encode the swap.
This execution state may be enforced immediately or later, but must be enforceable to resolve disputes.
- Node locks withdrawal liquidity on the non-home chain.
-
Signed state updates allocations and net flows so that:
- User receives funds on the non-home chain.
If enforcement stalls:
- challenges and timeouts guarantee completion or reversion.
If an escrow process is challenged (status becomes DISPUTED) and the challenge period expires (challengeExpireAt passed) without a resolution:
- The
finalizefunction handles this case explicitly. - If called when
DISPUTEDand expired:- Do not invoke the channel engine.
- Manually unlock the locked funds to the Node.
- Zero out
lockedFundsandchallengeExpireAt. - Set status to
FINALIZED. - Emit a finalization event.
This logic mirrors the channel closure mechanism: if a challenge is not substantiated by a newer state within the timeout, the system defaults to a finalized state that releases locked resources.
After an escrow deposit is resolved, the node's locked funds must be returned to the node vault. The contract maintains a FIFO queue of escrow deposit IDs (_escrowDepositIds), sorted by unlockAt ascending, and a monotonically advancing head pointer (escrowHead).
A purge pass processes queue entries in order:
- FINALIZED entries are skipped — funds were already credited during finalization.
- DISPUTED entries (challenge still active) are skipped — the challenge outcome is pending.
- INITIALIZED entries past their
unlockAttimestamp are purged — the node's locked amount is returned to the node vault and the entry is marked FINALIZED. - INITIALIZED entries not yet at
unlockAtstop the scan — because the queue is sorted byunlockAtascending, no subsequent entry can be purgeable either.
The scan is bounded by a maxSteps budget that counts every inspected entry, whether skipped, purged, or halting. _purgeEscrowDeposits is called automatically on every protocol operation with a budget of MAX_DEPOSIT_ESCROW_STEPS = 64. The public purgeEscrowDeposits(maxSteps) function allows any caller to drain a backlog explicitly.
Migration enables moving the channel's "home" security chain from one blockchain to another, preserving allocations and cumulative accounting.
Like other cross-chain operations, migration is two-phase and optimistic.
NOTE: Home chain migration is not yet active in the Nitronode off-chain implementation. The on-chain
ChannelHubcontract fully implements bothinitiateMigration()andfinalizeMigration(), and the migration protocol is fully specified below. The Nitronode off-chain flow — including themigratetransition type, two-chain event coordination, and theMigrationInInitiated/MigrationOutFinalizedlifecycle — has not yet been implemented. Submitting amigratetransition via thechannels.v1.submit_stateRPC method returns an error. This section documents the intended design for future implementation.
The preparation phase establishes the channel on the target (non-home) chain:
- A preparation state is constructed with:
- intent = INITIATE_MIGRATION,
- non-home state where Node deposits liquidity equal to User's allocation on the home chain.
On the non-home chain:
- This state is submitted via
initiateMigration(). - Effect:
- creates a channel on the non-home chain with status
MIGRATING_IN, - locks Node's funds on the non-home chain.
- creates a channel on the non-home chain with status
- Implementation note: States are swapped before storing to maintain the invariant that
homeLedgerrepresents the current chain. - A
MIGRATING_INchannel is treated as the home chain for all subsequent on-chain operations: because the swap has already been applied to the stored state,homeLedgeralready describes the current chain and ChannelEngine processes operations (deposit, withdraw, checkpoint, escrow, close) using standard home chain logic. The only distinction fromOPERATINGis thatfinalizeMigration()has not yet been called to confirm the migration on the old home chain.
On the home chain:
- The preparation state can be submitted via
initiateMigration():- updates the channel's latest state,
- keeps the channel in
OPERATINGstatus, - can clear a challenge (following standard challenge resolution flow).
- The preparation state cannot be checkpointed via
checkpoint():checkpoint()explicitly rejects states with migration intents.
- The home-chain channel can be challenged with a preparation state:
- this enables dispute resolution if something goes wrong,
- a valid FINALIZE_MIGRATION execution state can move the channel from
DISPUTEDtoMIGRATED_OUT, - otherwise, after
challengeExpireAt, funds may be withdrawn according to standard challenge rules.
The execution phase completes the migration by swapping home and non-home roles:
- An execution state is constructed that:
- swaps the
homeLedgerandnonHomeLedgerfrom the preparation phase, - swaps allocations between User and Node in each state,
- intent = FINALIZE_MIGRATION.
- swaps the
On the old home chain:
- This state is submitted via
finalizeMigration():- releases Node liquidity on the old home chain,
- moves the channel to
MIGRATED_OUTstatus, - can clear a challenge (moving from
DISPUTEDtoMIGRATED_OUT).
- Implementation note: States are swapped before validation to maintain the invariant that
homeLedgerrepresents the current chain.
On the new home chain (old non-home chain):
- The execution state may be submitted explicitly via
finalizeMigration():- moves the channel from
MIGRATING_INtoOPERATING.
- moves the channel from
- However, the intended usage is to combine the execution phase with a subsequent operation:
- any on-chain call (deposit, withdrawal, checkpoint, escrow initiate/finalize, or close) can be applied on top of the execution phase state,
- this implicitly completes the migration and transitions the channel to
OPERATING.
- The new home chain can be challenged with the execution state (or any newer valid state), triggering normal challenge resolution.
A channel on a chain with status MIGRATED_OUT can be migrated back:
- Submitting a new preparation phase state via
initiateMigration()on that chain:- moves the channel from
MIGRATED_OUTtoMIGRATING_IN, - initiates a reverse migration flow.
- moves the channel from
This enables round-trip migration as needed.
Migration presents a unique challenge for on-chain implementation: which state represents the current chain changes during migration.
The protocol describes migration as swapping homeLedger and nonHomeLedger roles, but this creates semantic ambiguity for on-chain validation:
-
Preparation phase on non-home chain: Actions (node deposits liquidity) are encoded in
nonHomeLedger, but after the channel is created, subsequent operations must calculate deltas from this state—even though validation logic assumeshomeLedgerrepresents the current chain. -
Execution phase on old home chain: After the user swaps states in the execution phase state,
nonHomeLedgerrepresents the old home (current chain), but validation logic expectshomeLedgerto represent the current chain. -
Delta calculation inconsistency: After
INITIATE_MIGRATIONon the non-home chain creates aMIGRATING_INchannel, the next operation (e.g., deposit) cannot correctly calculate deltas because the previous state's allocations are innonHomeLedger, nothomeLedger.
To maintain the invariant that homeLedger always represents the chain where execution happens, the implementation uses:
1. Two Migration Intents with Context-Based Behavior:
INITIATE_MIGRATION: Single intent used on both home and non-home chainsFINALIZE_MIGRATION: Single intent used on both old home and new home chains
The same signed state can be submitted on both chains. The contract determines the correct behavior based on the channel status:
- INITIATE_MIGRATION + status VOID/MIGRATED_OUT → non-home chain behavior (create MIGRATING_IN)
- INITIATE_MIGRATION + status OPERATING/DISPUTED → home chain behavior (update state)
- FINALIZE_MIGRATION + status MIGRATING_IN → new home chain behavior (move to OPERATING)
- FINALIZE_MIGRATION + status OPERATING/DISPUTED → old home chain behavior (move to MIGRATED_OUT)
2. Four ChannelHub Functions:
initiateMigration(): Called on non-home chain to createMIGRATING_INchannelinitiateMigration(): Called on home chain to update statefinalizeMigration(): Called on new home chain to moveMIGRATING_IN→OPERATINGfinalizeMigration(): Called on old home chain to release funds and move toMIGRATED_OUT
All functions accept the same intents (INITIATE_MIGRATION or FINALIZE_MIGRATION), allowing the same signed state to be used on both chains.
2. Selective State Swapping (only where needed):
-
INITIATE_MIGRATION(on new home chain): SwaphomeLedger↔nonHomeLedgerbefore storing- Incoming state has actions in
nonHomeLedger(new home = current chain) - After swap, stored state has actions in
homeLedger(current chain) - Result: Next operation calculates deltas correctly from
homeLedger
- Incoming state has actions in
-
FINALIZE_MIGRATION(on old home chain): SwaphomeLedger↔nonHomeLedgerbefore validation- Incoming state (after user swaps) has old home actions in
nonHomeLedger(current chain) - After swap, validation sees actions in
homeLedger(current chain) - Result: Validation and fund release logic work correctly
- Incoming state (after user swaps) has old home actions in
-
No swap needed for
INITIATE_MIGRATION(on old home chain) andFINALIZE_MIGRATION(on new home chain) (homeLedger already represents current chain)
3. Special Delta Calculation for FINALIZE_MIGRATION (on new home chain):
When finalizing migration on the new home chain, the previous state (from INITIATE_MIGRATION) has allocations in nonHomeLedger (before swap) but was swapped when stored. Delta calculation must account for this:
delta = candidate.homeLedger.netFlow - prevStoredState.homeLedger.netFlowThis works because prevStoredState was swapped during INITIATE_MIGRATION.
- Signatures are validated before swapping (using the original signed state)
- After swapping, signatures are invalidated (
userSig = "",nodeSig = "") to prevent misuse - The swapped state is only used internally for storage and validation
- Migration lifecycle events (
MigrationInInitiated,MigrationOutInitiated,MigrationInFinalized,MigrationOutFinalized) emit the original signed state (before swap) for off-chain observability ChannelClosedis an exception: it emitsmeta.lastState, which on the new home chain is the already-swapped state stored duringinitiateMigration()- This approach maintains the critical invariant: ChannelEngine always sees homeLedger as the current chain
If a migration is initiated but never finalized and both channel copies are subsequently challenged and closed after timeout, ChannelClosed can be emitted on both chains for the same channelId and state version but with opposite homeLedger/nonHomeLedger orientation:
- Old home chain (
OPERATING/DISPUTED): emitsfinalStatewithhomeLedger= old home chain (original orientation) - New home chain (
MIGRATING_IN/DISPUTED): emitsfinalStatewithhomeLedger= new home chain (swapped orientation)
On-chain fund accounting is correct in both cases — each chain pays from its own locally stored allocations. The concern is for off-chain consumers:
- Index and key
ChannelClosedevents by(chainId, ChannelHub, channelId), never bychannelIdalone - Treat each emission as a distinct local settlement; there is no single canonical
finalStatefor an abandoned migration - Code that persists the emitted
finalStatemust handle the swapped ledger orientation for channels inMIGRATING_INstatus
-
Authorization: all state changes require valid signatures.
-
Monotonicity:
versionstrictly increases. -
Replay resistance: no two states with the same version can coexist.
-
Cross-deployment replay protection: Each ChannelHub deployment has a
VERSIONconstant. The version is encoded as the first byte ofchannelId, ensuring that signatures are bound to a specific ChannelHub version. This prevents replay attacks across different ChannelHub deployments on the same chain. TheescrowIdinherits this protection as it is derived fromchannelId. -
Liquidity safety: absolute allocations must be collateral-backed.
-
Optimistic safety:
- challenges always resolve by enforcing the newest valid state,
- stalled cross-chain operations can always be completed or reverted.
-
Token compatibility:
Only standard ERC20 tokens and native ETH are supported. The following token types are incompatible with the static ledger model:
- Rebasing tokens (e.g. stETH, aTokens): their autonomous balance changes are invisible to the ledger and create unrecoverable accounting divergence. Use non-rebasing wrappers instead (e.g. wstETH).
- Fee-on-transfer tokens: the amount received by the contract is less than the amount recorded, causing the ledger to overstate holdings from the very first deposit.
- Tokens without
decimals(): the contract callsIERC20Metadata.decimals()on every state transition; tokens that do not implement this optional ERC-20 extension are rejected on-chain withFailedToFetchDecimals. Unlike rebasing and fee-on-transfer issues (which silently corrupt accounting), missingdecimals()causes an immediate hard revert.
There is no hard-coded guardrail preventing deposit of rebasing or fee-on-transfer 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.
-
Transfer failure resilience: Outbound transfers (to users) never revert on failure:
- Failed transfers (due to blacklists, hooks, or token features) accumulate in a reclaim balance,
- Gas limiting (100k gas) prevents gas depletion attacks from malicious recipients or ERC777/ERC1363 hooks,
- Users can later claim accumulated funds to alternative addresses,
- This prevents two critical attack vectors:
- Channel lifecycle denial: User blacklists prevent state enforcement, blocking Node operations,
- Node fund lock: User forces Node to lock large funds via escrow deposit, then blocks all recovery operations with minimal capital.
- Combined gas limiting + reclaim pattern ensures channel operations continue regardless of transfer success.
-
Node trust for off-chain transfer routing: Off-chain transfers between parties are routed through the Node. The sender signs a state where their allocation decreases; the Node is expected to countersign it and also countersign a corresponding credit state for the receiver. The on-chain contract cannot enforce atomicity between two independent channel updates. A malicious Node could apply the sender's state while withholding the receiver's credit, effectively capturing the transferred funds. Users must trust the Node to faithfully execute both legs of every off-chain transfer.
-
Asset-symbol equivalence: All tokens configured under one asset symbol are treated as fully fungible 1:1 representations, so off-chain credit can be redeemed from any token inventory sharing that symbol. The Node operator MUST configure only economically equivalent (1:1 redeemable) tokens under a single symbol; equivalence cannot be verified programmatically and is an operator configuration responsibility.
The protocol supports flexible signature validation through two complementary systems: a validator registry and a bitmask for agreed validators. This design prevents signature forgery attacks while enabling custom signature schemes and maintaining cross-chain compatibility.
Agreed validators are specified in the ChannelDefinition.approvedSignatureValidators field (uint256 bitmask). The default ECDSA validator (0x00) is always available, regardless of the bitmask value. The bitmask specifies which additional validators from the node's registry are agreed validators. For example, if bit 42 is set to 1, then validator ID 42 from the node's registry is approved.
Since approvedSignatureValidators is part of the channelId computation, agreed validators cannot be changed during cross-chain operations without invalidating signatures. This prevents malicious nodes from forging user signatures by registering fake validators.
Security properties:
- Users control which validators (beyond the always-available default) can be used
- Cross-chain compatible (approvedSignatureValidators is in channelId, which is in all signatures)
- Zero transaction overhead (no separate validator registration needed)
- Prevents node-controlled validator forgery attacks
- Default ECDSA validator always available as fallback
- Signature domain: The default on-chain ECDSA validator accepts both EIP-191 (
eth_sign) and rawkeccak256signatures (this is done for extensibility), while the Nitronode off-chain validator accepts EIP-191 only. All client-produced signatures must use EIP-191 to be valid on both paths.
The protocol uses a validator registry where NODE registers signature validators and assigns them 1-byte identifiers (0x01-0xFF).
Design rationale: This allows NODE to use flexible signature schemes (SessionKey, multi-sig, etc.) for its own signatures while preventing it from controlling user signature validation. Benefits:
- NODE can enforce its security requirements for node signatures
- NODE benefits from flexible validator implementations
- Cross-chain compatibility (validator addresses don't affect channelId or signature verification)
- User signatures remain protected via approved validators bitmask
Nodes register validators by providing a signature over the validator configuration. This allows node operators to use cold storage or hardware wallets without exposing private keys to send transactions.
Registration message:
bytes memory message = abi.encode(validatorId, validatorAddress, block.chainid);The signature is verified using ECDSA recovery:
- Try EIP-191 recovery first (standard for wallet software)
- Fall back to raw ECDSA if needed
- Verify recovered address matches the node address
The registration signature includes block.chainid for cross-chain replay protection, ensuring validator registrations are chain-specific and cannot be replayed across chains.
All signatures in the protocol follow this structure:
[validator_id: 1 byte][signature_data: variable length]
For user signatures:
0x00= Use ChannelHub's default ECDSA validator (always available)0x01-0xFF= Look up validator in node's registry, only if corresponding bit is set inapprovedSignatureValidators(e.g., ID 42 allowed if bit 42 is 1)
For node signatures:
0x00= Use ChannelHub's default ECDSA validator0x01-0xFF= Look up validator in node's registry (always available for nodes)
The first byte determines which validator verifies the signature. The remaining bytes are passed to the selected validator for verification.
The dual validator selection system solves critical cross-chain problems:
User validators (approved validators bitmask): Since the allowed validator bitmask is in ChannelDefinition.approvedSignatureValidators, which is part of channelId, it travels with every signature across all chains. No cross-chain synchronization is needed.
Node validators (per-node registry): Validator contracts may not be deployed to the same address on all chains. The registry uses 1-byte IDs instead of addresses, allowing the same validator ID to map to different addresses on different chains. Nodes register their validators independently on each chain.
Full analysis with all considered options and trade-offs:
contracts/initial-user-sig-validation.md.
The approvedSignatureValidators bitmask protects user signatures on existing channels — the bitmask is part of channelId, so it is covered by every previously signed state. However, for createChannel there is no prior state: the bitmask comes from calldata supplied by the transaction sender, which may be the node itself.
This creates a circular dependency: the validator used to confirm the user's consent to the ChannelDefinition is selected from data the node controls. A node (basically, any address could be a node) could register a permissive validator and craft a ChannelDefinition that points to it, opening a channel without any real user signature.
The key distinction is between bootstrap validation (proving the user consented to the initial ChannelDefinition) and ongoing validation (proving the user authorized a specific state on an already-agreed channel). The approvedSignatureValidators bitmask is the right tool for ongoing validation but cannot self-validate at creation time.
The current implementation binds each ChannelHub to a single node address at deploy time (NODE immutable). _requireValidDefinition rejects any createChannel call where def.node != NODE. This eliminates the any-address variant of the attack: a malicious actor cannot forge user signatures on a ChannelHub bound to a different node.
Within the trust boundary of the bound node the original vulnerability remains. Users who interact with a given deployment must already trust that node — they sign off-chain states with it and grant it ERC20 allowances — so the residual risk sits inside an existing trust relationship rather than being exploitable by an arbitrary third party.
A contract-enforced VALIDATOR_ACTIVATION_DELAY (1 day) provides a partial, targeted defence within this trust boundary, effective only when users actively monitor on-chain registrations within the window: a newly registered validator cannot be used until the delay has elapsed, giving users time to detect a compromise and revoke ERC20 approvals before the attack can execute. Validators are permanent once registered — there is no deactivation mechanism. Users should subscribe to ValidatorRegistered events on the ChannelHub contract and avoid granting large standing ERC20 approvals; see contracts/SECURITY.md for concrete guidance.
A consequence of this model is that each node requires its own ChannelHub deployment; a single contract instance cannot serve multiple independent nodes.
Two designs fully close the bootstrap gap without per-node deployments:
Protocol-managed bootstrap registry (Option F). A separate registry, controlled by a protocol multisig (bootstrapAdmin), lists the only validators that may be used for the createChannel user signature. Nodes have no influence over this registry. The initial set covers ECDSA and ERC-1271; additional schemes (e.g. an ERC-4337 freezer validator) can be added by governance without redeployment. The remaining trust assumption is that the multisig is not compromised.
Two-registry system with tiered trusted validators (Option G). The trusted validator set is split into a hardcoded tier (validator IDs 0–2, in contract bytecode) and a governance tier (IDs 3+, extensible by a multisig with a contract-enforced activation delay). createChannel accepts only hardcoded-tier IDs for the user signature; no governance action can influence it.
Subsequent operations accept both tiers, filtered by the bitmask stored at creation time. This makes createChannel fully admin-proof while preserving extensibility for later operations. ERC-4337 wallets with key-rotation needs are supported within the hardcoded tier via a FreezerProxy ERC-1271 wrapper, requiring no governance action.
Several ChannelHub events are informational — emitted on a best-effort basis when a specific dedicated function path is taken, but not guaranteed to fire for every logical occurrence of the operation they name. Because the protocol allows any newer valid signed state to be enforced directly (e.g. a standard deposit or checkpoint on a MIGRATING_IN channel), intermediate cross-chain states can be bypassed without calling their dedicated functions, and therefore without emitting their events.
External consumers such as indexers, SDKs, and analytics tooling must not treat these events as exhaustive signals. The canonical terminal events for each cross-chain flow remain reliable and are always emitted.
The following events are informational:
MigrationInFinalized— not emitted if aMIGRATING_INchannel transitions toOPERATINGvia any standard operation (deposit, withdraw, checkpoint, challenge) rather than an explicitfinalizeMigration()call. The canonical migration completion signal isMigrationOutFinalized, which is always emitted unconditionally on the old home chain.MigrationOutInitiated— not emitted if a newer signed state is enforced on the old home channel that supersedes the explicit initiation state.EscrowDepositFinalized— not emitted if the non-home channel advances past escrow finalization via a newer signed state.EscrowDepositFinalizedOnHome— not emitted if the home channel advances past the escrow finalization acknowledgement via a newer signed state.EscrowWithdrawalInitiatedOnHome— not emitted if the home channel advances past the escrow withdrawal initiation acknowledgement via a newer signed state.EscrowWithdrawalFinalizedOnHome— not emitted if the home channel advances past the escrow withdrawal finalization acknowledgement via a newer signed state.
The Nitronode does not rely on any of these informational events for its state machine. For migration, the Nitronode watches MigrationInInitiated (the on-chain signal establishing the new home chain) and MigrationOutFinalized (the unconditional completion signal on the old home chain).
- Off-chain protocol decides what should happen.
- On-chain contract enforces the latest authorized accounting state.
- Bridging is non-atomic but recoverable.
- The channel is continuously enforceable, not locked until closure.