diff --git a/cerebro/commands.go b/cerebro/commands.go index 7dcb3051e..0cfa03d34 100644 --- a/cerebro/commands.go +++ b/cerebro/commands.go @@ -1010,13 +1010,45 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, return } - // Determine version by fetching existing keys + // Determine version by fetching existing keys. include_inactive=true so an expired + // prior version still surfaces — otherwise rotation would restart from version 1 and + // collide with the monotonic pointer enforced server-side. + includeInactive := true var version uint64 = 1 existingStates, err := o.client.GetLastChannelKeyStates(ctx, wallet, &sdk.GetLastChannelKeyStatesOptions{ - SessionKey: &sessionKeyAddr, + SessionKey: &sessionKeyAddr, + IncludeInactive: &includeInactive, }) if err == nil && len(existingStates) > 0 { - version = existingStates[0].Version + 1 + for _, s := range existingStates { + if s.Version >= version { + version = s.Version + 1 + } + } + } + + // SessionKeySig requires the session-key private key. Fetch it up-front and bail if the + // stored key doesn't match the address being registered — without the private key, the + // nitronode rejects the submit. + storedPK, pkErr := o.store.GetSessionKeyPrivateKey() + if pkErr != nil { + fmt.Printf("ERROR: Cannot register session key without the matching private key: %v\n", pkErr) + return + } + storedRawSigner, sigErr := sign.NewEthereumRawSigner(storedPK) + if sigErr != nil { + fmt.Printf("ERROR: Failed to load stored session key: %v\n", sigErr) + return + } + if !strings.EqualFold(storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) { + fmt.Printf("ERROR: Stored session key %s does not match the address being registered (%s)\n", + storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) + return + } + sessionKeySigner, sigErr := sign.NewEthereumMsgSignerFromRaw(storedRawSigner) + if sigErr != nil { + fmt.Printf("ERROR: Failed to construct session-key message signer: %v\n", sigErr) + return } expiresAt := time.Now().Add(time.Duration(expiresHours) * time.Hour) @@ -1037,6 +1069,13 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, } state.UserSig = sig + keySig, err := sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) + if err != nil { + fmt.Printf("ERROR: Failed to sign session key ownership: %v\n", err) + return + } + state.SessionKeySig = keySig + fmt.Println("Submitting channel session key state...") if err := o.client.SubmitChannelSessionKeyState(ctx, state); err != nil { fmt.Printf("ERROR: Failed to submit session key state: %v\n", err) @@ -1049,21 +1088,7 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, fmt.Printf(" Assets: %s\n", strings.Join(assets, ", ")) fmt.Printf(" Expires At: %s\n", expiresAt.Format("2006-01-02 15:04:05")) - // If we have a stored session key matching this address, activate it as the state signer - storedPK, pkErr := o.store.GetSessionKeyPrivateKey() - if pkErr != nil { - return - } - storedSigner, sigErr := sign.NewEthereumRawSigner(storedPK) - if sigErr != nil { - return - } - if !strings.EqualFold(storedSigner.PublicKey().Address().String(), sessionKeyAddr) { - return - } - - // Compute metadata hash and store full session key data - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(wallet, version, assets, expiresAt.Unix()) if err != nil { fmt.Printf("WARNING: Failed to compute metadata hash: %v\n", err) return @@ -1135,10 +1160,14 @@ func (o *Operator) createAppSessionKey(ctx context.Context, sessionKeyAddr, expi return } - // Determine version by fetching existing keys + // Determine version by fetching existing keys. include_inactive=true so an expired + // prior version still surfaces — otherwise rotation would restart from version 1 and + // collide with the monotonic pointer enforced server-side. + includeInactive := true var version uint64 = 1 existingStates, err := o.client.GetLastAppKeyStates(ctx, wallet, &sdk.GetLastKeyStatesOptions{ - SessionKey: &sessionKeyAddr, + SessionKey: &sessionKeyAddr, + IncludeInactive: &includeInactive, }) if err == nil && len(existingStates) > 0 { for _, s := range existingStates { @@ -1148,6 +1177,28 @@ func (o *Operator) createAppSessionKey(ctx context.Context, sessionKeyAddr, expi } } + // SessionKeySig requires the session-key private key. + storedPK, pkErr := o.store.GetSessionKeyPrivateKey() + if pkErr != nil { + fmt.Printf("ERROR: Cannot register session key without the matching private key: %v\n", pkErr) + return + } + storedRawSigner, sigErr := sign.NewEthereumRawSigner(storedPK) + if sigErr != nil { + fmt.Printf("ERROR: Failed to load stored session key: %v\n", sigErr) + return + } + if !strings.EqualFold(storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) { + fmt.Printf("ERROR: Stored session key %s does not match the address being registered (%s)\n", + storedRawSigner.PublicKey().Address().String(), sessionKeyAddr) + return + } + sessionKeySigner, sigErr := sign.NewEthereumMsgSignerFromRaw(storedRawSigner) + if sigErr != nil { + fmt.Printf("ERROR: Failed to construct session-key message signer: %v\n", sigErr) + return + } + state := app.AppSessionKeyStateV1{ UserAddress: wallet, SessionKey: sessionKeyAddr, @@ -1165,6 +1216,13 @@ func (o *Operator) createAppSessionKey(ctx context.Context, sessionKeyAddr, expi } state.UserSig = sig + keySig, err := sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) + if err != nil { + fmt.Printf("ERROR: Failed to sign session key ownership: %v\n", err) + return + } + state.SessionKeySig = keySig + fmt.Println("Submitting app session key state...") if err := o.client.SubmitAppSessionKeyState(ctx, state); err != nil { fmt.Printf("ERROR: Failed to submit session key state: %v\n", err) diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index f87347f64..761b1ad92 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -41,17 +41,6 @@ Invariant: --- -- (NOT TRUE) only less-or-equal amount of internally-accounted funds can be withdrawn (NOT TRUE for states that include "receive" off-chain ops) - -The absence of the aforementioned invariant creates a huge risk of an attacker draining the Node. -To protect from this, the Node should keep CORRECT track of off-chain user funds. -CAUTION IS REQUIRED. - -P.S. This invariant still can be enforced by updating `lockedFunds` per channel meta-variable during on-chain state processing, -e.g. when processing "receive X, withdraw Y", increase `lockedFunds` (and "lock" Node's funds in channel) by X, and then decrease by Y. - ---- - - User funds can be withdrawn only after channel is finalized (closed or challenged) or during WITHDRAW action - any action is valid only with a Node's signature (for now, but this condition may be loosened to improve UX by making protocol more complex) - a state with `version` <= `latestKnownVersion` per chain cannot be accepted as valid @@ -182,6 +171,58 @@ no funds can be permanently locked if it does. --- +### 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. + +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** in `finalState`: + +- **Old home chain** (`OPERATING`/`DISPUTED`): `finalState.homeLedger` describes the old home chain (original orientation as signed). +- **New home chain** (`MIGRATING_IN`/`DISPUTED`): `finalState.homeLedger` describes the new home chain (swapped orientation as stored). + +On-chain accounting is correct in both cases — each chain pays from its own locally stored allocations. The concern is for **off-chain consumers** of the `ChannelClosed` event: + +- Key `ChannelClosed` events by `(chainId, ChannelHub, channelId)`, never by `channelId` alone. +- Treat each emission as a distinct local settlement; there is no single canonical `finalState` for an abandoned migration close. +- Code that persists the emitted `finalState` must handle the swapped ledger orientation for channels in `MIGRATING_IN` status at close time. + +Under correct Node behavior this scenario does not occur: the Node stops issuing new states during migration and ensures either finalization or reversion before closing. The on-chain contract handles it safely so that no funds are permanently locked if it does occur. + +--- + +## Trust Assumptions + +Beyond the cryptographic and on-chain guarantees listed above, correct user fund protection depends on the following trust assumptions about Node behavior. + +### Node as off-chain transfer intermediary + +When a user sends funds off-chain to another party, the protocol requires two independent state updates that the Node must countersign: + +1. The sender's channel state: user allocation decreases, encoding the transfer. +2. The receiver's channel state: receiver allocation increases. + +A malicious Node could countersign the sender's state (making the reduction in user allocation on-chain enforceable) while withholding the receiver's credit state — pocketing the transferred funds. The on-chain contract has no visibility into independent per-channel state updates and cannot enforce atomicity between them. + +**Users must trust the Node to faithfully execute both legs of every off-chain transfer.** This extends the existing Node trust relationship (liquidity management, cross-chain relay) to include off-chain transfer routing. If the Node behaves maliciously, the user's recourse is to challenge the channel and close it, recovering whatever funds remain in their on-chain allocation at the last enforced state. + +### Node off-chain credit accounting + +The protocol does not bound on-chain withdrawals to the amount explicitly deposited. States that include "receive" off-chain operations can increase a user's allocation beyond what was deposited, reflecting credit extended by the Node. A Node that over-issues credits may have insufficient vault funds to honour those allocations when they are enforced on-chain. + +**Users must trust the Node to extend only credit that is backed by actual vault funds.** The Node is solely responsible for maintaining correct off-chain accounting of all user balances and ensuring no signed state represents an allocation the Node cannot fund. + +### Signature validator selection + +In the single-node deployment model, the ChannelHub is deployed by the Node operator, who also chooses the `defaultSigValidator`. The following trust properties apply: + +- **Default validator trust**: All participants using the default validator (0x00) trust the ChannelHub deployer's choice of default validator. +- **User validator control**: Users control which additional validators (beyond the always-available default) can verify their signatures via the `approvedSignatureValidators` bitmask in `ChannelDefinition`. This prevents nodes from forging user signatures by registering malicious validators. +- **Validator agreement**: Both users and nodes can only use agreed validators specified in the bitmask (plus the always-available default validator), preventing unilateral changes to signature validation schemes. +- **Registration immutability**: Once a node registers a validator at a specific ID, it cannot be changed. Signatures created with a given validator ID remain valid for the lifetime of the ChannelHub deployment. +- **Cross-chain consistency**: The same validator ID may map to different validator addresses on different chains, but the security properties must remain equivalent. Nodes are responsible for registering compatible validators across chains. + +--- + ## Signature Validation Security The Nitrolite protocol uses a pluggable signature validation system to support flexible authorization schemes. This section describes the security model and considerations for signature validators. @@ -243,15 +284,15 @@ The protocol maintains clear separation between protocol concerns (ChannelHub) a See `signature-validators.md` for detailed documentation on each validator. -### Trust Model +### On-Chain vs Off-Chain Signature Domain Asymmetry -- **Default validator trust**: All participants using the default validator (0x00) trust the ChannelHub deployer's choice of default validator. -- **User validator control**: Users control which additional validators (beyond the always-available default) can verify signatures via the `approvedSignatureValidators` bitmask in `ChannelDefinition`. This prevents nodes from forging user signatures by registering malicious validators. Users can approve specific validators from the node's registry by setting the corresponding bits. -- **Validator agreement**: Both users and nodes can only use agreed validators specified in the bitmask (plus the always-available default validator). This ensures that validators are mutually agreed upon and prevents unilateral changes to signature validation schemes. -- **Registration immutability**: Once a node registers a validator at a specific ID, it cannot be changed. This ensures that signatures created with a given validator ID remain valid for the lifetime of the ChannelHub deployment. -- **Cross-chain consistency**: The same validator ID may map to different validator addresses on different chains, but the security properties must remain equivalent. Nodes are responsible for registering compatible validators across chains. +The default ECDSA validator (`EcdsaSignatureUtils.validateEcdsaSigner`) accepts **both EIP-191 and raw ECDSA** signatures on-chain: it first attempts EIP-191 recovery (the `"\x19Ethereum Signed Message:\n"` prefix used by `eth_sign`), then falls back to raw `keccak256(message)` recovery if EIP-191 fails. ---- +The Nitronode off-chain validator (`ChannelSigValidator`) uses **EIP-191 only** — no raw-ECDSA fallback. + +**Consequence:** A channel state signature produced with raw `keccak256` (no EIP-191 prefix) will pass on-chain validation but be rejected off-chain by the Nitronode. Under correct protocol operation this cannot occur, because the Nitronode only countersigns states it has already verified off-chain. However, implementations building custom signers or relayers must be aware of this asymmetry. + +**All client implementations must use EIP-191 (`eth_sign`) for channel state signatures** to ensure round-trip validity on both the off-chain (Nitronode) and on-chain (ChannelHub) validation paths. The raw-ECDSA fallback exists in the on-chain contract as a compatibility measure and should not be relied upon for new integrations. ### Bootstrap vulnerability: initial user signature at `createChannel` @@ -293,6 +334,22 @@ The registration is an observable on-chain event, and with monitoring in place, **Operational consequence:** Each node requires its own ChannelHub deployment and its own set of ERC20 approvals from users. A single deployment cannot serve multiple independent nodes. Validators must be registered 1 day before first use (one-time cost per validator). +**User responsibilities and monitoring requirement:** + +The `VALIDATOR_ACTIVATION_DELAY` is only effective if users actively monitor on-chain validator registrations within the 1-day window. The delay provides no protection for users who are not watching. Key points every user must understand: + +1. **Validators are permanent.** Once a validator is registered at a given ID, it cannot be deactivated or overwritten. If a malicious validator becomes active, it remains active for the lifetime of the ChannelHub deployment. There is no on-chain recovery mechanism after the delay expires. + +2. **The 1-day window is the entire response budget.** After `VALIDATOR_ACTIVATION_DELAY` elapses, the validator is active and any outstanding ERC20 approvals to ChannelHub are immediately exploitable via a forged `createChannel`. + +3. **Users must subscribe to `ValidatorRegistered` events.** Monitor the `ValidatorRegistered(uint8 indexed validatorId, address indexed validator)` event on the ChannelHub contract. Any unexpected registration should be treated as a potential compromise. Upon detecting an unrecognised validator: + - Revoke all ERC20 approvals granted to ChannelHub immediately (protects undeposited funds). + - Exit any open escrow positions via `initiateEscrowWithdrawal` before the delay expires — funds already in escrow remain exposed until withdrawn, since a malicious validator can forge user signatures for any on-chain operation, not only `createChannel`. + +4. **Avoid large standing ERC20 approvals.** Do not grant unlimited (`type(uint256).max`) or long-lived ERC20 allowances to ChannelHub. Prefer exact-amount approvals per operation. A standing approval only becomes exploitable once a malicious validator activates, so minimising the approved amount caps the worst-case loss. + +The Go SDK exposes `Client.WatchValidatorRegistered` and the TypeScript SDK exposes `Client.watchValidatorRegistered`, both delivering events as streams. The two implementations differ in transport requirements: the Go watcher uses `SubscribeFilterLogs` and requires a WebSocket or IPC endpoint (`wss://`); the TypeScript watcher uses viem's `watchContractEvent` which falls back to polling `getLogs` over HTTP when no WebSocket endpoint is configured. + #### Stronger alternatives **Option F — Protocol-managed bootstrap registry.** A separate registry controlled by a `bootstrapAdmin` multisig lists the validators permitted for `createChannel` user-sig validation. Nodes have no influence over this registry. New schemes (e.g. an ERC-4337 freezer validator) can be added without redeployment. The remaining attack requires compromising the multisig; using a timelock gives users a guaranteed observation window. Supports multiple nodes in one deployment. @@ -383,6 +440,12 @@ Use non-rebasing equivalents where available (e.g. wstETH instead of stETH). Fee-on-transfer tokens are **not supported**. The amount received by the contract is less than the amount recorded in the ledger, causing it to overstate holdings from the very first deposit. This produces the same class of insolvency as a negative rebase: late withdrawers may receive less than recorded or nothing at all. +### Tokens without `decimals()` + +ERC-20 defines `decimals()` as an **optional** extension (via `IERC20Metadata`), not a core requirement. However, during token decimals validation a `IERC20Metadata(token).decimals()` is called, which will revert the outer transaction if the call fails. This check runs on every channel state transition, escrow deposit, and escrow withdrawal — meaning tokens that omit `decimals()` **cannot be used at all**, even for otherwise supported operations. + +Tokens must implement `IERC20Metadata.decimals()`. Unlike the rebasing and fee-on-transfer restrictions (which are accounting-correctness requirements), this is a hard on-chain gate: the contract will reject the transaction immediately rather than silently misaccount. + --- ## Native ETH vs ERC20 Deposit Asymmetry @@ -567,3 +630,24 @@ Critically, DISPUTED entries still consume a step. Without this, an actor could > **Bounded purge iteration** (complements invariant 22): `_purgeEscrowDeposits(maxSteps)` inspects at most `maxSteps` queue entries per call. Every inspected entry, regardless of disposition (skipped, purged, or halting), counts against the budget. The per-operation automatic budget is `MAX_DEPOSIT_ESCROW_STEPS = 64`. --- + +## Informational Events + +Several `ChannelHub` events are **informational** — they are emitted on a best-effort basis when a specific dedicated function path is taken, but are not guaranteed to fire for every logical occurrence of the operation they name. A newer signed state that supersedes an intermediate cross-chain step can be enforced directly through a standard channel operation (deposit, withdraw, checkpoint, challenge), bypassing the dedicated function and therefore skipping its event. This is intentional: for example, `MIGRATING_IN` channels are treated as fully operational home-chain channels, and similarly, channel states involved in escrow flows can be advanced by any valid newer state. + +External consumers — indexers, SDKs, analytics — **must not treat these events as exhaustive signals**. The canonical, always-guaranteed terminal events for each cross-chain flow (`MigrationOutFinalized`, `EscrowDepositInitiated`, `EscrowWithdrawalFinalized`, etc.) remain reliable. + +The following events are informational: + +| Event | When it may be skipped | +| --- | --- | +| `MigrationInFinalized` | A `MIGRATING_IN` channel transitions to `OPERATING` via any standard operation (deposit, withdraw, checkpoint, challenge) rather than explicit `finalizeMigration()` | +| `MigrationOutInitiated` | A newer `MIGRATING_OUT` signed state is enforced on the old home channel that is the successor of the explicit initiation state | +| `EscrowDepositFinalized` | The non-home channel advances past escrow finalization via a newer signed state | +| `EscrowDepositFinalizedOnHome` | The home channel advances past the escrow finalization acknowledgement via a newer signed state | +| `EscrowWithdrawalInitiatedOnHome` | The home channel advances past the escrow withdrawal initiation acknowledgement via a newer signed state | +| `EscrowWithdrawalFinalizedOnHome` | 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 request establishing the new home chain) and `MigrationOutFinalized` (the unconditional completion signal on the old home chain). Both are guaranteed events. + +--- diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index 42790eeca..ea448cadb 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -62,7 +62,7 @@ contract ChannelHub is ReentrancyGuard { event Deposited(address indexed token, uint256 amount); event Withdrawn(address indexed token, uint256 amount); - event EscrowDepositsPurged(uint256 purgedCount); + event EscrowDepositsPurged(bytes32[] escrowIds, uint256 purgedCount); event ChannelCreated( bytes32 indexed channelId, address indexed user, ChannelDefinition definition, State initialState @@ -76,18 +76,46 @@ contract ChannelHub is ReentrancyGuard { event EscrowDepositInitiated(bytes32 indexed escrowId, bytes32 indexed channelId, State state); event EscrowDepositInitiatedOnHome(bytes32 indexed escrowId, bytes32 indexed channelId, State state); event EscrowDepositChallenged(bytes32 indexed escrowId, State state, uint64 challengeExpireAt); + + /// @dev Informational event: emitted only when finalizeEscrowDeposit() is called on the non-home chain via its + /// dedicated function path. It is not emitted if a newer state is enforced on the channel that supersedes the + /// finalization. External consumers must not treat this as an exhaustive signal. event EscrowDepositFinalized(bytes32 indexed escrowId, bytes32 indexed channelId, State state); + + /// @dev Informational event: emitted only when the home-chain channel advances through the dedicated + /// finalizeEscrowDeposit() path. It is not emitted if the channel state is advanced by a newer signed state that + /// supersedes the escrow finalization. External consumers must not treat this as an exhaustive signal. event EscrowDepositFinalizedOnHome(bytes32 indexed escrowId, bytes32 indexed channelId, State state); event EscrowWithdrawalInitiated(bytes32 indexed escrowId, bytes32 indexed channelId, State state); + + /// @dev Informational event: emitted only when initiateEscrowWithdrawal() advances the home-chain channel via its + /// dedicated function path. It is not emitted if a newer signed state supersedes the initiation on that channel. + /// External consumers must not treat this as an exhaustive signal. event EscrowWithdrawalInitiatedOnHome(bytes32 indexed escrowId, bytes32 indexed channelId, State state); + event EscrowWithdrawalChallenged(bytes32 indexed escrowId, State state, uint64 challengeExpireAt); event EscrowWithdrawalFinalized(bytes32 indexed escrowId, bytes32 indexed channelId, State state); + + /// @dev Informational event: emitted only when finalizeEscrowWithdrawal() advances the home-chain channel via its + /// dedicated function path. It is not emitted if a newer signed state supersedes the finalization on that channel. + /// External consumers must not treat this as an exhaustive signal. event EscrowWithdrawalFinalizedOnHome(bytes32 indexed escrowId, bytes32 indexed channelId, State state); + /// @dev Informational event: emitted only when initiateMigration() is called on the old home chain via its + /// dedicated function path. It is not emitted if a newer signed state (e.g. FINALIZE_MIGRATION) is enforced + /// directly on the channel, bypassing the explicit initiation path. External consumers must not treat this as + /// an exhaustive signal. event MigrationOutInitiated(bytes32 indexed channelId, State state); + event MigrationInInitiated(bytes32 indexed channelId, State state); event MigrationOutFinalized(bytes32 indexed channelId, State state); + + /// @dev Informational event: emitted only when finalizeMigration() is called explicitly on the new home chain. + /// A MIGRATING_IN channel may transition to OPERATING through any standard channel operation (deposit, withdraw, + /// checkpoint, challenge) built on top of a FINALIZE_MIGRATION state, in which case this event is not emitted. + /// External consumers must not treat this as an exhaustive signal; the canonical migration completion signal is + /// MigrationOutFinalized, which is always emitted unconditionally on the old home chain. event MigrationInFinalized(bytes32 indexed channelId, State state); event ValidatorRegistered(uint8 indexed validatorId, ISignatureValidator indexed validator); @@ -164,6 +192,7 @@ contract ChannelHub is ReentrancyGuard { // TODO: estimate these values better uint32 public constant MIN_CHALLENGE_DURATION = 1 days; + uint32 public constant MAX_CHALLENGE_DURATION = 7 days; uint32 public constant ESCROW_DEPOSIT_UNLOCK_DELAY = 3 hours; @@ -337,7 +366,7 @@ contract ChannelHub is ReentrancyGuard { function withdrawFromNode(address to, address token, uint256 amount) external { require(to != address(0), InvalidAddress()); require(amount > 0, IncorrectAmount()); - require(msg.sender == NODE, IncorrectNode()); + require(msg.sender == NODE, IncorrectMsgSender()); uint256 currentBalance = _nodeBalances[token]; require(currentBalance >= amount, InsufficientBalance()); @@ -430,6 +459,10 @@ contract ChannelHub is ReentrancyGuard { uint256 totalDeposits = _escrowDepositIds.length; uint256 escrowHeadTemp = escrowHead; + uint256 remaining = totalDeposits > escrowHeadTemp ? totalDeposits - escrowHeadTemp : 0; + uint256 maxPossible = maxSteps < remaining ? maxSteps : remaining; + bytes32[] memory purgedIds = new bytes32[](maxPossible); + while (escrowHeadTemp < totalDeposits && steps < maxSteps) { bytes32 escrowId = _escrowDepositIds[escrowHeadTemp]; EscrowDepositMeta storage meta = _escrowDeposits[escrowId]; @@ -446,6 +479,7 @@ contract ChannelHub is ReentrancyGuard { meta.status = EscrowStatus.FINALIZED; meta.lockedAmount = 0; + purgedIds[purgedCount] = escrowId; purgedCount++; escrowHeadTemp++; @@ -458,7 +492,11 @@ contract ChannelHub is ReentrancyGuard { escrowHead = escrowHeadTemp; if (purgedCount != 0) { - emit EscrowDepositsPurged(purgedCount); + // Trim the over-allocated memory array to the actual purged count. + assembly { + mstore(purgedIds, purgedCount) + } + emit EscrowDepositsPurged(purgedIds, purgedCount); } } @@ -477,8 +515,12 @@ contract ChannelHub is ReentrancyGuard { /** * @notice Register a signature validator for NODE using signature-based authorization * @dev Anyone can submit this transaction with a valid NODE signature, enabling relayer-friendly registration. - * NODE's private key only signs the registration data, never sends transactions directly. - * This allows NODE to use cold storage or HSMs without exposing keys to transaction submission. + * For this function only, NODE's private key only signs the registration data and does not need to send + * the transaction directly, allowing cold storage or HSM usage without exposing keys to transaction submission. + * Other NODE-gated operations (withdrawFromNode, home-chain initiateEscrowDeposit, and submitting + * a new INITIATE_ESCROW_DEPOSIT state via challengeChannel) still require NODE to be msg.sender + * and are not relayer-compatible. Note: challenging with the same-version INITIATE_ESCROW_DEPOSIT + * state (already-processed path) is open to any caller, as it only sets the DISPUTED flag. * The signature includes block.chainid and address(this) to prevent cross-chain and cross-deployment replay attacks. * @param validatorId The validator ID (0x01-0xFF, 0x00 reserved for DEFAULT) * @param validator The validator contract address @@ -629,8 +671,9 @@ contract ChannelHub is ReentrancyGuard { ChannelEngine.TransitionEffects memory effects = ChannelEngine.validateTransition(ctx, candidate); _applyTransitionEffects(channelId, def, candidate, effects); + } else { + require(msg.value == 0, IncorrectValue()); } - // else: challenging with same version, state already processed (ISignatureValidator validator, bytes calldata sigData) = _extractValidator(challengerSig, approvedSignatureValidators); @@ -696,6 +739,7 @@ contract ChannelHub is ReentrancyGuard { if (_isChannelHomeChain(channelId)) { require(msg.sender == NODE, IncorrectMsgSender()); + require(msg.value == 0, IncorrectValue()); _processHomeChainEscrow(channelId, candidate); emit EscrowDepositInitiatedOnHome(escrowId, channelId, candidate); } else { @@ -1145,17 +1189,19 @@ contract ChannelHub is ReentrancyGuard { ChannelEngine.TransitionEffects memory effects ) internal { ChannelMeta storage meta = _channels[channelId]; + address token = candidate.homeLedger.token; meta.lastState = candidate; address user = def.user; - address token = candidate.homeLedger.token; // Process POSITIVE deltas first (additions to lockedFunds) to prevent underflow if (effects.userFundsDelta > 0) { uint256 amount = effects.userFundsDelta.toUint256(); _pullFunds(user, token, amount); meta.lockedFunds += amount; + } else { + require(msg.value == 0, IncorrectValue()); } if (effects.nodeFundsDelta > 0) { @@ -1196,6 +1242,7 @@ contract ChannelHub is ReentrancyGuard { uint256 approvedSignatureValidators ) internal { EscrowDepositMeta storage meta = _escrowDeposits[escrowId]; + address token = effects.updateInitState ? candidate.nonHomeLedger.token : meta.initState.nonHomeLedger.token; if (effects.newStatus != EscrowStatus.VOID) { meta.status = effects.newStatus; @@ -1209,22 +1256,22 @@ contract ChannelHub is ReentrancyGuard { meta.unlockAt = effects.newUnlockAt; } - if (effects.newChallengeExpiry > 0) { + if (meta.challengeExpireAt != effects.newChallengeExpiry) { meta.challengeExpireAt = effects.newChallengeExpiry; } - // Determine the correct token to use (from init state for finalization, from candidate for initiation) - address token = effects.updateInitState ? candidate.nonHomeLedger.token : meta.initState.nonHomeLedger.token; - // Handle user funds (positive = pull from user) if (effects.userFundsDelta > 0) { uint256 amount = effects.userFundsDelta.toUint256(); _pullFunds(user, token, amount); meta.lockedAmount += amount; - } else if (effects.userFundsDelta < 0) { - uint256 amount = (-effects.userFundsDelta).toUint256(); - _nonRevertingPushFunds(user, token, amount); - meta.lockedAmount -= amount; + } else { + require(msg.value == 0, IncorrectValue()); + if (effects.userFundsDelta < 0) { + uint256 amount = (-effects.userFundsDelta).toUint256(); + _nonRevertingPushFunds(user, token, amount); + meta.lockedAmount -= amount; + } } // Handle node funds (positive = pull from node vault, negative = release to vault) @@ -1264,7 +1311,7 @@ contract ChannelHub is ReentrancyGuard { _initEscrowWithdrawalMetadata(escrowId, channelId, candidate, user, approvedSignatureValidators); } - if (effects.newChallengeExpiry > 0) { + if (meta.challengeExpireAt != effects.newChallengeExpiry) { meta.challengeExpireAt = effects.newChallengeExpiry; } @@ -1335,7 +1382,10 @@ contract ChannelHub is ReentrancyGuard { require(user != address(0), InvalidAddress()); require(def.node == NODE, IncorrectNode()); require(user != NODE, AddressCollision(user)); - require(def.challengeDuration >= MIN_CHALLENGE_DURATION, IncorrectChallengeDuration()); + require( + def.challengeDuration >= MIN_CHALLENGE_DURATION && def.challengeDuration <= MAX_CHALLENGE_DURATION, + IncorrectChallengeDuration() + ); } /// @dev Returns true when the channel is active and considers the current chain its home chain. @@ -1370,14 +1420,19 @@ contract ChannelHub is ReentrancyGuard { return _escrowWithdrawals[escrowId].channelId == bytes32(0) && _isChannelHomeChain(channelId); } - function _pullFunds(address from, address token, uint256 amount) internal nonReentrant { - if (amount == 0) return; - + /// @dev native token: requires msg.value == amount; ERC20: requires msg.value == 0. + function _requireMsgValueForPull(address token, uint256 amount) internal view { if (token == address(0)) { require(msg.value == amount, IncorrectValue()); } else { require(msg.value == 0, IncorrectValue()); } + } + + function _pullFunds(address from, address token, uint256 amount) internal nonReentrant { + if (amount == 0) return; + + _requireMsgValueForPull(token, amount); if (token != address(0)) { IERC20(token).safeTransferFrom(from, address(this), amount); @@ -1425,16 +1480,27 @@ contract ChannelHub is ReentrancyGuard { /// - explicit return value: decoded as uint256, non-zero treated as success. /// Decoding as uint256 (not bool) avoids an abi.decode revert on non-canonical bool encodings /// (e.g. a token returning 2), which would otherwise break _nonRevertingPushFunds' no-revert guarantee. + /// Uses assembly to write the call output directly to scratch space (0x00-0x1f). + /// That range is already within the memory expanded by the preceding abi.encodeCall(), so no + /// additional memory expansion occurs regardless of actual returndata size. + /// The high-level `(bool, bytes memory) = addr.call(...)` form copies ALL returndata into caller memory + /// at caller-gas expense — a malicious token returning 1 MB would exhaust gas before we reach the length check. function _trySafeTransfer(address token, address to, uint256 amount) internal returns (bool) { - (bool success, bytes memory returnData) = - address(token).call{gas: TRANSFER_GAS_LIMIT}(abi.encodeCall(IERC20.transfer, (to, amount))); + bytes memory callData = abi.encodeCall(IERC20.transfer, (to, amount)); + bool success; + uint256 rdsize; + uint256 retval; + + assembly ("memory-safe") { + // Output to scratch space (0x00-0x1f); returndatasize() still reflects the full returndata length. + success := call(TRANSFER_GAS_LIMIT, token, 0, add(callData, 0x20), mload(callData), 0x00, 0x20) + rdsize := returndatasize() + retval := mload(0x00) + } if (!success) return false; - if (returnData.length == 0) return address(token).code.length > 0; - // Solidity 0.8's ABI decoder validates canonical bool encoding (only 0 or 1 are valid). - // A token returning any other non-zero value (e.g. 2, 0xff...ff) would cause abi.decode(..., (bool)) to revert - // — propagating out of _nonRevertingPushFunds and breaking its invariant - if (returnData.length >= 32) return abi.decode(returnData, (uint256)) != 0; - return false; + if (rdsize == 0) return address(token).code.length > 0; + if (rdsize < 32) return false; + return retval != 0; } } diff --git a/contracts/src/Utils.sol b/contracts/src/Utils.sol index ca27b8659..dbfd4cfc7 100644 --- a/contracts/src/Utils.sol +++ b/contracts/src/Utils.sol @@ -17,11 +17,12 @@ library Utils { error FailedToFetchDecimals(); function getChannelId(ChannelDefinition memory def, uint8 version) internal pure returns (bytes32 channelId) { - bytes32 baseId = keccak256(abi.encode(def)); - assembly ("memory-safe") { + // ChannelDefinition has 6 static fields × 32 bytes = 192 (0xC0) bytes in memory. + // Memory layout is identical to abi.encode for structs with only value types, so we + // hash the struct pointer directly, avoiding the abi.encode allocation. + let baseId := keccak256(def, 0xC0) // Store the version in the first byte (most significant byte) of the channelId - // Clear the first byte of baseId, then set it to version channelId := or( and(baseId, 0x00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff), shl(248, version) @@ -29,9 +30,17 @@ library Utils { } } - function getEscrowId(bytes32 channelId, uint64 version) internal pure returns (bytes32) { + function getEscrowId(bytes32 channelId, uint64 version) internal pure returns (bytes32 escrowId) { // "channelId, (state-)version" pair is unique as long as participants do not reuse versions - return keccak256(abi.encode(channelId, version)); + // Uses the 64-byte scratch space (0x00–0x3f) to avoid a heap allocation. + // Mask version to 64 bits: mstore writes the full 256-bit stack word, and Solidity does + // not guarantee that internal call arguments are cleaned for sub-256-bit types. Without + // the mask, dirty high bits would produce a hash that diverges from abi.encode(channelId, version). + assembly ("memory-safe") { + mstore(0x00, channelId) + mstore(0x20, and(version, 0xffffffffffffffff)) + escrowId := keccak256(0x00, 0x40) + } } function getValidatorRegistrationMessage(address channelHub, uint8 validatorId, address validator) @@ -71,7 +80,10 @@ library Utils { /** * @notice Validates that the ledger's decimals match the token contract's decimals - * @dev Only validates if on the same chain as the ledger + * @dev Only validates if on the same chain as the ledger. + * The token must implement IERC20Metadata.decimals(). If the call reverts, + * this function reverts with FailedToFetchDecimals. ERC-20 tokens that omit + * the optional decimals() method cannot be used in any protocol operation. * @param ledger The ledger to validate */ function validateTokenDecimals(Ledger memory ledger) internal view { diff --git a/contracts/src/interfaces/Types.sol b/contracts/src/interfaces/Types.sol index 364411e6d..314230b6e 100644 --- a/contracts/src/interfaces/Types.sol +++ b/contracts/src/interfaces/Types.sol @@ -8,6 +8,9 @@ enum ParticipantIndex { NODE } +// INVARIANT: Utils.getChannelId hashes the raw memory layout of this struct using the +// hardcoded size 0xC0 (6 fields × 32 bytes). Adding or removing fields requires updating +// that constant; failing to do so silently produces wrong channelIds. struct ChannelDefinition { uint32 challengeDuration; address user; diff --git a/contracts/test/ChannelHub_Node.t.sol b/contracts/test/ChannelHub_Node.t.sol index 9930e5088..a095c9e0d 100644 --- a/contracts/test/ChannelHub_Node.t.sol +++ b/contracts/test/ChannelHub_Node.t.sol @@ -24,6 +24,12 @@ contract ChannelHubTest_withdrawFromNode is ChannelHubTest_Base { // ========== Input Validation ========== + function test_reverts_whenCallerIsNotNode() public { + vm.prank(alice); + vm.expectRevert(ChannelHub.IncorrectMsgSender.selector); + cHub.withdrawFromNode(recipient, address(token), DEPOSIT_AMOUNT); + } + function test_reverts_whenToIsZeroAddress() public { vm.prank(node); vm.expectRevert(ChannelHub.InvalidAddress.selector); diff --git a/contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol b/contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol index 0c845a623..4ccbedcba 100644 --- a/contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol +++ b/contracts/test/ChannelHub_challenge/ChannelHub_challengeSessionKeyValidator.t.sol @@ -25,6 +25,7 @@ abstract contract ChannelHubTest_Challenge_SkApproved_Base is ChannelHubTest_Cha user: alice, node: node, nonce: NONCE, + // forge-lint: disable-next-line(incorrect-shift) -- intentional bitmask: 1 << N sets bit N approvedSignatureValidators: 1 << SESSION_KEY_VALIDATOR_ID, metadata: bytes32(0) }); diff --git a/contracts/test/ChannelHub_escrowDepositPurge/ChannelHub_purgeEscrowDeposits.t.sol b/contracts/test/ChannelHub_escrowDepositPurge/ChannelHub_purgeEscrowDeposits.t.sol index 0e813d788..701a8d101 100644 --- a/contracts/test/ChannelHub_escrowDepositPurge/ChannelHub_purgeEscrowDeposits.t.sol +++ b/contracts/test/ChannelHub_escrowDepositPurge/ChannelHub_purgeEscrowDeposits.t.sol @@ -56,10 +56,13 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge function test_purges_whenSingleInitialized_unlockable() public { bytes32 id = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id; + vm.expectEmit(true, true, true, true); emit ChannelHub.NodeBalanceUpdated(address(token), LOCKED_AMOUNT); vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); _purge(type(uint256).max); @@ -93,8 +96,11 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge _addDisputed(uint64(block.timestamp) + 1 days); bytes32 id = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); _purge(type(uint256).max); @@ -107,8 +113,11 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge _addFinalized(); bytes32 id = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); _purge(type(uint256).max); @@ -134,12 +143,17 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge uint256 amount2 = 200; uint256 amount3 = 300; - _addUnlockable(amount1); - _addUnlockable(amount2); - _addUnlockable(amount3); + bytes32 id1 = _addUnlockable(amount1); + bytes32 id2 = _addUnlockable(amount2); + bytes32 id3 = _addUnlockable(amount3); + + bytes32[] memory expectedIds = new bytes32[](3); + expectedIds[0] = id1; + expectedIds[1] = id2; + expectedIds[2] = id3; vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(3); + emit ChannelHub.EscrowDepositsPurged(expectedIds, 3); _purge(type(uint256).max); @@ -155,8 +169,12 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge bytes32 id2 = _addUnlockable(LOCKED_AMOUNT); bytes32 id3 = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](2); + expectedIds[0] = id1; + expectedIds[1] = id2; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(2); + emit ChannelHub.EscrowDepositsPurged(expectedIds, 2); _purge(2); @@ -182,8 +200,11 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge _addDisputed(uint64(block.timestamp) + 1 days); bytes32 id = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); // purgedCount=1, not steps=2 + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); // purgedCount=1, not steps=2 _purge(2); @@ -207,8 +228,11 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge _addFinalized(); bytes32 id = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); // purgedCount=1, not steps=2 + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); // purgedCount=1, not steps=2 _purge(2); @@ -234,8 +258,11 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge bytes32 id1 = _addUnlockable(LOCKED_AMOUNT); bytes32 id2 = _addUnlockable(LOCKED_AMOUNT); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id1; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); // purgedCount=1, not steps=2 + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); // purgedCount=1, not steps=2 _purge(2); @@ -253,8 +280,11 @@ contract ChannelHubTest_purgeEscrowDeposits is ChannelHubTest_EscrowDepositPurge bytes32 id3 = _addUnlockable(LOCKED_AMOUNT); bytes32 id4 = _addNotYetUnlockable(LOCKED_AMOUNT * 2); + bytes32[] memory expectedIds = new bytes32[](1); + expectedIds[0] = id3; + vm.expectEmit(true, true, true, true); - emit ChannelHub.EscrowDepositsPurged(1); + emit ChannelHub.EscrowDepositsPurged(expectedIds, 1); _purge(type(uint256).max); diff --git a/contracts/test/ChannelHub_lifecycle/ChannelHub_singlechain.lifecycle.t.sol b/contracts/test/ChannelHub_lifecycle/ChannelHub_singlechain.lifecycle.t.sol index 5f3eaefec..8650310e7 100644 --- a/contracts/test/ChannelHub_lifecycle/ChannelHub_singlechain.lifecycle.t.sol +++ b/contracts/test/ChannelHub_lifecycle/ChannelHub_singlechain.lifecycle.t.sol @@ -11,6 +11,7 @@ import {TestUtils, SESSION_KEY_VALIDATOR_ID} from "../TestUtils.sol"; contract ChannelHubTest_SingleChain_Lifecycle is ChannelHubTest_Base { function test_happyPath() public { // Approve SessionKeyValidator (ID 1) for user signatures by setting bit 1 + // forge-lint: disable-next-line(incorrect-shift) -- intentional bitmask: 1 << N sets bit N uint256 approvedValidators = 1 << SESSION_KEY_VALIDATOR_ID; // Bit 1 = 1 ChannelDefinition memory def = ChannelDefinition({ diff --git a/contracts/test/ChannelHub_nonRevertingPushFunds.t.sol b/contracts/test/ChannelHub_nonRevertingPushFunds.t.sol index 6bd6af367..3d8296c4b 100644 --- a/contracts/test/ChannelHub_nonRevertingPushFunds.t.sol +++ b/contracts/test/ChannelHub_nonRevertingPushFunds.t.sol @@ -10,6 +10,7 @@ import {RevertingERC20} from "./mocks/RevertingERC20.sol"; import {GasConsumingERC20} from "./mocks/GasConsumingERC20.sol"; import {MalformedReturningERC20} from "./mocks/MalformedReturningERC20.sol"; import {DonatingERC20} from "./mocks/DonatingERC20.sol"; +import {OversizedReturnERC20} from "./mocks/OversizedReturnERC20.sol"; import {RevertingEthReceiver} from "./mocks/RevertingEthReceiver.sol"; import {GasConsumingEthReceiver} from "./mocks/GasConsumingEthReceiver.sol"; @@ -173,6 +174,90 @@ contract ChannelHubTest_nonRevertingPushFunds is Test { _verifyBalancesNotChanged(recipient, address(malformedToken), TRANSFER_AMOUNT); } + // ========== Oversized Return Data ERC20 Tests ========== + + // Regression pin for the SC-L01 audit finding. + // The existing oversized-data tests verify behavioural correctness but still pass if the old + // high-level `(bool, bytes memory) = addr.call(...)` form is restored, because Foundry's default + // gas budget is far too large to trigger OOG. This test calls with a tight external gas cap so + // that the old copy path runs out of gas while the assembly fix passes comfortably. + // + // Budget breakdown (Berlin EVM, 80 000 gas forwarded to exposed_nonRevertingPushFunds): + // function overhead + nonReentrant SSTORE + abi.encodeCall : ~11 000 gas + // 63/64 rule → forwarded to token : 67 922 gas + // token execution (100 KB memory expansion, no SSTORE) : ~28 500 gas (returned: 39 422) + // caller gas after CALL (kept 1/64 + returned) : ~40 500 gas + // ── new code post-call (cold reclaim SSTORE + emit) : ~26 250 → 14 250 spare → PASSES + // ── old code extra (memory expand + RETURNDATACOPY 100 KB) : ~38 072 → needs 64 322 → OOGs + function test_doesNotOOG_withTightGas_whenERC20ReturnsLargeData() public { + OversizedReturnERC20 largeToken = new OversizedReturnERC20(100_000, 0); + largeToken.mint(address(cHub), BALANCE_AMOUNT); + + (bool ok,) = address(cHub).call{gas: 80_000}( + abi.encodeCall( + TestChannelHub.exposed_nonRevertingPushFunds, (recipient, address(largeToken), TRANSFER_AMOUNT) + ) + ); + + assertTrue(ok, "assembly path must not OOG: old high-level copy costs ~38k extra gas"); + assertEq(cHub.getReclaimBalance(recipient, address(largeToken)), TRANSFER_AMOUNT, "reclaim must be written"); + } + + // Covers the rdsize > 32, retval != 0 branch of _trySafeTransfer. + // With the old high-level (bool, bytes memory) call form, a token returning a large buffer + // would exhaust caller gas during returndata copy before we could inspect the length. + // The assembly fix caps the copy to 32 bytes, so the first word is read and the transfer succeeds. + function test_succeeds_whenERC20ReturnsOversizedDataWithNonzeroValue() public { + OversizedReturnERC20 oversizedToken = new OversizedReturnERC20(10_240, 1); + oversizedToken.mint(address(cHub), BALANCE_AMOUNT); + + cHub.exposed_nonRevertingPushFunds(recipient, address(oversizedToken), TRANSFER_AMOUNT); + + _verifyTransferSuccess(recipient, address(oversizedToken), TRANSFER_AMOUNT); + } + + // Covers the rdsize > 32, retval == 0 branch of _trySafeTransfer. + // Oversized returndata with a zero first word must be treated as failure and accumulate reclaims, + // not revert. + function test_accumulatesReclaims_whenERC20ReturnsOversizedDataWithZeroValue() public { + OversizedReturnERC20 oversizedToken = new OversizedReturnERC20(10_240, 0); + oversizedToken.mint(address(cHub), BALANCE_AMOUNT); + + vm.expectEmit(true, true, false, true); + emit ChannelHub.TransferFailed(recipient, address(oversizedToken), TRANSFER_AMOUNT); + + cHub.exposed_nonRevertingPushFunds(recipient, address(oversizedToken), TRANSFER_AMOUNT); + + _verifyBalancesNotChanged(recipient, address(oversizedToken), TRANSFER_AMOUNT); + } + + // Covers the rdsize ∈ [1, 31] branch of _trySafeTransfer. + // Any response shorter than 32 bytes is rejected as failure regardless of content — + // a valid ERC20 bool is always a full 32-byte ABI word. + function test_accumulatesReclaims_whenERC20ReturnsShortData() public { + OversizedReturnERC20 shortToken = new OversizedReturnERC20(16, 0); + shortToken.mint(address(cHub), BALANCE_AMOUNT); + + vm.expectEmit(true, true, false, true); + emit ChannelHub.TransferFailed(recipient, address(shortToken), TRANSFER_AMOUNT); + + cHub.exposed_nonRevertingPushFunds(recipient, address(shortToken), TRANSFER_AMOUNT); + + _verifyBalancesNotChanged(recipient, address(shortToken), TRANSFER_AMOUNT); + } + + // Covers rdsize == 32 with a non-canonical bool value (2). + // abi.decode(..., (bool)) reverts on value 2 — Solidity 0.8 only accepts 0 or 1. + // Decoding as uint256 and checking != 0 handles this correctly without reverting. + function test_succeeds_whenERC20ReturnsNonCanonicalBoolValue() public { + OversizedReturnERC20 nonCanonicalToken = new OversizedReturnERC20(32, 2); + nonCanonicalToken.mint(address(cHub), BALANCE_AMOUNT); + + cHub.exposed_nonRevertingPushFunds(recipient, address(nonCanonicalToken), TRANSFER_AMOUNT); + + _verifyTransferSuccess(recipient, address(nonCanonicalToken), TRANSFER_AMOUNT); + } + // ========== ERC777 Donation-Back Tests ========== function test_succeeds_whenERC777DonatesBack() public { diff --git a/contracts/test/ChannelHub_sigValidator.t.sol b/contracts/test/ChannelHub_sigValidator.t.sol index 87fa91900..389c49106 100644 --- a/contracts/test/ChannelHub_sigValidator.t.sol +++ b/contracts/test/ChannelHub_sigValidator.t.sol @@ -102,6 +102,7 @@ contract ChannelHubTest_RegisterNodeValidator is ChannelHubTest_SigValidator_Bas contract ChannelHubTest_ExtractValidator is ChannelHubTest_SigValidator_Base { /// @dev approvedSignatureValidators bitmask with bit `id` set. function _approved(uint8 id) internal pure returns (uint256) { + // forge-lint: disable-next-line(incorrect-shift) -- intentional bitmask: 1 << N sets bit N return 1 << id; } diff --git a/contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol b/contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol index 727d7b559..f6f43b3d1 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_challenge.t.sol @@ -101,6 +101,88 @@ contract ChannelHubTest_challenge is ChannelHubTest_Base { cHub.challengeChannel(channelId, state, challengerSig, ParticipantIndex.USER); } + // ========== Payable ========== + + function test_revert_ifETHSent_sameVersionChallenge() public { + bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(channelId, initState, NODE_PK); + + vm.deal(node, 1); + vm.expectRevert(ChannelHub.IncorrectValue.selector); + vm.prank(node); + cHub.challengeChannel{value: 1}(channelId, initState, challengerSig, ParticipantIndex.NODE); + } + + function test_revert_challengeChannel_initiateEscrowDepositIntent_ifETHSent() public { + bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(channelId, escrowState, NODE_PK); + + vm.deal(node, 1); + vm.expectRevert(ChannelHub.IncorrectValue.selector); + vm.prank(node); + cHub.challengeChannel{value: 1}(channelId, escrowState, challengerSig, ParticipantIndex.NODE); + } + + function test_nativeDepositChallenge_acceptsExactETH() public { + uint256 depositDelta = 100; + ChannelDefinition memory nativeDef = ChannelDefinition({ + challengeDuration: CHALLENGE_DURATION, + user: alice, + node: node, + nonce: NONCE + 1, + approvedSignatureValidators: 0, + metadata: bytes32(0) + }); + bytes32 nativeChannelId = Utils.getChannelId(nativeDef, CHANNEL_HUB_VERSION); + + State memory nativeInitState = State({ + version: 0, + intent: StateIntent.DEPOSIT, + metadata: bytes32(0), + homeLedger: Ledger({ + chainId: uint64(block.chainid), + token: address(0), + decimals: 18, + userAllocation: DEPOSIT_AMOUNT, + userNetFlow: int256(DEPOSIT_AMOUNT), + nodeAllocation: 0, + nodeNetFlow: 0 + }), + nonHomeLedger: TestUtils.emptyLedger(), + userSig: "", + nodeSig: "" + }); + nativeInitState = mutualSignStateBothWithEcdsaValidator(nativeInitState, nativeChannelId, ALICE_PK); + + vm.deal(alice, DEPOSIT_AMOUNT); + vm.prank(alice); + cHub.createChannel{value: DEPOSIT_AMOUNT}(nativeDef, nativeInitState); + + State memory depositState = TestUtils.nextState( + nativeInitState, + StateIntent.DEPOSIT, + [uint256(DEPOSIT_AMOUNT + depositDelta), uint256(0)], + [int256(DEPOSIT_AMOUNT + depositDelta), int256(0)] + ); + depositState = mutualSignStateBothWithEcdsaValidator(depositState, nativeChannelId, ALICE_PK); + bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(nativeChannelId, depositState, NODE_PK); + + uint256 hubBalanceBefore = address(cHub).balance; + vm.deal(node, depositDelta); + vm.prank(node); + cHub.challengeChannel{value: depositDelta}(nativeChannelId, depositState, challengerSig, ParticipantIndex.NODE); + + (ChannelStatus status,, State memory latestState, uint256 challengeExpiry,) = + cHub.getChannelData(nativeChannelId); + assertEq(uint8(status), uint8(ChannelStatus.DISPUTED), "Channel should be DISPUTED"); + assertEq(latestState.version, 1, "Native deposit state should be enforced"); + assertEq( + latestState.homeLedger.userAllocation, + DEPOSIT_AMOUNT + depositDelta, + "Native allocation should include challenge deposit" + ); + assertEq(challengeExpiry, block.timestamp + CHALLENGE_DURATION, "Challenge expiry should be set"); + assertEq(address(cHub).balance, hubBalanceBefore + depositDelta, "Native ETH should be pulled"); + } + // ========== INITIATE_ESCROW_DEPOSIT caller restriction ========== function test_revert_initiateEscrowDeposit_homeChain_callerNotNode() public { diff --git a/contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol b/contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol index 1539f5255..7ae773ac9 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_createChannel.t.sol @@ -105,6 +105,23 @@ contract ChannelHubTest_createChannel is ChannelHubTest_Base { ); } + function test_createChannel_allowsMaxChallengeDuration() public { + def.challengeDuration = cHub.MAX_CHALLENGE_DURATION(); + bytes32 maxDurationChannelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION); + State memory state = mutualSignStateBothWithEcdsaValidator(initialDepositState, maxDurationChannelId, ALICE_PK); + + vm.prank(alice); + cHub.createChannel(def, state); + + verifyChannelData( + maxDurationChannelId, + ChannelStatus.OPERATING, + 0, + 0, + "Channel status should be OPERATING when challenge duration is the contract max" + ); + } + // ========== Revert: createChannel on existing channel ========== function _createDefaultChannel() internal { @@ -167,6 +184,28 @@ contract ChannelHubTest_createChannel is ChannelHubTest_Base { cHub.createChannel(def, state); } + // ========== Challenge duration ========== + + function test_revert_ifChallengeDurationAboveMax() public { + def.challengeDuration = cHub.MAX_CHALLENGE_DURATION() + 1; + bytes32 tooLongChannelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION); + State memory state = mutualSignStateBothWithEcdsaValidator(initialDepositState, tooLongChannelId, ALICE_PK); + + vm.prank(alice); + vm.expectRevert(ChannelHub.IncorrectChallengeDuration.selector); + cHub.createChannel(def, state); + } + + function test_revert_ifChallengeDurationBelowMin() public { + def.challengeDuration = cHub.MIN_CHALLENGE_DURATION() - 1; + bytes32 tooShortChannelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION); + State memory state = mutualSignStateBothWithEcdsaValidator(initialDepositState, tooShortChannelId, ALICE_PK); + + vm.prank(alice); + vm.expectRevert(ChannelHub.IncorrectChallengeDuration.selector); + cHub.createChannel(def, state); + } + // ========== Payable ========== // createChannel is payable to support native ETH deposits (DEPOSIT intent). diff --git a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol index 57338a52d..36425e1a0 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol @@ -6,7 +6,14 @@ import {TestUtils} from "../TestUtils.sol"; import {Utils} from "../../src/Utils.sol"; import {ChannelHub} from "../../src/ChannelHub.sol"; -import {State, ChannelDefinition, StateIntent, Ledger} from "../../src/interfaces/Types.sol"; +import { + State, + ChannelDefinition, + StateIntent, + Ledger, + EscrowStatus, + ParticipantIndex +} from "../../src/interfaces/Types.sol"; // forge-lint: disable-next-item(unsafe-typecast) contract ChannelHubTest_finalizeEscrowDeposit is ChannelHubTest_Base { @@ -64,6 +71,88 @@ contract ChannelHubTest_finalizeEscrowDeposit is ChannelHubTest_Base { cHub.finalizeEscrowDeposit(channelId, bytes32(0), state); } + // ========== Challenge Expiry Clearing ========== + + // Regression test: cooperative finalization from DISPUTED must zero out challengeExpireAt. + // Before the fix, _applyEscrowDepositEffects used `if (effects.newChallengeExpiry > 0)` which + // skipped the write when the finalize effects left newChallengeExpiry at 0, leaving a stale + // non-zero value observable via getEscrowDepositData(). + function test_cooperativeFinalize_fromDISPUTED_clearsChallengeExpiry() public { + ChannelDefinition memory altDef = ChannelDefinition({ + challengeDuration: CHALLENGE_DURATION, + user: alice, + node: node, + nonce: NONCE + 1, + approvedSignatureValidators: 0, + metadata: bytes32(0) + }); + bytes32 altChannelId = Utils.getChannelId(altDef, CHANNEL_HUB_VERSION); + + // Current chain acts as non-home chain; NON_HOME_CHAIN_ID is the home chain. + State memory escrowInitState = State({ + version: 1, + intent: StateIntent.INITIATE_ESCROW_DEPOSIT, + metadata: bytes32(0), + homeLedger: Ledger({ + chainId: NON_HOME_CHAIN_ID, + token: NON_HOME_TOKEN, + decimals: 18, + userAllocation: DEPOSIT_AMOUNT, + userNetFlow: int256(DEPOSIT_AMOUNT), + nodeAllocation: ESCROW_AMOUNT, + nodeNetFlow: int256(ESCROW_AMOUNT) + }), + nonHomeLedger: Ledger({ + chainId: uint64(block.chainid), + token: address(token), + decimals: 18, + userAllocation: ESCROW_AMOUNT, + userNetFlow: int256(ESCROW_AMOUNT), + nodeAllocation: 0, + nodeNetFlow: 0 + }), + userSig: "", + nodeSig: "" + }); + escrowInitState = mutualSignStateBothWithEcdsaValidator(escrowInitState, altChannelId, ALICE_PK); + + vm.prank(alice); + cHub.initiateEscrowDeposit(altDef, escrowInitState); + bytes32 escrowId = Utils.getEscrowId(altChannelId, escrowInitState.version); + + // Challenge → status becomes DISPUTED, challengeExpireAt set to a future timestamp. + bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(altChannelId, escrowInitState, ALICE_PK); + cHub.challengeEscrowDeposit(escrowId, challengerSig, ParticipantIndex.USER); + + (, EscrowStatus disputedStatus,, uint64 challengeExpiryAfterChallenge,,) = cHub.getEscrowDepositData(escrowId); + assertEq(uint8(disputedStatus), uint8(EscrowStatus.DISPUTED), "Should be DISPUTED after challenge"); + assertGt(challengeExpiryAfterChallenge, 0, "challengeExpireAt should be non-zero after challenge"); + + // Cooperatively finalize before the challenge period expires. + // Home userNetFlow must be unchanged (delta == 0), home userAllocation grows by ESCROW_AMOUNT. + State memory finalizeState = TestUtils.nextState( + escrowInitState, + StateIntent.FINALIZE_ESCROW_DEPOSIT, + [DEPOSIT_AMOUNT + ESCROW_AMOUNT, uint256(0)], + [int256(DEPOSIT_AMOUNT), int256(ESCROW_AMOUNT)], + uint64(block.chainid), + address(token), + [uint256(0), uint256(0)], + [int256(ESCROW_AMOUNT), -int256(ESCROW_AMOUNT)] + ); + finalizeState = mutualSignStateBothWithEcdsaValidator(finalizeState, altChannelId, ALICE_PK); + cHub.finalizeEscrowDeposit(altChannelId, escrowId, finalizeState); + + // Assert: status FINALIZED and challengeExpireAt cleared to zero. + (, EscrowStatus finalStatus,, uint64 finalChallengeExpiry,,) = cHub.getEscrowDepositData(escrowId); + assertEq( + uint8(finalStatus), uint8(EscrowStatus.FINALIZED), "Should be FINALIZED after cooperative finalization" + ); + assertEq( + finalChallengeExpiry, 0, "challengeExpireAt must be cleared after cooperative finalization from DISPUTED" + ); + } + function test_revert_nonHomeChain_ifWrongIntent() public { // Use a different nonce so this channel does not exist on the current chain (non-home path). ChannelDefinition memory altDef = ChannelDefinition({ diff --git a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol index 063993a22..4d31414cf 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol @@ -6,7 +6,14 @@ import {TestUtils} from "../TestUtils.sol"; import {Utils} from "../../src/Utils.sol"; import {ChannelHub} from "../../src/ChannelHub.sol"; -import {State, ChannelDefinition, StateIntent, Ledger} from "../../src/interfaces/Types.sol"; +import { + State, + ChannelDefinition, + StateIntent, + Ledger, + EscrowStatus, + ParticipantIndex +} from "../../src/interfaces/Types.sol"; // forge-lint: disable-next-item(unsafe-typecast) contract ChannelHubTest_finalizeEscrowWithdrawal is ChannelHubTest_Base { @@ -64,6 +71,88 @@ contract ChannelHubTest_finalizeEscrowWithdrawal is ChannelHubTest_Base { cHub.finalizeEscrowWithdrawal(channelId, bytes32(0), state); } + // ========== Challenge Expiry Clearing ========== + + // Regression test: cooperative finalization from DISPUTED must zero out challengeExpireAt. + // Before the fix, _applyEscrowWithdrawalEffects used `if (effects.newChallengeExpiry > 0)` which + // skipped the write when the finalize effects left newChallengeExpiry at 0, leaving a stale + // non-zero value observable via getEscrowWithdrawalData(). + function test_cooperativeFinalize_fromDISPUTED_clearsChallengeExpiry() public { + ChannelDefinition memory altDef = ChannelDefinition({ + challengeDuration: CHALLENGE_DURATION, + user: alice, + node: node, + nonce: NONCE + 1, + approvedSignatureValidators: 0, + metadata: bytes32(0) + }); + bytes32 altChannelId = Utils.getChannelId(altDef, CHANNEL_HUB_VERSION); + + // Current chain acts as non-home chain; NON_HOME_CHAIN_ID is the home chain. + // Node locks WITHDRAWAL_AMOUNT from its vault on this (non-home) chain. + State memory escrowInitState = State({ + version: 1, + intent: StateIntent.INITIATE_ESCROW_WITHDRAWAL, + metadata: bytes32(0), + homeLedger: Ledger({ + chainId: NON_HOME_CHAIN_ID, + token: NON_HOME_TOKEN, + decimals: 18, + userAllocation: WITHDRAWAL_AMOUNT, + userNetFlow: int256(WITHDRAWAL_AMOUNT), + nodeAllocation: 0, + nodeNetFlow: 0 + }), + nonHomeLedger: Ledger({ + chainId: uint64(block.chainid), + token: address(token), + decimals: 18, + userAllocation: 0, + userNetFlow: 0, + nodeAllocation: WITHDRAWAL_AMOUNT, + nodeNetFlow: int256(WITHDRAWAL_AMOUNT) + }), + userSig: "", + nodeSig: "" + }); + escrowInitState = mutualSignStateBothWithEcdsaValidator(escrowInitState, altChannelId, ALICE_PK); + + cHub.initiateEscrowWithdrawal(altDef, escrowInitState); + bytes32 escrowId = Utils.getEscrowId(altChannelId, escrowInitState.version); + + // Challenge → status becomes DISPUTED, challengeExpireAt set to a future timestamp. + bytes memory challengerSig = signChallengeEip191WithEcdsaValidator(altChannelId, escrowInitState, ALICE_PK); + cHub.challengeEscrowWithdrawal(escrowId, challengerSig, ParticipantIndex.USER); + + (, EscrowStatus disputedStatus, uint64 challengeExpiryAfterChallenge,,) = cHub.getEscrowWithdrawalData(escrowId); + assertEq(uint8(disputedStatus), uint8(EscrowStatus.DISPUTED), "Should be DISPUTED after challenge"); + assertGt(challengeExpiryAfterChallenge, 0, "challengeExpireAt should be non-zero after challenge"); + + // Cooperatively finalize before the challenge period expires. + // User allocation on home decreases by WITHDRAWAL_AMOUNT; node releases locked funds to user. + State memory finalizeState = TestUtils.nextState( + escrowInitState, + StateIntent.FINALIZE_ESCROW_WITHDRAWAL, + [uint256(0), uint256(0)], + [int256(WITHDRAWAL_AMOUNT), -int256(WITHDRAWAL_AMOUNT)], + uint64(block.chainid), + address(token), + [uint256(0), uint256(0)], + [-int256(WITHDRAWAL_AMOUNT), int256(WITHDRAWAL_AMOUNT)] + ); + finalizeState = mutualSignStateBothWithEcdsaValidator(finalizeState, altChannelId, ALICE_PK); + cHub.finalizeEscrowWithdrawal(altChannelId, escrowId, finalizeState); + + // Assert: status FINALIZED and challengeExpireAt cleared to zero. + (, EscrowStatus finalStatus, uint64 finalChallengeExpiry,,) = cHub.getEscrowWithdrawalData(escrowId); + assertEq( + uint8(finalStatus), uint8(EscrowStatus.FINALIZED), "Should be FINALIZED after cooperative finalization" + ); + assertEq( + finalChallengeExpiry, 0, "challengeExpireAt must be cleared after cooperative finalization from DISPUTED" + ); + } + function test_revert_nonHomeChain_ifWrongIntent() public { // Use a different nonce so this channel does not exist on the current chain (non-home path). ChannelDefinition memory altDef = ChannelDefinition({ diff --git a/contracts/test/ChannelHub_units/ChannelHub_initiateEscrowDeposit.t.sol b/contracts/test/ChannelHub_units/ChannelHub_initiateEscrowDeposit.t.sol index d058bfe3a..cd2bd85f8 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_initiateEscrowDeposit.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_initiateEscrowDeposit.t.sol @@ -5,7 +5,14 @@ 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"; +import { + State, + ChannelDefinition, + StateIntent, + Ledger, + ChannelStatus, + EscrowStatus +} from "../../src/interfaces/Types.sol"; // forge-lint: disable-next-item(unsafe-typecast) contract ChannelHubTest_initiateEscrowDeposit is ChannelHubTest_Base { @@ -86,6 +93,13 @@ contract ChannelHubTest_initiateEscrowDeposit is ChannelHubTest_Base { cHub.initiateEscrowDeposit(def, escrowState); } + function test_revert_homeChain_ifETHSent() public { + vm.deal(node, 1); + vm.expectRevert(ChannelHub.IncorrectValue.selector); + vm.prank(node); + cHub.initiateEscrowDeposit{value: 1}(def, escrowState); + } + function test_homeChain_nodeCanSubmit() public { uint256 nodeBalanceBefore = cHub.getNodeBalance(address(token)); @@ -100,4 +114,74 @@ contract ChannelHubTest_initiateEscrowDeposit is ChannelHubTest_Base { "Node balance should decrease by escrow amount" ); } + + // ========== Non-home native deposit ========== + + function test_nonHomeChain_nativeDeposit_acceptsExactETH() public { + (ChannelDefinition memory nonHomeDef, bytes32 nonHomeChannelId, State memory nativeEscrowState) = + _buildNonHomeNativeEscrowDeposit(); + bytes32 escrowId = Utils.getEscrowId(nonHomeChannelId, nativeEscrowState.version); + + uint256 hubBalanceBefore = address(cHub).balance; + vm.deal(alice, ESCROW_AMOUNT); + vm.prank(alice); + cHub.initiateEscrowDeposit{value: ESCROW_AMOUNT}(nonHomeDef, nativeEscrowState); + + (, EscrowStatus status,,, uint256 lockedAmount,) = cHub.getEscrowDepositData(escrowId); + assertEq(uint8(status), uint8(EscrowStatus.INITIALIZED), "Escrow should be initialized"); + assertEq(lockedAmount, ESCROW_AMOUNT, "Escrow should lock native ETH"); + assertEq(address(cHub).balance, hubBalanceBefore + ESCROW_AMOUNT, "Native ETH should be pulled"); + } + + function test_revert_nonHomeChain_nativeDeposit_wrongValue() public { + (ChannelDefinition memory nonHomeDef,, State memory nativeEscrowState) = _buildNonHomeNativeEscrowDeposit(); + + vm.deal(alice, ESCROW_AMOUNT - 1); + vm.expectRevert(ChannelHub.IncorrectValue.selector); + vm.prank(alice); + cHub.initiateEscrowDeposit{value: ESCROW_AMOUNT - 1}(nonHomeDef, nativeEscrowState); + } + + function _buildNonHomeNativeEscrowDeposit() + internal + view + returns (ChannelDefinition memory nonHomeDef, bytes32 nonHomeChannelId, State memory nativeEscrowState) + { + nonHomeDef = ChannelDefinition({ + challengeDuration: CHALLENGE_DURATION, + user: alice, + node: node, + nonce: NONCE + 1, + approvedSignatureValidators: 0, + metadata: bytes32(0) + }); + nonHomeChannelId = Utils.getChannelId(nonHomeDef, CHANNEL_HUB_VERSION); + + nativeEscrowState = State({ + version: 1, + intent: StateIntent.INITIATE_ESCROW_DEPOSIT, + metadata: bytes32(0), + homeLedger: Ledger({ + chainId: NON_HOME_CHAIN_ID, + token: NON_HOME_TOKEN, + decimals: 18, + userAllocation: ESCROW_AMOUNT, + userNetFlow: int256(ESCROW_AMOUNT), + nodeAllocation: ESCROW_AMOUNT, + nodeNetFlow: int256(ESCROW_AMOUNT) + }), + nonHomeLedger: Ledger({ + chainId: uint64(block.chainid), + token: address(0), + decimals: 18, + userAllocation: ESCROW_AMOUNT, + userNetFlow: int256(ESCROW_AMOUNT), + nodeAllocation: 0, + nodeNetFlow: 0 + }), + userSig: "", + nodeSig: "" + }); + nativeEscrowState = mutualSignStateBothWithEcdsaValidator(nativeEscrowState, nonHomeChannelId, ALICE_PK); + } } diff --git a/contracts/test/Utils.t.sol b/contracts/test/Utils.t.sol index 1aca53ea8..633e141a2 100644 --- a/contracts/test/Utils.t.sol +++ b/contracts/test/Utils.t.sol @@ -154,6 +154,27 @@ contract UtilsTest is Test { console.logBytes32(channelId); } + function test_getChannelId_matchesAbiEncodePath() public pure { + ChannelDefinition memory def = ChannelDefinition({ + challengeDuration: 86400, + user: 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045, + node: 0x435d4B6b68e1083Cc0835D1F971C4739204C1d2a, + nonce: 42, + approvedSignatureValidators: 24042, + metadata: 0x13730b0d8e1bdbdc000000000000000000000000000000000000000000000000 + }); + uint8 version = 1; + + // Reproduce the pre-assembly path: keccak256(abi.encode(def)) + version in first byte + bytes32 baseId = keccak256(abi.encode(def)); + bytes32 expected = bytes32( + (uint256(baseId) & 0x00ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) + | (uint256(version) << 248) + ); + + assertEq(Utils.getChannelId(def, version), expected); + } + function test_log_calculateEscrowId() public pure { bytes32 channelId = 0xeac2bed767671a8ab77527e1e2fff00bb2e62de5467d9ba3a4105dad5c6e3d66; uint64 version = 42; diff --git a/contracts/test/mocks/MalformedReturningERC20.sol b/contracts/test/mocks/MalformedReturningERC20.sol index 5ba972fba..d4c4289a8 100644 --- a/contracts/test/mocks/MalformedReturningERC20.sol +++ b/contracts/test/mocks/MalformedReturningERC20.sol @@ -17,7 +17,11 @@ contract MalformedReturningERC20 is ERC20 { // Return only 1 byte instead of 32 (malformed) WITHOUT actually transferring // This simulates a malicious/buggy token that returns invalid data assembly { - mstore(0, 1) + // mstore8 writes a single byte (0x01) to address 0. + // mstore(0, 1) would write the value 1 as a 32-byte big-endian word, + // placing 0x01 at address 31 and 0x00 at address 0 — so return(0, 1) + // would yield 0x00, not 0x01. mstore8 avoids this by writing exactly one byte. + mstore8(0, 1) return(0, 1) } } @@ -26,7 +30,11 @@ contract MalformedReturningERC20 is ERC20 { // Return only 1 byte instead of 32 (malformed) WITHOUT actually transferring // This simulates a malicious/buggy token that returns invalid data assembly { - mstore(0, 1) + // mstore8 writes a single byte (0x01) to address 0. + // mstore(0, 1) would write the value 1 as a 32-byte big-endian word, + // placing 0x01 at address 31 and 0x00 at address 0 — so return(0, 1) + // would yield 0x00, not 0x01. mstore8 avoids this by writing exactly one byte. + mstore8(0, 1) return(0, 1) } } diff --git a/contracts/test/mocks/OversizedReturnERC20.sol b/contracts/test/mocks/OversizedReturnERC20.sol new file mode 100644 index 000000000..d099d3481 --- /dev/null +++ b/contracts/test/mocks/OversizedReturnERC20.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title OversizedReturnERC20 + * @notice Mock ERC20 whose transfer() returns `returnDataSize` bytes with `firstWord` as the first word. + * @dev Used to verify that _trySafeTransfer caps returndata copy to 32 bytes, preventing + * memory-expansion OOG when a token returns an oversized buffer. + * The actual transfer is performed only when firstWord != 0, matching normal token semantics + * (a zero return value signals failure without a state change). + */ +contract OversizedReturnERC20 is ERC20 { + uint256 private immutable RETURN_DATA_SIZE; + uint256 private immutable FIRST_WORD; + + constructor(uint256 returnDataSize, uint256 firstWord) ERC20("Oversized Token", "OVR") { + RETURN_DATA_SIZE = returnDataSize; + FIRST_WORD = firstWord; + } + + function transfer(address to, uint256 amount) public override returns (bool) { + if (FIRST_WORD != 0) { + _transfer(msg.sender, to, amount); + } + + uint256 size = RETURN_DATA_SIZE; + uint256 word = FIRST_WORD; + assembly { + let ptr := mload(0x40) + mstore(ptr, word) + // EVM zero-initialises newly expanded memory, so bytes [32, size) are 0x00. + return(ptr, size) + } + } + + function mint(address to, uint256 amount) public { + _mint(to, amount); + } +} diff --git a/docs/api.yaml b/docs/api.yaml index db4eea051..d87b2c12c 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -29,7 +29,7 @@ types: description: Nonce for the channel - name: status type: string - description: Current status of the channel (void, open, challenged, closed) + description: Current status of the channel (void, open, challenged, closing, closed) - name: state_version type: string description: On-chain state version of the channel @@ -380,6 +380,9 @@ types: - name: user_sig type: string description: User's signature over the session key metadata to authorize the registration/update of the session key + - name: session_key_sig + type: string + description: Session-key holder's signature over PackChannelKeyStateV1(session_key, metadata_hash) — the same packed bytes user_sig signs, where metadata_hash binds user_address — proving possession of the key being registered. Required on every submit to prevent registration of keys the submitter does not control. - app_session_key_state: description: Represents the state of an app session key @@ -409,6 +412,9 @@ types: - name: user_sig type: string description: User's signature over the session key metadata to authorize the registration/update of the session key + - name: session_key_sig + type: string + description: Session-key holder's signature over the same packed state (which already binds user_address) proving possession of the key being registered. Required on every submit to prevent registration of keys the submitter does not control. - app: description: Application definition @@ -639,7 +645,7 @@ api: - message: invalid_session_key_state description: The session key state is invalid - name: get_last_key_states - description: Retrieve the latest session key states for a user with optional filtering by session key + description: Retrieve the latest session key states for a user with optional filtering by session key. By default only currently active (non-expired) latest states are returned; set `include_inactive` to include expired latest states. Mandatory pagination caps response size (max page size 10). request: - field_name: user_address type: string @@ -648,12 +654,23 @@ api: type: string description: Optionally filter by session key optional: true + - field_name: include_inactive + type: boolean + description: If true, include latest states whose `expires_at` is in the past (expired or revoked). Defaults to false (active-only). + optional: true + - field_name: pagination + type: pagination_params + description: Pagination parameters (offset, limit). Default limit 10, max 10. The `sort` field is not supported by this endpoint and must be omitted. + optional: true response: - field_name: states type: array items: - type: app_session_key_state - description: List of active session key states for the user + type: channel_session_key_state + description: Latest session key states for the user, filtered by `include_inactive`. + - field_name: metadata + type: pagination_metadata + description: Pagination information errors: - message: account_not_found description: The specified account was not found @@ -827,21 +844,32 @@ api: - message: invalid_session_key_state description: The session key state is invalid - name: get_last_key_states - description: Retrieve the latest session key states for a user with optional filtering by session key + description: Retrieve the latest session key states for a user with optional filtering by session key. By default only currently active (non-expired) latest states are returned; set `include_inactive` to include expired latest states. Mandatory pagination caps response size (max page size 10). request: - - field_name: wallet + - field_name: user_address type: string description: User's wallet address - field_name: session_key type: string description: Optionally filter by session key optional: true + - field_name: include_inactive + type: boolean + description: If true, include latest states whose `expires_at` is in the past (expired or revoked). Defaults to false (active-only). + optional: true + - field_name: pagination + type: pagination_params + description: Pagination parameters (offset, limit). Default limit 10, max 10. The `sort` field is not supported by this endpoint and must be omitted. + optional: true response: - field_name: states type: array items: type: app_session_key_state - description: List of active session key states for the user + description: Latest session key states for the user, filtered by `include_inactive`. + - field_name: metadata + type: pagination_metadata + description: Pagination information errors: - message: account_not_found description: The specified account was not found diff --git a/docs/data_models.mmd b/docs/data_models.mmd index 9ce8ff680..4cf7584c8 100644 --- a/docs/data_models.mmd +++ b/docs/data_models.mmd @@ -194,6 +194,7 @@ classDiagram +numeric version +timestamptz expires_at +text user_sig + +text session_key_sig +timestamptz created_at +timestamptz updated_at } @@ -216,6 +217,7 @@ classDiagram +char~66~ metadata_hash +timestamptz expires_at +text user_sig + +text session_key_sig +timestamptz created_at } @@ -224,6 +226,15 @@ classDiagram +varchar~20~ asset PK } + class CurrentSessionKeyStateV1 { + +char~42~ user_address PK + +char~42~ session_key PK + +smallint kind PK + +numeric version + +timestamptz updated_at + UNIQUE(session_key, kind) + } + %% ===== BLOCKCHAIN TABLES ===== class ContractEvent { @@ -321,6 +332,10 @@ classDiagram ChannelSessionKeyAssetV1 --> ChannelSessionKeyStateV1 : session_key_state_id ChannelSessionKeyStateV1 ..> Channel : validated against user_address + asset + %% -- Pointer table tracks latest version per (user_address, session_key, kind) -- + CurrentSessionKeyStateV1 ..> AppSessionKeyStateV1 : kind=2 -> latest version + CurrentSessionKeyStateV1 ..> ChannelSessionKeyStateV1 : kind=1 -> latest version + %% -- Action log gates user operations -- ActionLogEntryV1 --> GatedAction : gated_action ActionLogEntryV1 ..> AppSessionV1 : gates session operations diff --git a/docs/protocol/enforcement.md b/docs/protocol/enforcement.md index ae8820863..57bb3950d 100644 --- a/docs/protocol/enforcement.md +++ b/docs/protocol/enforcement.md @@ -31,7 +31,7 @@ Node-issued pending states (those carrying only the node's signature) are NOT en Off-chain states and on-chain enforcement states are related as follows: - Participants advance state off-chain through signed updates -- At any time, any party MAY submit the latest mutually signed state to the blockchain layer +- Any party MAY submit the latest mutually signed state to the blockchain layer, provided a valid execution path exists for that state's intent in the current channel context - The blockchain layer validates the submitted state and updates its record - On-chain state always reflects the latest successfully checkpointed state @@ -69,7 +69,7 @@ The creation process: 3. The blockchain layer validates signatures, creates the channel record, and applies fund effects according to the state's intent 4. The channel is now active on the on-chain layer -The state submitted for channel creation MAY carry a DEPOSIT, WITHDRAW, or OPERATE intent. +The state submitted for channel creation MAY carry a DEPOSIT, WITHDRAW, or OPERATE intent. OPERATE intent requires the user net flow delta to be zero relative to the previous on-chain state (which is the empty state for a new channel). An OPERATE state that carries accumulated user net flow from an unenforced prior DEPOSIT state cannot be used to create or checkpoint a channel — parties MUST enforce the DEPOSIT state on-chain before advancing to subsequent OPERATE states that depend on it. ## State Submission diff --git a/docs/protocol/overview.md b/docs/protocol/overview.md index 58da06d92..1336b2451 100644 --- a/docs/protocol/overview.md +++ b/docs/protocol/overview.md @@ -2,7 +2,7 @@ Nitrolite is a state channel protocol that enables high-speed off-chain interactions between users while preserving on-chain security guarantees. -Users exchange signed state updates off-chain with Nodes that act as a hub connecting network participants. Any user can enforce the latest agreed state on the blockchain layer at any time. +Users exchange signed state updates off-chain with Nodes that act as a hub connecting network participants. Any user can enforce the latest agreed state on the blockchain layer, provided a valid execution path exists for that state's intent in the current channel context. ## Table of Contents @@ -23,7 +23,7 @@ Users exchange signed state updates off-chain with Nodes that act as a hub conne The protocol is designed to achieve: - **Off-chain scalability** — minimize on-chain transactions by moving state advancement off-chain -- **Blockchain security guarantees** — any user can fall back to the blockchain layer to enforce the latest state +- **Blockchain security guarantees** — any user can fall back to the blockchain layer to enforce the latest state, provided a valid execution path exists for that state's intent - **Cross-chain asset interaction** — operate on assets across multiple blockchains through a unified model - **Extensibility** — support additional functionality through protocol extensions without modifying the core protocol @@ -60,7 +60,7 @@ A state represents the current agreed asset allocations and metadata shared betw User and a node advance states off-chain by exchanging signed state transitions. Each new state MUST have a version exactly one greater than the previous state. Transitions include deposits, withdrawals, transfers, commits, releases, escrow operations, and migrations. **State Enforcement** -Any party MAY submit the latest signed state to the blockchain layer for on-chain enforcement. The blockchain layer validates signatures, version ordering, and ledger invariants before accepting a state. +Any party MAY submit the latest mutually signed state to the blockchain layer for on-chain enforcement, provided that state's intent has a valid execution path in the current channel context. The blockchain layer validates signatures, version ordering, and ledger invariants before accepting a state. **Unified Assets** The same asset from multiple blockchains is represented in a unified model, enabling cross-chain operations among users and apps. The protocol normalizes amounts by decimal precision when comparing allocations across chains. diff --git a/docs/protocol/security-and-limitations.md b/docs/protocol/security-and-limitations.md index c7dae6e2e..6a24e9d50 100644 --- a/docs/protocol/security-and-limitations.md +++ b/docs/protocol/security-and-limitations.md @@ -17,7 +17,7 @@ However, the protocol in its current form is not fully trust-minimized. The prim The protocol aims to guarantee: - **Asset safety** — participants MUST NOT lose assets without signing a state that authorizes the change -- **State finality** — the latest mutually signed state can always be enforced on-chain +- **State finality** — the latest mutually signed state can be enforced on-chain when a valid execution path exists for its intent; parties MUST retain any intermediate states required to establish that path - **Non-repudiation** — a participant cannot deny having signed a state - **Censorship resistance** — any party MAY independently enforce state on the blockchain layer @@ -41,7 +41,8 @@ Each state update MUST satisfy transition-specific rules. Invalid transitions ar The blockchain layer provides the following guarantees: -- Any party MAY submit the latest signed state at any time +- Any party MAY submit the latest mutually signed state to the blockchain layer; enforcement succeeds when a valid execution path exists for that state's intent in the current channel context +- Parties MUST retain and enforce intermediate states (such as a DEPOSIT state) before discarding them — a subsequent OPERATE state built on top of an unenforced DEPOSIT cannot be used to create or checkpoint a channel on-chain, because OPERATE requires zero change in user net flow relative to the last enforced state - The blockchain layer accepts only states with valid signatures and a higher version than the current on-chain state - After the challenge period, the enforced state becomes final - Final state allocations determine asset distribution @@ -62,6 +63,8 @@ In the current protocol version, participants MUST trust nodes for: - **Cross-chain liquidity** — nodes MUST maintain sufficient funds on each supported chain to honour off-chain allocations; insufficient liquidity may cause on-chain enforcement to fail - **Cross-chain relay** — nodes relay cross-chain state updates; trustless cross-chain enforcement is not yet implemented - **Timely enforcement** — nodes are expected to submit checkpoints when requested; delayed enforcement may affect user experience but does not compromise single-chain asset safety +- **Off-chain transfer routing** — when a user sends funds off-chain to another party, the node must countersign both the sender's state (decreasing their allocation) and the receiver's credit state (increasing theirs); 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, capturing the transferred funds. Users must trust the node to faithfully execute both legs of every off-chain transfer. +- **Signature validator registry** — the node operator controls which additional signature validators are registered on the ChannelHub contract. A malicious or compromised node could register a validator that approves forged user signatures, then use it to create channels or close them without the user's knowledge. A 1-day activation delay (`VALIDATOR_ACTIVATION_DELAY`) creates an observable window before any newly registered validator can be used. Users MUST monitor the `ValidatorRegistered` event on the ChannelHub contract and SHOULD revoke all ERC20 approvals granted to ChannelHub immediately upon detecting an unexpected registration. Once registered, a validator cannot be deactivated — the 1-day window is the entire response budget. Users SHOULD avoid granting large standing ERC20 approvals to ChannelHub to cap worst-case exposure. Participants do not need to trust nodes for: diff --git a/nitronode/README.md b/nitronode/README.md index 88481b618..fa6b520c3 100644 --- a/nitronode/README.md +++ b/nitronode/README.md @@ -85,7 +85,8 @@ assets: |----------|-------------|---------| | `NITRONODE_SIGNER_KEY` | Private key for signing node state updates | (Required) | | `NITRONODE_DATABASE_DRIVER` | `sqlite` or `postgres` | `sqlite` | -| `NITRONODE_DATABASE_URL` | Connection string or file path | `nitronode.db` | +| `NITRONODE_DATABASE_URL` | Postgres DSN/URL or sqlite file path. When set for `postgres`, used verbatim and overrides the individual host/user/password/sslmode fields | `nitronode.db` | +| `NITRONODE_DATABASE_SSLMODE` | Postgres SSL mode: `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full` | `require` | | `NITRONODE_LOG_LEVEL` | `debug`, `info`, `warn`, `error` | `info` | | `NITRONODE_BLOCKCHAIN_RPC_` | RPC endpoint for a specific blockchain | (Required) | diff --git a/nitronode/api/app_session_v1/README.md b/nitronode/api/app_session_v1/README.md index 353404d22..ae9280522 100644 --- a/nitronode/api/app_session_v1/README.md +++ b/nitronode/api/app_session_v1/README.md @@ -708,6 +708,9 @@ ORDER BY created_at DESC; - `user_sig` is required - Version must be sequential (latest_version + 1) - Signature must recover to `user_address` +- Newly registered keys count against the per-user cap (`NITRONODE_MAX_SESSION_KEYS_PER_USER`, default 100). Updates to keys that already exist for the user are not blocked by the cap. + +**Concurrency**: A `SELECT ... FOR UPDATE` is taken on a per-(user, session_key, kind) pointer row in `current_session_key_states_v1` so concurrent submits for the same key serialize and report a clean "expected version" error instead of racing on the history table's UNIQUE constraint. **Signature Verification**: - Uses ABI encoding via `PackAppSessionKeyStateV1` to create a deterministic hash @@ -750,6 +753,10 @@ ORDER BY created_at DESC; - Returns only the latest version per session key - Excludes expired session key states +**Pagination**: Optional `pagination` block (`limit`, `offset`); response includes a `pagination` block with `current_page`, `page_count`, `per_page`, `total_items`. Server-side default and max `limit` are both 10. + +**Read path**: Filters `current_session_key_states_v1` by (user_address, kind=app_session) and JOINs the history table on (user_address, session_key, version). Per-request DB work is bounded by the number of distinct session keys for the user, regardless of version churn in history. + ## Implementation Details ### Files diff --git a/nitronode/api/app_session_v1/create_app_session.go b/nitronode/api/app_session_v1/create_app_session.go index ecb3879b1..27a65af63 100644 --- a/nitronode/api/app_session_v1/create_app_session.go +++ b/nitronode/api/app_session_v1/create_app_session.go @@ -59,8 +59,8 @@ func (h *Handler) CreateAppSession(c *rpc.Context) { "nonce", reqPayload.Definition.Nonce) // Validate nonce - if reqPayload.Definition.Nonce == "" || reqPayload.Definition.Nonce == "0" { - c.Fail(nil, "nonce is zero or not provided") + if appDef.Nonce == 0 { + c.Fail(nil, "nonce must be non-zero") return } diff --git a/nitronode/api/app_session_v1/create_app_session_test.go b/nitronode/api/app_session_v1/create_app_session_test.go index 4b8047048..db4681c14 100644 --- a/nitronode/api/app_session_v1/create_app_session_test.go +++ b/nitronode/api/app_session_v1/create_app_session_test.go @@ -36,7 +36,7 @@ func TestCreateAppSession_Success(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create a real test wallet for participant1 @@ -141,7 +141,7 @@ func TestCreateAppSession_QuorumWithMultipleSignatures(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create real test wallets for participant1 and participant2 @@ -223,68 +223,70 @@ func TestCreateAppSession_QuorumWithMultipleSignatures(t *testing.T) { } func TestCreateAppSession_ZeroNonce(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - mockSigner := NewMockChannelSigner() - mockAssetStore := new(MockAssetStore) - mockStatePacker := new(MockStatePacker) - - handler := NewHandler( - storeTxProvider, - mockAssetStore, - &MockActionGateway{}, - mockSigner, - core.NewStateAdvancerV1(mockAssetStore), - mockStatePacker, - "0xnode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, - ) - - // Test data - participant1 := "0x1111111111111111111111111111111111111111" - - reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ - Definition: rpc.AppDefinitionV1{ - Application: "test-app", - Participants: []rpc.AppParticipantV1{ - { - WalletAddress: participant1, - SignatureWeight: 1, + // Zero-padded values must also be rejected: strconv.ParseUint accepts "00", + // "000", etc. and yields 0, which used to bypass the raw-string "0" check. + cases := []string{"0", "00", "000"} + for _, nonce := range cases { + t.Run("nonce="+nonce, func(t *testing.T) { + mockStore := new(MockStore) + + storeTxProvider := func(fn StoreTxHandler) error { + return fn(mockStore) + } + + mockSigner := NewMockChannelSigner() + mockAssetStore := new(MockAssetStore) + mockStatePacker := new(MockStatePacker) + + handler := NewHandler( + storeTxProvider, + mockAssetStore, + &MockActionGateway{}, + mockSigner, + core.NewStateAdvancerV1(mockAssetStore), + mockStatePacker, + "0xnode", + true, + metrics.NewNoopRuntimeMetricExporter(), + 32, 1024, 256, 16, 100, + ) + + participant1 := "0x1111111111111111111111111111111111111111" + + reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ + Definition: rpc.AppDefinitionV1{ + Application: "test-app", + Participants: []rpc.AppParticipantV1{ + { + WalletAddress: participant1, + SignatureWeight: 1, + }, + }, + Quorum: 1, + Nonce: nonce, }, - }, - Quorum: 1, - Nonce: "0", // Zero nonce - invalid - }, - QuorumSigs: []string{"0x1234567890abcdef"}, - } + QuorumSigs: []string{"0x1234567890abcdef"}, + } - // Create RPC context - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppSessionsV1CreateAppSessionMethod), payload), - } + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, string(rpc.AppSessionsV1CreateAppSessionMethod), payload), + } - handler.CreateAppSession(ctx) + handler.CreateAppSession(ctx) - assert.NotNil(t, ctx.Response) + assert.NotNil(t, ctx.Response) - // Verify response contains error about nonce - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "nonce") + err = ctx.Response.Error() + require.Error(t, err) + assert.Contains(t, err.Error(), "nonce") - // Verify no mocks were called since we fail early - mockStore.AssertExpectations(t) + mockStore.AssertExpectations(t) + }) + } } func TestCreateAppSession_QuorumExceedsTotalWeights(t *testing.T) { @@ -309,7 +311,7 @@ func TestCreateAppSession_QuorumExceedsTotalWeights(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -380,7 +382,7 @@ func TestCreateAppSession_NoSignatures(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -445,7 +447,7 @@ func TestCreateAppSession_SignatureFromNonParticipant(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create a wallet that is NOT a participant @@ -527,7 +529,7 @@ func TestCreateAppSession_QuorumNotMet(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create a real wallet for participant1 @@ -623,7 +625,7 @@ func TestCreateAppSession_DuplicateSignatures(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create a real wallet for participant1 @@ -716,7 +718,7 @@ func TestCreateAppSession_InvalidSignatureHex(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -785,7 +787,7 @@ func TestCreateAppSession_SignatureRecoveryFailure(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -854,7 +856,7 @@ func TestCreateAppSession_AppNotRegistered(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -924,7 +926,7 @@ func TestCreateAppSession_OwnerSigRequired(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -1001,7 +1003,7 @@ func TestCreateAppSession_OwnerSigSuccess(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create participant and owner wallets @@ -1102,7 +1104,7 @@ func TestCreateAppSession_AppRegistryDisabled(t *testing.T) { "0xnode", false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -1193,7 +1195,7 @@ func TestCreateAppSession_DuplicateParticipantAcrossCases(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) lower := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/nitronode/api/app_session_v1/get_last_key_states.go b/nitronode/api/app_session_v1/get_last_key_states.go index 83976a1c5..997ebca90 100644 --- a/nitronode/api/app_session_v1/get_last_key_states.go +++ b/nitronode/api/app_session_v1/get_last_key_states.go @@ -7,6 +7,7 @@ import ( ) // GetLastKeyStates retrieves the latest session key states for a user with optional filtering by session key. +// Mandatory pagination caps response size to prevent unbounded reads. func (h *Handler) GetLastKeyStates(c *rpc.Context) { ctx := c.Context logger := log.FromContext(ctx) @@ -18,19 +19,46 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { } if req.UserAddress == "" { - c.Fail(rpc.Errorf("wallet is required"), "") + c.Fail(rpc.Errorf("user_address is required"), "") return } + var limit, offset uint32 + if req.Pagination != nil { + // The endpoint orders rows by (created_at DESC, id ASC) for stable pagination; + // callers cannot override this, so any sort value is rejected rather than silently + // ignored. PaginationParamsV1.Sort is shared across the v1 API and other endpoints + // honor it, which is why we validate here instead of dropping the field. + if req.Pagination.Sort != nil && *req.Pagination.Sort != "" { + c.Fail(rpc.Errorf("invalid_pagination: sort is not supported by get_last_key_states"), "") + return + } + if req.Pagination.Limit != nil { + limit = *req.Pagination.Limit + } + if req.Pagination.Offset != nil { + offset = *req.Pagination.Offset + } + } + if limit == 0 || limit > rpc.GetLastKeyStatesPageLimit { + limit = rpc.GetLastKeyStatesPageLimit + } + + includeInactive := req.IncludeInactive != nil && *req.IncludeInactive + logger.Debug("retrieving session key states", - "wallet", req.UserAddress, - "sessionKey", req.SessionKey) + "userAddress", req.UserAddress, + "sessionKey", req.SessionKey, + "includeInactive", includeInactive, + "limit", limit, + "offset", offset) var states []app.AppSessionKeyStateV1 + var totalCount uint32 err := h.useStoreInTx(func(tx Store) error { var err error - states, err = tx.GetLastAppSessionKeyStates(req.UserAddress, req.SessionKey) + states, totalCount, err = tx.GetLastAppSessionKeyStates(req.UserAddress, req.SessionKey, includeInactive, limit, offset) return err }) @@ -46,7 +74,8 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { } resp := rpc.AppSessionsV1GetLastKeyStatesResponse{ - States: rpcStates, + States: rpcStates, + Metadata: buildPageMetadata(totalCount, limit, offset), } payload, err := rpc.NewPayload(resp) @@ -57,3 +86,27 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { c.Succeed(c.Request.Method, payload) } + +// buildPageMetadata returns the standard pagination metadata for get_last_key_states. +// Page is 1-based and defaults to 1 (including the empty-result case, so the metadata is +// never `{page: 0, page_count: 0}`). For non-aligned offsets the page formula treats the +// offset as a row-skip count and reports the page that contains row `offset+1` — callers +// that need exact page semantics should pass offset as a multiple of limit. +func buildPageMetadata(totalCount, limit, offset uint32) rpc.PaginationMetadataV1 { + page := uint32(1) + if limit > 0 && offset >= limit { + page = (offset / limit) + 1 + } + + var pageCount uint32 + if totalCount > 0 && limit > 0 { + pageCount = (totalCount + limit - 1) / limit + } + + return rpc.PaginationMetadataV1{ + Page: page, + PerPage: limit, + TotalCount: totalCount, + PageCount: pageCount, + } +} diff --git a/nitronode/api/app_session_v1/get_last_key_states_test.go b/nitronode/api/app_session_v1/get_last_key_states_test.go new file mode 100644 index 000000000..89b758e95 --- /dev/null +++ b/nitronode/api/app_session_v1/get_last_key_states_test.go @@ -0,0 +1,168 @@ +package app_session_v1 + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitrolite/pkg/app" + "github.com/layer-3/nitrolite/pkg/rpc" +) + +func newGetLastKeyStatesHandler(store Store) *Handler { + return &Handler{ + useStoreInTx: func(fn StoreTxHandler) error { + return fn(store) + }, + } +} + +func callGetLastKeyStates(t *testing.T, h *Handler, req rpc.AppSessionsV1GetLastKeyStatesRequest) *rpc.Context { + t.Helper() + payload, err := rpc.NewPayload(req) + require.NoError(t, err) + c := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1GetLastKeyStatesMethod.String(), payload), + } + h.GetLastKeyStates(c) + return c +} + +func extractGetLastKeyStatesResponse(t *testing.T, c *rpc.Context) rpc.AppSessionsV1GetLastKeyStatesResponse { + t.Helper() + require.NotNil(t, c.Response) + require.Nil(t, c.Response.Error()) + var resp rpc.AppSessionsV1GetLastKeyStatesResponse + require.NoError(t, c.Response.Payload.Translate(&resp)) + return resp +} + +func TestGetLastKeyStates_DefaultsToPageOneOnEmptyResult(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(0)). + Return([]app.AppSessionKeyStateV1{}, 0, nil) + + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{UserAddress: "0xuser"}) + resp := extractGetLastKeyStatesResponse(t, c) + + assert.Empty(t, resp.States) + // Empty results must not produce {page: 0, page_count: 0} — page is always 1-based. + assert.Equal(t, uint32(1), resp.Metadata.Page) + assert.Equal(t, uint32(10), resp.Metadata.PerPage) + assert.Equal(t, uint32(0), resp.Metadata.TotalCount) + assert.Equal(t, uint32(0), resp.Metadata.PageCount) +} + +func TestGetLastKeyStates_PaginationMetadata_AlignedOffset(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + limit := uint32(10) + offset := uint32(10) + pagination := &rpc.PaginationParamsV1{Limit: &limit, Offset: &offset} + + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(10)). + Return([]app.AppSessionKeyStateV1{ + {UserAddress: "0xuser", SessionKey: "0xkey", Version: 1, ExpiresAt: time.Now().Add(time.Hour)}, + }, 25, nil) + + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + Pagination: pagination, + }) + resp := extractGetLastKeyStatesResponse(t, c) + + assert.Equal(t, uint32(2), resp.Metadata.Page) + assert.Equal(t, uint32(3), resp.Metadata.PageCount) // ceil(25/10) + assert.Equal(t, uint32(25), resp.Metadata.TotalCount) +} + +func TestGetLastKeyStates_ClampsLimitToMax(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + excessive := uint32(1000) + pagination := &rpc.PaginationParamsV1{Limit: &excessive} + + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), false, rpc.GetLastKeyStatesPageLimit, uint32(0)). + Return([]app.AppSessionKeyStateV1{}, 0, nil) + + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + Pagination: pagination, + }) + resp := extractGetLastKeyStatesResponse(t, c) + + assert.Equal(t, rpc.GetLastKeyStatesPageLimit, resp.Metadata.PerPage) + mockStore.AssertExpectations(t) +} + +func TestGetLastKeyStates_RejectsSortField(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + sort := "asc" + pagination := &rpc.PaginationParamsV1{Sort: &sort} + + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + Pagination: pagination, + }) + + require.NotNil(t, c.Response) + require.NotNil(t, c.Response.Error()) + assert.Contains(t, c.Response.Error().Error(), "sort is not supported") + mockStore.AssertNotCalled(t, "GetLastAppSessionKeyStates", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestGetLastKeyStates_RequiresUserAddress(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{}) + + require.NotNil(t, c.Response) + require.NotNil(t, c.Response.Error()) + assert.Contains(t, c.Response.Error().Error(), "user_address is required") +} + +func TestGetLastKeyStates_IncludeInactiveTruePlumbsToStore(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), true, uint32(10), uint32(0)). + Return([]app.AppSessionKeyStateV1{}, 0, nil) + + includeInactive := true + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + IncludeInactive: &includeInactive, + }) + _ = extractGetLastKeyStatesResponse(t, c) + + mockStore.AssertExpectations(t) +} + +func TestGetLastKeyStates_IncludeInactiveFalsePlumbsToStore(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(0)). + Return([]app.AppSessionKeyStateV1{}, 0, nil) + + includeInactive := false + c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + IncludeInactive: &includeInactive, + }) + _ = extractGetLastKeyStatesResponse(t, c) + + mockStore.AssertExpectations(t) +} diff --git a/nitronode/api/app_session_v1/handler.go b/nitronode/api/app_session_v1/handler.go index e8918dec6..20e34fd0e 100644 --- a/nitronode/api/app_session_v1/handler.go +++ b/nitronode/api/app_session_v1/handler.go @@ -25,19 +25,20 @@ import ( // Handler manages app session operations and provides RPC endpoints for app session management. type Handler struct { - useStoreInTx StoreTxProvider - assetStore AssetStore - actionGateway ActionGateway - signer *core.ChannelDefaultSigner - stateAdvancer core.StateAdvancer - statePacker core.StatePacker - nodeAddress string // Node's wallet address - appRegistryEnabled bool - metrics metrics.RuntimeMetricExporter - maxParticipants int - maxSessionData int - maxSessionKeyIDs int - maxSignedUpdates int + useStoreInTx StoreTxProvider + assetStore AssetStore + actionGateway ActionGateway + signer *core.ChannelDefaultSigner + stateAdvancer core.StateAdvancer + statePacker core.StatePacker + nodeAddress string // Node's wallet address + appRegistryEnabled bool + metrics metrics.RuntimeMetricExporter + maxParticipants int + maxSessionData int + maxSessionKeyIDs int + maxSignedUpdates int + maxSessionKeysPerUser int } // NewHandler creates a new Handler instance with the provided dependencies. @@ -52,21 +53,23 @@ func NewHandler( appRegistryEnabled bool, m metrics.RuntimeMetricExporter, maxParticipants, maxSessionData, maxSessionKeyIDs, maxSignedUpdates int, + maxSessionKeysPerUser int, ) *Handler { return &Handler{ - useStoreInTx: useStoreInTx, - assetStore: assetStore, - actionGateway: actionGateway, - signer: signer, - stateAdvancer: stateAdvancer, - statePacker: statePacker, - nodeAddress: nodeAddress, - appRegistryEnabled: appRegistryEnabled, - metrics: m, - maxParticipants: maxParticipants, - maxSessionData: maxSessionData, - maxSessionKeyIDs: maxSessionKeyIDs, - maxSignedUpdates: maxSignedUpdates, + useStoreInTx: useStoreInTx, + assetStore: assetStore, + actionGateway: actionGateway, + signer: signer, + stateAdvancer: stateAdvancer, + statePacker: statePacker, + nodeAddress: nodeAddress, + appRegistryEnabled: appRegistryEnabled, + metrics: m, + maxParticipants: maxParticipants, + maxSessionData: maxSessionData, + maxSessionKeyIDs: maxSessionKeyIDs, + maxSignedUpdates: maxSignedUpdates, + maxSessionKeysPerUser: maxSessionKeysPerUser, } } @@ -156,15 +159,8 @@ func (h *Handler) issueReleaseReceiverState(ctx context.Context, tx Store, recei return rpc.Errorf("failed to apply release transition: %v", err) } - // Check if we need to sign the state (skip signing if last signed state was a lock) - lastSignedState, err := tx.GetLastUserState(receiverWallet, asset, true) - if err != nil { - return rpc.Errorf("failed to get last signed state: %v", err) - } - - // TODO: move to DB query - if lastSignedState != nil && lastSignedState.EscrowChannelID != nil { - return rpc.Errorf("cannot issue release receiver state: last signed state is a lock with escrow channel %s", *lastSignedState.EscrowChannelID) + if err := tx.EnsureNoOngoingEscrowOperation(receiverWallet, asset); err != nil { + return rpc.Errorf("cannot issue release receiver state: %v", err) } if newState.HomeChannelID != nil { diff --git a/nitronode/api/app_session_v1/interface.go b/nitronode/api/app_session_v1/interface.go index 6720ad7b0..cb9948abc 100644 --- a/nitronode/api/app_session_v1/interface.go +++ b/nitronode/api/app_session_v1/interface.go @@ -2,6 +2,7 @@ package app_session_v1 import ( "github.com/layer-3/nitrolite/nitronode/action_gateway" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/shopspring/decimal" @@ -33,19 +34,33 @@ type Store interface { // Returns the current balance. LockUserState(wallet, asset string) (decimal.Decimal, error) - // CheckOpenChannel verifies if a user has an active channel for the given asset - // and returns the approved signature validators if such a channel exists. - CheckOpenChannel(wallet, asset string) (string, bool, error) + // CheckActiveChannel verifies if a user has an active home channel for the given asset + // and returns its approved signature validators and current status. A nil status means + // no active channel exists. "Active" includes Void (DB-only, awaiting onchain confirmation) + // and Open (materialized onchain); callers needing onchain materialization must additionally + // require Status == core.ChannelStatusOpen. + CheckActiveChannel(wallet, asset string) (string, *core.ChannelStatus, error) GetLastUserState(wallet, asset string, signed bool) (*core.State, error) // StoreUserState persists a user state. applicationID is the client-declared // origin tag (rpc.ApplicationIDQueryParam); empty string is persisted as NULL. StoreUserState(state core.State, applicationID string) error EnsureNoOngoingStateTransitions(wallet, asset string) error + // EnsureNoOngoingEscrowOperation validates that the user has no in-flight escrow + // operation (escrow_lock, mutual_lock, or unfinalized escrow_deposit/escrow_withdraw) + // that would prevent issuing a receiver-side state. + EnsureNoOngoingEscrowOperation(wallet, asset string) error + // App Session key state operations + LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) + CountSessionKeysForUser(userAddress string) (uint32, error) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint64, error) - GetLastAppSessionKeyStates(wallet string, sessionKey *string) ([]app.AppSessionKeyStateV1, error) + // GetLastAppSessionKeyStates retrieves the latest app session key states for a user, + // optionally filtered by session key. When includeInactive is false, only non-expired + // latest states are returned; when true, all latest states are returned regardless of + // expiry. Results are paginated. + GetLastAppSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) GetAppSessionKeyOwner(sessionKey, appSessionId string) (string, error) // Channel Session key state operations diff --git a/nitronode/api/app_session_v1/rebalance_app_sessions_test.go b/nitronode/api/app_session_v1/rebalance_app_sessions_test.go index d62a14484..264bcb2ba 100644 --- a/nitronode/api/app_session_v1/rebalance_app_sessions_test.go +++ b/nitronode/api/app_session_v1/rebalance_app_sessions_test.go @@ -50,7 +50,7 @@ func TestRebalanceAppSessions_Success_TwoSessions(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create test wallets with real keys @@ -222,7 +222,7 @@ func TestRebalanceAppSessions_Success_MultiAsset(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create test wallets with real keys @@ -383,7 +383,7 @@ func TestRebalanceAppSessions_Error_InsufficientSessions(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -442,7 +442,7 @@ func TestRebalanceAppSessions_Error_InvalidIntent(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -517,7 +517,7 @@ func TestRebalanceAppSessions_Error_DuplicateSession(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -594,7 +594,7 @@ func TestRebalanceAppSessions_Error_ConservationViolation(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Create test wallets with real keys @@ -741,7 +741,7 @@ func TestRebalanceAppSessions_Error_SessionNotFound(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -823,7 +823,7 @@ func TestRebalanceAppSessions_Error_ClosedSession(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -919,7 +919,7 @@ func TestRebalanceAppSessions_Error_InvalidVersion(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -1016,7 +1016,7 @@ func TestRebalanceAppSessions_AppRegistryDisabled(t *testing.T) { "0xNode", false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -1142,7 +1142,7 @@ func TestRebalanceAppSessions_Error_DuplicateAllocation(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) @@ -1271,7 +1271,7 @@ func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) { "0xNode", false, // registry disabled — simpler: skip GetApp path metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) wallet1 := NewTestAppSessionWallet(t) diff --git a/nitronode/api/app_session_v1/submit_app_state_test.go b/nitronode/api/app_session_v1/submit_app_state_test.go index c56231673..4dff63633 100644 --- a/nitronode/api/app_session_v1/submit_app_state_test.go +++ b/nitronode/api/app_session_v1/submit_app_state_test.go @@ -3,6 +3,7 @@ package app_session_v1 import ( "context" "errors" + "fmt" "strings" "testing" @@ -37,7 +38,7 @@ func TestSubmitAppState_OperateIntent_NoRedistribution_Success(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -153,7 +154,7 @@ func TestSubmitAppState_OperateIntent_WithRedistribution_Success(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -276,7 +277,7 @@ func TestSubmitAppState_WithdrawIntent_Success(t *testing.T) { nodeAddress, true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -350,7 +351,7 @@ func TestSubmitAppState_WithdrawIntent_Success(t *testing.T) { } mockStore.On("LockUserState", participant1, "USDC").Return(decimal.Zero, nil) mockStore.On("GetLastUserState", participant1, "USDC", false).Return(existingUserState, nil) - mockStore.On("GetLastUserState", participant1, "USDC", true).Return(nil, nil) + mockStore.On("EnsureNoOngoingEscrowOperation", participant1, "USDC").Return(nil) mockStatePacker.On("PackState", mock.Anything).Return([]byte("packed"), nil) mockStore.On("RecordTransaction", mock.Anything, mock.Anything).Return(nil) @@ -412,7 +413,7 @@ func TestSubmitAppState_WithdrawIntent_ReceiverWithEscrowLock_Rejected(t *testin "0xNode", false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -485,20 +486,9 @@ func TestSubmitAppState_WithdrawIntent_ReceiverWithEscrowLock_Rejected(t *testin }, } - // Last signed state has an active escrow channel - escrowChannelID := "0xEscrowChannel456" - lastSignedState := core.State{ - Asset: "USDC", - UserWallet: participant1, - Epoch: 1, - Version: 1, - HomeChannelID: &homeChannelID, - EscrowChannelID: &escrowChannelID, - } - mockStore.On("LockUserState", participant1, "USDC").Return(decimal.Zero, nil) mockStore.On("GetLastUserState", participant1, "USDC", false).Return(existingUserState, nil) - mockStore.On("GetLastUserState", participant1, "USDC", true).Return(lastSignedState, nil) + mockStore.On("EnsureNoOngoingEscrowOperation", participant1, "USDC").Return(fmt.Errorf("escrow lock is still ongoing")) // Create RPC context payload, err := rpc.NewPayload(reqPayload) @@ -516,7 +506,7 @@ func TestSubmitAppState_WithdrawIntent_ReceiverWithEscrowLock_Rejected(t *testin require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr, "Expected error when participant has active escrow lock") - assert.Contains(t, respErr.Error(), "last signed state is a lock with escrow channel") + assert.Contains(t, respErr.Error(), "escrow lock is still ongoing") mockStore.AssertExpectations(t) } @@ -544,7 +534,7 @@ func TestSubmitAppState_CloseIntent_Success(t *testing.T) { nodeAddress, true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -638,7 +628,7 @@ func TestSubmitAppState_CloseIntent_Success(t *testing.T) { mockStore.On("RecordLedgerEntry", participant1, appSessionID, "USDC", decimal.NewFromInt(-100)).Return(nil) mockStore.On("LockUserState", participant1, "USDC").Return(decimal.Zero, nil) mockStore.On("GetLastUserState", participant1, "USDC", false).Return(existingUserState1, nil) - mockStore.On("GetLastUserState", participant1, "USDC", true).Return(nil, nil) + mockStore.On("EnsureNoOngoingEscrowOperation", participant1, "USDC").Return(nil) mockStatePacker.On("PackState", mock.Anything).Return([]byte("packed"), nil) mockStore.On("RecordTransaction", mock.Anything, mock.Anything).Return(nil) @@ -646,7 +636,7 @@ func TestSubmitAppState_CloseIntent_Success(t *testing.T) { mockStore.On("RecordLedgerEntry", participant2, appSessionID, "USDC", decimal.NewFromInt(-50)).Return(nil) mockStore.On("LockUserState", participant2, "USDC").Return(decimal.Zero, nil) mockStore.On("GetLastUserState", participant2, "USDC", false).Return(existingUserState2, nil) - mockStore.On("GetLastUserState", participant2, "USDC", true).Return(nil, nil) + mockStore.On("EnsureNoOngoingEscrowOperation", participant2, "USDC").Return(nil) // Capture stored states to verify node signatures var capturedStates []core.State @@ -709,7 +699,7 @@ func TestSubmitAppState_CloseIntent_AllocationMismatch_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -809,7 +799,7 @@ func TestSubmitAppState_OperateIntent_MissingAllocation_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -923,7 +913,7 @@ func TestSubmitAppState_WithdrawIntent_MissingAllocation_Rejected(t *testing.T) "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -988,7 +978,7 @@ func TestSubmitAppState_WithdrawIntent_MissingAllocation_Rejected(t *testing.T) mockStore.On("RecordLedgerEntry", participant1, appSessionID, "USDC", decimal.NewFromInt(-40)).Return(nil).Maybe() mockStore.On("LockUserState", participant1, "USDC").Return(decimal.Zero, nil).Maybe() mockStore.On("GetLastUserState", participant1, "USDC", false).Return(nil, nil).Maybe() - mockStore.On("GetLastUserState", participant1, "USDC", true).Return(nil, nil).Maybe() + mockStore.On("EnsureNoOngoingEscrowOperation", participant1, "USDC").Return(nil).Maybe() mockStore.On("StoreUserState", mock.Anything, mock.Anything).Return(nil).Maybe() mockStore.On("RecordTransaction", mock.Anything, mock.Anything).Return(nil).Maybe() @@ -1035,7 +1025,7 @@ func TestSubmitAppState_DepositIntent_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1091,7 +1081,7 @@ func TestSubmitAppState_ClosedSession_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1162,7 +1152,7 @@ func TestSubmitAppState_InvalidVersion_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1238,7 +1228,7 @@ func TestSubmitAppState_SessionNotFound_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1299,7 +1289,7 @@ func TestSubmitAppState_OperateIntent_InvalidDecimalPrecision_Rejected(t *testin "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1408,7 +1398,7 @@ func TestSubmitAppState_WithdrawIntent_InvalidDecimalPrecision_Rejected(t *testi "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1513,7 +1503,7 @@ func TestSubmitAppState_OperateIntent_RedistributeToNewParticipant_Success(t *te "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1635,7 +1625,7 @@ func TestSubmitAppState_AppRegistryDisabled(t *testing.T) { "0xNode", false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1741,7 +1731,7 @@ func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1832,7 +1822,7 @@ func TestSubmitAppState_CloseIntent_DuplicateAllocation_Rejected(t *testing.T) { "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1925,7 +1915,7 @@ func TestSubmitAppState_OperateIntent_DuplicateAllocation_Rejected(t *testing.T) "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" diff --git a/nitronode/api/app_session_v1/submit_deposit_state.go b/nitronode/api/app_session_v1/submit_deposit_state.go index 274a2429e..15436e9c7 100644 --- a/nitronode/api/app_session_v1/submit_deposit_state.go +++ b/nitronode/api/app_session_v1/submit_deposit_state.go @@ -104,12 +104,12 @@ func (h *Handler) SubmitDepositState(c *rpc.Context) { return rpc.Errorf("user state transition must have 'commit' type, got '%s'", lastTransition.Type.String()) } - approvedSigValidators, userHasOpenChannel, err := tx.CheckOpenChannel(userState.UserWallet, userState.Asset) + approvedSigValidators, channelStatus, err := tx.CheckActiveChannel(userState.UserWallet, userState.Asset) if err != nil { - return rpc.Errorf("failed to check open channel: %v", err) + return rpc.Errorf("failed to check active channel: %v", err) } - if !userHasOpenChannel { - return rpc.Errorf("user has no open channel") + if channelStatus == nil { + return rpc.Errorf("user has no active channel") } if lastTransition.AccountID != appStateUpd.AppSessionID { diff --git a/nitronode/api/app_session_v1/submit_deposit_state_test.go b/nitronode/api/app_session_v1/submit_deposit_state_test.go index 3cf476aab..0ee18095c 100644 --- a/nitronode/api/app_session_v1/submit_deposit_state_test.go +++ b/nitronode/api/app_session_v1/submit_deposit_state_test.go @@ -192,7 +192,7 @@ func TestSubmitDepositState_Success(t *testing.T) { // Mock expectations mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() - mockStore.On("CheckOpenChannel", participant1, asset).Return("0x03", true, nil).Once() + mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() mockStore.On("GetLastUserState", participant1, asset, false).Return(currentUserState, nil).Once() mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) @@ -541,7 +541,7 @@ func TestSubmitDepositState_QuorumNotMet(t *testing.T) { // Mock expectations mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() - mockStore.On("CheckOpenChannel", participant1, asset).Return("0x03", true, nil).Once() + mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() mockStore.On("GetLastUserState", participant1, asset, false).Return(currentUserState, nil).Once() mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) @@ -688,7 +688,7 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { // NO GetApp mock — it should not be called mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() - mockStore.On("CheckOpenChannel", participant1, asset).Return("0x03", true, nil).Once() + mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() mockStore.On("GetLastUserState", participant1, asset, false).Return(currentUserState, nil).Once() mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) @@ -833,7 +833,7 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() - mockStore.On("CheckOpenChannel", participant1, asset).Return("0x03", true, nil).Once() + mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() mockStore.On("GetLastUserState", participant1, asset, false).Return(currentUserState, nil).Once() mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) @@ -966,7 +966,7 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() - mockStore.On("CheckOpenChannel", participant1, asset).Return("0x03", true, nil).Once() + mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() mockStore.On("GetLastUserState", participant1, asset, false).Return(currentUserState, nil).Once() mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) diff --git a/nitronode/api/app_session_v1/submit_session_key_state.go b/nitronode/api/app_session_v1/submit_session_key_state.go index dd8b096db..14d9b83b0 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state.go +++ b/nitronode/api/app_session_v1/submit_session_key_state.go @@ -1,16 +1,15 @@ package app_session_v1 import ( + "errors" "strings" "time" - "github.com/ethereum/go-ethereum/common/hexutil" - + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/log" "github.com/layer-3/nitrolite/pkg/rpc" - "github.com/layer-3/nitrolite/pkg/sign" ) // SubmitSessionKeyState processes session key state submissions for registration and updates. @@ -49,6 +48,11 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return } + if strings.EqualFold(coreState.UserAddress, coreState.SessionKey) { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key must differ from user_address"), "") + return + } + if coreState.Version == 0 { c.Fail(rpc.Errorf("invalid_session_key_state: version must be greater than 0"), "") return @@ -69,46 +73,52 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: user_sig is required"), "") return } - - // Pack the session key state for signature verification (ABI encoding) - packedState, err := app.PackAppSessionKeyStateV1(coreState) - if err != nil { - c.Fail(rpc.Errorf("invalid_session_key_state: failed to pack state: %v", err), "") + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") return } - // Decode the user signature - sigBytes, err := hexutil.Decode(coreState.UserSig) - if err != nil { - c.Fail(rpc.Errorf("invalid_session_key_state: failed to decode user_sig: %v", err), "") - return - } - - // Recover signer address from signature using ECDSA recovery - ethMsgRecoverer, err := sign.NewSigValidator(sign.TypeEthereumMsg) - if err != nil { - c.Fail(rpc.Errorf("internal_error: failed to create signature validator: %v", err), "") - return - } - - recoveredAddress, err := ethMsgRecoverer.Recover(packedState, sigBytes) - if err != nil { - c.Fail(rpc.Errorf("invalid_session_key_state: failed to recover signer: %v", err), "") - return - } - - // Verify the recovered address matches user_address - if !strings.EqualFold(recoveredAddress, coreState.UserAddress) { - c.Fail(rpc.Errorf("invalid_session_key_state: signature does not match user_address"), "") + // Validate both signatures: wallet's UserSig and session-key holder's SessionKeySig. + if err := app.ValidateAppSessionKeyStateV1(coreState); err != nil { + c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") return } // Validate version and store the session key state err = h.useStoreInTx(func(tx Store) error { - // Check the latest version for this (user_address, session_key) pair; 0 means no state exists - latestVersion, err := tx.GetLastAppSessionKeyVersion(coreState.UserAddress, coreState.SessionKey) + // Lock the (user, session_key, app_session) pointer row for the duration of the tx so + // that concurrent submits for the same (user, session_key) serialize cleanly and report + // a proper "expected version" error rather than racing on the history UNIQUE constraint. + latestVersion, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindAppSession) if err != nil { - return rpc.Errorf("failed to check existing session key state: %v", err) + if errors.Is(err, database.ErrSessionKeyNotAllowed) { + logger.Warn("session key registration collision", + "userAddress", coreState.UserAddress, + "sessionKey", coreState.SessionKey, + "kind", database.SessionKeyKindAppSession) + return rpc.Errorf("invalid_session_key_state: session_key not allowed") + } + return rpc.Errorf("failed to lock session key state: %v", err) + } + + // Enforce the per-user cap when registering a new session key. Existing keys (latestVersion > 0) + // can always be updated regardless of the cap so that legitimate rotation is never blocked. + // + // TODO(MF-H01-followup): the row lock above only serializes submits for the same + // (user, session_key, kind), so two concurrent submits registering *different* new keys + // for the same user can both observe the same count and both pass the check, ending up + // at most maxSessionKeysPerUser + (concurrent new-key writers - 1) keys. The cap is a + // soft DOS bound, not a hard quota — a small over-shoot under genuine concurrency is + // acceptable. If a hard quota is ever required, take a per-user advisory lock here + // (pg_advisory_xact_lock(hashtext(user_address))) before counting. + if latestVersion == 0 && h.maxSessionKeysPerUser > 0 { + count, err := tx.CountSessionKeysForUser(coreState.UserAddress) + if err != nil { + return rpc.Errorf("failed to count session keys for user: %v", err) + } + if count >= uint32(h.maxSessionKeysPerUser) { + return rpc.Errorf("invalid_session_key_state: user has reached the session key limit of %d", h.maxSessionKeysPerUser) + } } if coreState.Version != latestVersion+1 { diff --git a/nitronode/api/app_session_v1/submit_session_key_state_test.go b/nitronode/api/app_session_v1/submit_session_key_state_test.go index 0f714d0da..2a7fc0fbc 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state_test.go +++ b/nitronode/api/app_session_v1/submit_session_key_state_test.go @@ -14,13 +14,16 @@ import ( "github.com/stretchr/testify/require" "github.com/layer-3/nitrolite/nitronode/metrics" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/rpc" "github.com/layer-3/nitrolite/pkg/sign" ) // buildSignedSessionKeyStateReq creates a properly signed SubmitSessionKeyState request. -func buildSignedSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, applicationIDs, appSessionIDs []string, expiresAt time.Time, signer sign.Signer) rpc.AppSessionsV1SubmitSessionKeyStateRequest { +// signer signs the wallet UserSig; keySigner signs the SessionKeySig over the same packed +// bytes. Pass nil for keySigner to omit the field (for negative-path tests). +func buildSignedSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, applicationIDs, appSessionIDs []string, expiresAt time.Time, signer, keySigner sign.Signer) rpc.AppSessionsV1SubmitSessionKeyStateRequest { t.Helper() if applicationIDs == nil { @@ -45,17 +48,23 @@ func buildSignedSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, sig, err := signer.Sign(packed) require.NoError(t, err) - return rpc.AppSessionsV1SubmitSessionKeyStateRequest{ - State: rpc.AppSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Version: strconv.FormatUint(version, 10), - ApplicationIDs: applicationIDs, - AppSessionIDs: appSessionIDs, - ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), - UserSig: hexutil.Encode(sig), - }, + state := rpc.AppSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Version: strconv.FormatUint(version, 10), + ApplicationIDs: applicationIDs, + AppSessionIDs: appSessionIDs, + ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), + UserSig: hexutil.Encode(sig), + } + + if keySigner != nil { + keySig, err := keySigner.Sign(packed) + require.NoError(t, err) + state.SessionKeySig = hexutil.Encode(keySig) } + + return rpc.AppSessionsV1SubmitSessionKeyStateRequest{State: state} } func TestSubmitSessionKeyState_Success(t *testing.T) { @@ -80,9 +89,9 @@ func TestSubmitSessionKeyState_Success(t *testing.T) { appIDs := []string{"0x1111111111111111111111111111111111111111111111111111111111111111"} sessionIDs := []string{"0x2222222222222222222222222222222222222222222222222222222222222222"} - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner, sessionKeySigner) - mockStore.On("GetLastAppSessionKeyVersion", userAddress, sessionKeyAddress).Return(uint64(0), nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -225,9 +234,9 @@ func TestSubmitSessionKeyState_AtMaxLimit(t *testing.T) { "0x4444444444444444444444444444444444444444444444444444444444444444", } - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner, sessionKeySigner) - mockStore.On("GetLastAppSessionKeyVersion", userAddress, sessionKeyAddress).Return(uint64(0), nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -377,9 +386,9 @@ func TestSubmitSessionKeyState_VersionMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Submit version 3 when latest is 0 (expects 1) - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, []string{}, expiresAt, userSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, []string{}, expiresAt, userSigner, sessionKeySigner) - mockStore.On("GetLastAppSessionKeyVersion", userAddress, sessionKeyAddress).Return(uint64(0), nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -398,6 +407,84 @@ func TestSubmitSessionKeyState_VersionMismatch(t *testing.T) { mockStore.AssertExpectations(t) } +func TestSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + maxSessionKeysPerUser: 3, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) + mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session key limit of 3") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreAppSessionKeyState", mock.Anything) +} + +func TestSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + maxSessionKeysPerUser: 3, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, nil, nil, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(4, nil) + mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -417,11 +504,78 @@ func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Sign with differentSigner but claim userAddress - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, []string{}, expiresAt, differentSigner) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, []string{}, expiresAt, differentSigner, sessionKeySigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "user_sig does not match user_address") +} + +func TestSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // Use the wallet as its own session key — must be rejected outright. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, userAddress, 1, nil, nil, expiresAt, userSigner, userSigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key must differ from user_address") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // keySigner=nil → SessionKeySig field stays empty in the request. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, nil) + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) ctx := &rpc.Context{ Context: context.Background(), Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), @@ -432,5 +586,80 @@ func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "signature does not match user_address") + assert.Contains(t, respErr.Error(), "session_key_sig is required") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + otherSigner := NewMockSigner() + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // SessionKeySig produced by an unrelated key — declared session_key won't match the recovered address. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, otherSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig does not match session_key") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession). + Return(0, database.ErrSessionKeyNotAllowed) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key not allowed") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreAppSessionKeyState", mock.Anything) } diff --git a/nitronode/api/app_session_v1/testing.go b/nitronode/api/app_session_v1/testing.go index f5c997af3..a0770bdda 100644 --- a/nitronode/api/app_session_v1/testing.go +++ b/nitronode/api/app_session_v1/testing.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/layer-3/nitrolite/nitronode/action_gateway" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/sign" @@ -78,9 +79,14 @@ func (m *MockStore) RecordTransaction(tx core.Transaction, applicationID string) return args.Error(0) } -func (m *MockStore) CheckOpenChannel(wallet, asset string) (string, bool, error) { +func (m *MockStore) CheckActiveChannel(wallet, asset string) (string, *core.ChannelStatus, error) { args := m.Called(wallet, asset) - return args.String(0), args.Bool(1), args.Error(2) + var status *core.ChannelStatus + if v := args.Get(1); v != nil { + s := v.(core.ChannelStatus) + status = &s + } + return args.String(0), status, args.Error(2) } func (m *MockStore) LockUserState(wallet, asset string) (decimal.Decimal, error) { @@ -107,6 +113,21 @@ func (m *MockStore) EnsureNoOngoingStateTransitions(wallet, asset string) error return args.Error(0) } +func (m *MockStore) EnsureNoOngoingEscrowOperation(wallet, asset string) error { + args := m.Called(wallet, asset) + return args.Error(0) +} + +func (m *MockStore) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) { + args := m.Called(userAddress, sessionKey, kind) + return uint64(args.Int(0)), args.Error(1) +} + +func (m *MockStore) CountSessionKeysForUser(userAddress string) (uint32, error) { + args := m.Called(userAddress) + return uint32(args.Int(0)), args.Error(1) +} + func (m *MockStore) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error { args := m.Called(state) return args.Error(0) @@ -117,12 +138,12 @@ func (m *MockStore) GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint return args.Get(0).(uint64), args.Error(1) } -func (m *MockStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string) ([]app.AppSessionKeyStateV1, error) { - args := m.Called(wallet, sessionKey) +func (m *MockStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) { + args := m.Called(wallet, sessionKey, includeInactive, limit, offset) if args.Get(0) == nil { - return nil, args.Error(1) + return nil, uint32(args.Int(1)), args.Error(2) } - return args.Get(0).([]app.AppSessionKeyStateV1), args.Error(1) + return args.Get(0).([]app.AppSessionKeyStateV1), uint32(args.Int(1)), args.Error(2) } func (m *MockStore) GetAppSessionKeyOwner(sessionKey, appSessionId string) (string, error) { diff --git a/nitronode/api/app_session_v1/utils.go b/nitronode/api/app_session_v1/utils.go index 4381b2d62..974553b31 100644 --- a/nitronode/api/app_session_v1/utils.go +++ b/nitronode/api/app_session_v1/utils.go @@ -270,6 +270,7 @@ func unmapSessionKeyStateV1(state *rpc.AppSessionKeyStateV1) (app.AppSessionKeyS AppSessionIDs: appSessionIDs, ExpiresAt: time.Unix(expiresAtUnix, 0), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } @@ -283,6 +284,7 @@ func mapSessionKeyStateV1(state *app.AppSessionKeyStateV1) rpc.AppSessionKeyStat AppSessionIDs: state.AppSessionIDs, ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } diff --git a/nitronode/api/channel_v1/get_channels.go b/nitronode/api/channel_v1/get_channels.go index 7861370a7..fb74b74fb 100644 --- a/nitronode/api/channel_v1/get_channels.go +++ b/nitronode/api/channel_v1/get_channels.go @@ -16,6 +16,8 @@ func channelStatusFromString(s string) (core.ChannelStatus, error) { return core.ChannelStatusOpen, nil case "challenged": return core.ChannelStatusChallenged, nil + case "closing": + return core.ChannelStatusClosing, nil case "closed": return core.ChannelStatusClosed, nil default: diff --git a/nitronode/api/channel_v1/get_home_channel.go b/nitronode/api/channel_v1/get_home_channel.go index bba432ec1..a6595084a 100644 --- a/nitronode/api/channel_v1/get_home_channel.go +++ b/nitronode/api/channel_v1/get_home_channel.go @@ -23,7 +23,7 @@ func (h *Handler) GetHomeChannel(c *rpc.Context) { var channel *core.Channel err = h.useStoreInTx(func(tx Store) error { var err error - channel, err = tx.GetActiveHomeChannel(req.Wallet, req.Asset) + channel, err = tx.GetNotClosedHomeChannel(req.Wallet, req.Asset) if err != nil { return rpc.Errorf("failed to get home channel: %v", err) } diff --git a/nitronode/api/channel_v1/get_home_channel_test.go b/nitronode/api/channel_v1/get_home_channel_test.go index 17767e7e0..b9b1b1d0a 100644 --- a/nitronode/api/channel_v1/get_home_channel_test.go +++ b/nitronode/api/channel_v1/get_home_channel_test.go @@ -59,7 +59,7 @@ func TestGetHomeChannel_Success(t *testing.T) { } // Mock expectations - mockTxStore.On("GetActiveHomeChannel", userWallet, asset).Return(&homeChannel, nil) + mockTxStore.On("GetNotClosedHomeChannel", userWallet, asset).Return(&homeChannel, nil) // Create RPC request reqPayload := rpc.ChannelsV1GetHomeChannelRequest{ @@ -133,7 +133,7 @@ func TestGetHomeChannel_NotFound(t *testing.T) { asset := "USDC" // Mock expectations - mockTxStore.On("GetActiveHomeChannel", userWallet, asset).Return(nil, nil) + mockTxStore.On("GetNotClosedHomeChannel", userWallet, asset).Return(nil, nil) // Create RPC request reqPayload := rpc.ChannelsV1GetHomeChannelRequest{ @@ -164,6 +164,47 @@ func TestGetHomeChannel_NotFound(t *testing.T) { mockTxStore.AssertExpectations(t) } +// TestGetHomeChannel_ClosingChannel verifies that GetHomeChannel returns the channel data +// even after an off-chain Finalize has flipped it to Closing, so the SDK can still fetch +// it before submitting the on-chain close. +func TestGetHomeChannel_ClosingChannel(t *testing.T) { + mockTxStore := new(MockStore) + + handler := &Handler{ + useStoreInTx: func(h StoreTxHandler) error { return h(mockTxStore) }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + } + + userWallet := "0x1234567890123456789012345678901234567890" + asset := "USDC" + closingChannel := core.Channel{ + ChannelID: "0xHomeChannel123", + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + Status: core.ChannelStatusClosing, + StateVersion: 5, + } + mockTxStore.On("GetNotClosedHomeChannel", userWallet, asset).Return(&closingChannel, nil) + + payload, err := rpc.NewPayload(rpc.ChannelsV1GetHomeChannelRequest{Wallet: userWallet, Asset: asset}) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.get_home_channel", Payload: payload}, + } + + handler.GetHomeChannel(ctx) + + require.Nil(t, ctx.Response.Error(), "Closing channel must be visible to GetHomeChannel") + var response rpc.ChannelsV1GetHomeChannelResponse + require.NoError(t, ctx.Response.Payload.Translate(&response)) + assert.Equal(t, "closing", response.Channel.Status) + mockTxStore.AssertExpectations(t) +} + // TestGetHomeChannel_NormalizesWallet verifies the wallet is normalized before the store call. func TestGetHomeChannel_NormalizesWallet(t *testing.T) { mockTxStore := new(MockStore) @@ -178,7 +219,7 @@ func TestGetHomeChannel_NormalizesWallet(t *testing.T) { asset := "USDC" homeChannel := core.Channel{ChannelID: "0xch", UserWallet: canonicalWallet, Asset: asset, Type: core.ChannelTypeHome} - mockTxStore.On("GetActiveHomeChannel", canonicalWallet, asset).Return(&homeChannel, nil) + mockTxStore.On("GetNotClosedHomeChannel", canonicalWallet, asset).Return(&homeChannel, nil) reqPayload := rpc.ChannelsV1GetHomeChannelRequest{Wallet: mixedCaseWallet, Asset: asset} payload, err := rpc.NewPayload(reqPayload) diff --git a/nitronode/api/channel_v1/get_last_key_states.go b/nitronode/api/channel_v1/get_last_key_states.go index 001d96008..69288cce6 100644 --- a/nitronode/api/channel_v1/get_last_key_states.go +++ b/nitronode/api/channel_v1/get_last_key_states.go @@ -7,6 +7,7 @@ import ( ) // GetLastKeyStates retrieves the latest channel session key states for a user with optional filtering by session key. +// Mandatory pagination caps response size to prevent unbounded reads. func (h *Handler) GetLastKeyStates(c *rpc.Context) { ctx := c.Context logger := log.FromContext(ctx) @@ -22,15 +23,42 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { return } + var limit, offset uint32 + if req.Pagination != nil { + // The endpoint orders rows by (created_at DESC, id ASC) for stable pagination; + // callers cannot override this, so any sort value is rejected rather than silently + // ignored. PaginationParamsV1.Sort is shared across the v1 API and other endpoints + // honor it, which is why we validate here instead of dropping the field. + if req.Pagination.Sort != nil && *req.Pagination.Sort != "" { + c.Fail(rpc.Errorf("invalid_pagination: sort is not supported by get_last_key_states"), "") + return + } + if req.Pagination.Limit != nil { + limit = *req.Pagination.Limit + } + if req.Pagination.Offset != nil { + offset = *req.Pagination.Offset + } + } + if limit == 0 || limit > rpc.GetLastKeyStatesPageLimit { + limit = rpc.GetLastKeyStatesPageLimit + } + + includeInactive := req.IncludeInactive != nil && *req.IncludeInactive + logger.Debug("retrieving channel session key states", "userAddress", req.UserAddress, - "sessionKey", req.SessionKey) + "sessionKey", req.SessionKey, + "includeInactive", includeInactive, + "limit", limit, + "offset", offset) var states []core.ChannelSessionKeyStateV1 + var totalCount uint32 err := h.useStoreInTx(func(tx Store) error { var err error - states, err = tx.GetLastChannelSessionKeyStates(req.UserAddress, req.SessionKey) + states, totalCount, err = tx.GetLastChannelSessionKeyStates(req.UserAddress, req.SessionKey, includeInactive, limit, offset) return err }) @@ -46,7 +74,8 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { } resp := rpc.ChannelsV1GetLastKeyStatesResponse{ - States: rpcStates, + States: rpcStates, + Metadata: buildPageMetadata(totalCount, limit, offset), } payload, err := rpc.NewPayload(resp) @@ -57,3 +86,27 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { c.Succeed(c.Request.Method, payload) } + +// buildPageMetadata returns the standard pagination metadata for get_last_key_states. +// Page is 1-based and defaults to 1 (including the empty-result case, so the metadata is +// never `{page: 0, page_count: 0}`). For non-aligned offsets the page formula treats the +// offset as a row-skip count and reports the page that contains row `offset+1` — callers +// that need exact page semantics should pass offset as a multiple of limit. +func buildPageMetadata(totalCount, limit, offset uint32) rpc.PaginationMetadataV1 { + page := uint32(1) + if limit > 0 && offset >= limit { + page = (offset / limit) + 1 + } + + var pageCount uint32 + if totalCount > 0 && limit > 0 { + pageCount = (totalCount + limit - 1) / limit + } + + return rpc.PaginationMetadataV1{ + Page: page, + PerPage: limit, + TotalCount: totalCount, + PageCount: pageCount, + } +} diff --git a/nitronode/api/channel_v1/get_last_key_states_test.go b/nitronode/api/channel_v1/get_last_key_states_test.go new file mode 100644 index 000000000..d4cd37034 --- /dev/null +++ b/nitronode/api/channel_v1/get_last_key_states_test.go @@ -0,0 +1,167 @@ +package channel_v1 + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitrolite/pkg/core" + "github.com/layer-3/nitrolite/pkg/rpc" +) + +func newGetLastKeyStatesHandler(store Store) *Handler { + return &Handler{ + useStoreInTx: func(fn StoreTxHandler) error { + return fn(store) + }, + } +} + +func callGetLastKeyStates(t *testing.T, h *Handler, req rpc.ChannelsV1GetLastKeyStatesRequest) *rpc.Context { + t.Helper() + payload, err := rpc.NewPayload(req) + require.NoError(t, err) + c := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1GetLastKeyStatesMethod.String(), payload), + } + h.GetLastKeyStates(c) + return c +} + +func extractGetLastKeyStatesResponse(t *testing.T, c *rpc.Context) rpc.ChannelsV1GetLastKeyStatesResponse { + t.Helper() + require.NotNil(t, c.Response) + require.Nil(t, c.Response.Error()) + var resp rpc.ChannelsV1GetLastKeyStatesResponse + require.NoError(t, c.Response.Payload.Translate(&resp)) + return resp +} + +func TestChannelGetLastKeyStates_DefaultsToPageOneOnEmptyResult(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(0)). + Return([]core.ChannelSessionKeyStateV1{}, 0, nil) + + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{UserAddress: "0xuser"}) + resp := extractGetLastKeyStatesResponse(t, c) + + assert.Empty(t, resp.States) + assert.Equal(t, uint32(1), resp.Metadata.Page) + assert.Equal(t, uint32(10), resp.Metadata.PerPage) + assert.Equal(t, uint32(0), resp.Metadata.TotalCount) + assert.Equal(t, uint32(0), resp.Metadata.PageCount) +} + +func TestChannelGetLastKeyStates_PaginationMetadata_AlignedOffset(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + limit := uint32(10) + offset := uint32(10) + pagination := &rpc.PaginationParamsV1{Limit: &limit, Offset: &offset} + + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(10)). + Return([]core.ChannelSessionKeyStateV1{ + {UserAddress: "0xuser", SessionKey: "0xkey", Version: 1, ExpiresAt: time.Now().Add(time.Hour)}, + }, 25, nil) + + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + Pagination: pagination, + }) + resp := extractGetLastKeyStatesResponse(t, c) + + assert.Equal(t, uint32(2), resp.Metadata.Page) + assert.Equal(t, uint32(3), resp.Metadata.PageCount) + assert.Equal(t, uint32(25), resp.Metadata.TotalCount) +} + +func TestChannelGetLastKeyStates_ClampsLimitToMax(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + excessive := uint32(1000) + pagination := &rpc.PaginationParamsV1{Limit: &excessive} + + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), false, rpc.GetLastKeyStatesPageLimit, uint32(0)). + Return([]core.ChannelSessionKeyStateV1{}, 0, nil) + + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + Pagination: pagination, + }) + resp := extractGetLastKeyStatesResponse(t, c) + + assert.Equal(t, rpc.GetLastKeyStatesPageLimit, resp.Metadata.PerPage) + mockStore.AssertExpectations(t) +} + +func TestChannelGetLastKeyStates_RejectsSortField(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + sort := "asc" + pagination := &rpc.PaginationParamsV1{Sort: &sort} + + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + Pagination: pagination, + }) + + require.NotNil(t, c.Response) + require.NotNil(t, c.Response.Error()) + assert.Contains(t, c.Response.Error().Error(), "sort is not supported") + mockStore.AssertNotCalled(t, "GetLastChannelSessionKeyStates", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelGetLastKeyStates_RequiresUserAddress(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{}) + + require.NotNil(t, c.Response) + require.NotNil(t, c.Response.Error()) + assert.Contains(t, c.Response.Error().Error(), "user_address is required") +} + +func TestChannelGetLastKeyStates_IncludeInactiveTruePlumbsToStore(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), true, uint32(10), uint32(0)). + Return([]core.ChannelSessionKeyStateV1{}, 0, nil) + + includeInactive := true + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + IncludeInactive: &includeInactive, + }) + _ = extractGetLastKeyStatesResponse(t, c) + + mockStore.AssertExpectations(t) +} + +func TestChannelGetLastKeyStates_IncludeInactiveFalsePlumbsToStore(t *testing.T) { + mockStore := new(MockStore) + h := newGetLastKeyStatesHandler(mockStore) + + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(0)). + Return([]core.ChannelSessionKeyStateV1{}, 0, nil) + + includeInactive := false + c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{ + UserAddress: "0xuser", + IncludeInactive: &includeInactive, + }) + _ = extractGetLastKeyStatesResponse(t, c) + + mockStore.AssertExpectations(t) +} diff --git a/nitronode/api/channel_v1/handler.go b/nitronode/api/channel_v1/handler.go index b32fec5ac..95ae37012 100644 --- a/nitronode/api/channel_v1/handler.go +++ b/nitronode/api/channel_v1/handler.go @@ -12,17 +12,18 @@ import ( // Handler manages channel state transitions and provides RPC endpoints for state submission. type Handler struct { - useStoreInTx StoreTxProvider - memoryStore MemoryStore - actionGateway ActionGateway - nodeSigner *core.ChannelDefaultSigner - stateAdvancer core.StateAdvancer - statePacker core.StatePacker - nodeAddress string // Node's wallet address for channel ID calculation - minChallenge uint32 - maxChallenge uint32 - metrics metrics.RuntimeMetricExporter - maxSessionKeyIDs int + useStoreInTx StoreTxProvider + memoryStore MemoryStore + actionGateway ActionGateway + nodeSigner *core.ChannelDefaultSigner + stateAdvancer core.StateAdvancer + statePacker core.StatePacker + nodeAddress string // Node's wallet address for channel ID calculation + minChallenge uint32 + maxChallenge uint32 + metrics metrics.RuntimeMetricExporter + maxSessionKeyIDs int + maxSessionKeysPerUser int } // NewHandler creates a new Handler instance with the provided dependencies. @@ -37,19 +38,21 @@ func NewHandler( minChallenge, maxChallenge uint32, m metrics.RuntimeMetricExporter, maxSessionKeyIDs int, + maxSessionKeysPerUser int, ) *Handler { return &Handler{ - stateAdvancer: stateAdvancer, - statePacker: statePacker, - useStoreInTx: useStoreInTx, - memoryStore: memoryStore, - actionGateway: actionGateway, - nodeSigner: nodeSigner, - nodeAddress: nodeAddress, - minChallenge: minChallenge, - maxChallenge: maxChallenge, - metrics: m, - maxSessionKeyIDs: maxSessionKeyIDs, + stateAdvancer: stateAdvancer, + statePacker: statePacker, + useStoreInTx: useStoreInTx, + memoryStore: memoryStore, + actionGateway: actionGateway, + nodeSigner: nodeSigner, + nodeAddress: nodeAddress, + minChallenge: minChallenge, + maxChallenge: maxChallenge, + metrics: m, + maxSessionKeyIDs: maxSessionKeyIDs, + maxSessionKeysPerUser: maxSessionKeysPerUser, } } @@ -107,14 +110,8 @@ func (h *Handler) issueTransferReceiverState(ctx context.Context, tx Store, send return nil, err } - lastSignedState, err := tx.GetLastUserState(receiverWallet, senderState.Asset, true) - if err != nil { - return nil, rpc.Errorf("failed to get last %s user state for transfer receiver with address %s", senderState.Asset, receiverWallet) - } - - // TODO: move to DB query - if lastSignedState != nil && lastSignedState.EscrowChannelID != nil { - return nil, rpc.Errorf("cannot issue release receiver state: last signed state is a lock with escrow channel %s", *lastSignedState.EscrowChannelID) + if err := tx.EnsureNoOngoingEscrowOperation(receiverWallet, senderState.Asset); err != nil { + return nil, rpc.Errorf("cannot issue transfer receiver state: %v", err) } if newState.HomeChannelID != nil { diff --git a/nitronode/api/channel_v1/interface.go b/nitronode/api/channel_v1/interface.go index 393b757ec..37e8dd0f1 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -2,6 +2,7 @@ package channel_v1 import ( "github.com/layer-3/nitrolite/nitronode/action_gateway" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/core" "github.com/shopspring/decimal" ) @@ -27,9 +28,12 @@ type Store interface { // Returns nil state if no matching state exists. GetLastUserState(wallet, asset string, signed bool) (*core.State, error) - // CheckOpenChannel verifies if a user has an active channel for the given asset - // and returns the approved signature validators if such a channel exists. - CheckOpenChannel(wallet, asset string) (string, bool, error) + // CheckActiveChannel verifies if a user has an active home channel for the given asset + // and returns its approved signature validators and current status. A nil status means + // no active channel exists. "Active" includes Void (DB-only, awaiting onchain confirmation) + // and Open (materialized onchain); callers needing onchain materialization must additionally + // require Status == core.ChannelStatusOpen. + CheckActiveChannel(wallet, asset string) (string, *core.ChannelStatus, error) // StoreUserState persists a new user state to the database. // applicationID is the client-declared origin tag (rpc.ApplicationIDQueryParam); @@ -40,6 +44,11 @@ type Store interface { // that would conflict with submitting a new state transition. EnsureNoOngoingStateTransitions(wallet, asset string) error + // EnsureNoOngoingEscrowOperation validates that the user has no in-flight escrow + // operation (escrow_lock, mutual_lock, or unfinalized escrow_deposit/escrow_withdraw) + // that would prevent issuing a receiver-side state. + EnsureNoOngoingEscrowOperation(wallet, asset string) error + // ScheduleInitiateEscrowWithdrawal queues a blockchain action to initiate // withdrawal from an escrow channel (triggered by escrow_lock transition). ScheduleInitiateEscrowWithdrawal(stateID string, chainID uint64) error @@ -63,11 +72,32 @@ type Store interface { // Returns nil if no home channel exists for the given wallet and asset. GetActiveHomeChannel(wallet, asset string) (*core.Channel, error) + // GetNotClosedHomeChannel retrieves the home channel regardless of status as long as it + // is not Closed. Used by GetHomeChannel so the endpoint stays functional after an + // off-chain Finalize flips the channel to Closing. + GetNotClosedHomeChannel(wallet, asset string) (*core.Channel, error) + + // UpdateChannel persists changes to a channel's metadata (status, version, etc). + // The channel must already exist in the database. + UpdateChannel(channel core.Channel) error + + // HasNonClosedHomeChannel returns true if any home channel for (wallet, asset) has a + // status other than Closed, meaning a channel lifecycle is still in progress. + HasNonClosedHomeChannel(wallet, asset string) (bool, error) + // GetUserChannels retrieves all channels for a user with optional status, asset, and type filters. GetUserChannels(wallet string, status *core.ChannelStatus, asset *string, channelType *core.ChannelType, limit, offset uint32) ([]core.Channel, uint32, error) // Session key state operations + // LockSessionKeyState locks the (user, session_key, kind) pointer row for the surrounding + // transaction, returning the current version (0 if newly created). + LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) + + // CountSessionKeysForUser returns the number of distinct session keys for the wallet + // across both kinds, used to enforce the per-user cap at submit time. + CountSessionKeysForUser(userAddress string) (uint32, error) + // StoreChannelSessionKeyState persists a channel session key state. StoreChannelSessionKeyState(state core.ChannelSessionKeyStateV1) error @@ -76,8 +106,10 @@ type Store interface { GetLastChannelSessionKeyVersion(wallet, sessionKey string) (uint64, error) // GetLastChannelSessionKeyStates retrieves the latest channel session key states for a user, - // optionally filtered by session key. - GetLastChannelSessionKeyStates(wallet string, sessionKey *string) ([]core.ChannelSessionKeyStateV1, error) + // optionally filtered by session key. When includeInactive is false, only non-expired latest + // states are returned; when true, all latest states are returned regardless of expiry. + // Results are paginated. + GetLastChannelSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) // ValidateChannelSessionKeyForAsset checks that a valid, non-expired session key state // exists at its latest version for the (wallet, sessionKey) pair, includes the given asset, diff --git a/nitronode/api/channel_v1/request_creation.go b/nitronode/api/channel_v1/request_creation.go index bf264b781..c656317c1 100644 --- a/nitronode/api/channel_v1/request_creation.go +++ b/nitronode/api/channel_v1/request_creation.go @@ -88,6 +88,17 @@ func (h *Handler) RequestCreation(c *rpc.Context) { return rpc.Errorf("failed to lock user state: %v", err) } + // Reject if any home channel for this (wallet, asset) is not fully closed on-chain. + // This enforces one in-flight channel per asset and prevents epoch rebinding while + // a prior channel lifecycle is still pending settlement. + hasNonClosed, err := tx.HasNonClosedHomeChannel(incomingState.UserWallet, incomingState.Asset) + if err != nil { + return rpc.Errorf("failed to check channel lifecycle: %v", err) + } + if hasNonClosed { + return rpc.Errorf("existing channel is not yet closed on-chain; wait for settlement before opening a new channel") + } + // Check if channel already exists currentState, err := tx.GetLastUserState(incomingState.UserWallet, incomingState.Asset, false) if err != nil { diff --git a/nitronode/api/channel_v1/request_creation_test.go b/nitronode/api/channel_v1/request_creation_test.go index 72e8ab25d..584a354b8 100644 --- a/nitronode/api/channel_v1/request_creation_test.go +++ b/nitronode/api/channel_v1/request_creation_test.go @@ -91,6 +91,7 @@ func TestRequestCreation_Success(t *testing.T) { mockMemoryStore.On("IsAssetSupported", asset, tokenAddress, blockchainID).Return(true, nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil).Once() mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil).Once() + mockTxStore.On("HasNonClosedHomeChannel", userWallet, asset).Return(false, nil).Once() mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(nil, nil).Once() mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) mockTxStore.On("CreateChannel", mock.MatchedBy(func(channel core.Channel) bool { @@ -245,6 +246,7 @@ func TestRequestCreation_Acknowledgement_Success(t *testing.T) { mockMemoryStore.On("IsAssetSupported", asset, tokenAddress, blockchainID).Return(true, nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil).Once() mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil).Once() + mockTxStore.On("HasNonClosedHomeChannel", userWallet, asset).Return(false, nil).Once() mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(nil, nil).Once() mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) mockTxStore.On("CreateChannel", mock.MatchedBy(func(channel core.Channel) bool { @@ -522,3 +524,99 @@ func TestRequestCreation_ChallengeTooHigh(t *testing.T) { assert.Contains(t, err.Error(), "challenge") assert.Contains(t, err.Error(), "at most") } + +// TestRequestCreation_NonClosedChannelRejection verifies that opening a new channel is +// rejected while a prior channel is still in progress (Closing, Open, or Challenged), +// preventing epoch rebinding by ensuring only one channel lifecycle runs at a time. +func TestRequestCreation_NonClosedChannelRejection(t *testing.T) { + mockTxStore := new(MockStore) + mockMemoryStore := new(MockMemoryStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + nodeAddress := mockSigner.PublicKey().Address().String() + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockTxStore) + }, + memoryStore: mockMemoryStore, + nodeSigner: nodeSigner, + nodeAddress: nodeAddress, + minChallenge: uint32(3600), + maxChallenge: uint32(604800), + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + actionGateway: &MockActionGateway{}, + } + + userSigner := NewMockSigner() + userWallet := userSigner.PublicKey().Address().String() + asset := "USDC" + tokenAddress := "0xTokenAddress" + blockchainID := uint64(1) + nonce := uint64(99) + challenge := uint32(86400) + + homeChannelID, err := core.GetHomeChannelID(nodeAddress, userWallet, asset, nonce, challenge, "0x03") + require.NoError(t, err) + + mockMemoryStore.On("IsAssetSupported", asset, tokenAddress, blockchainID).Return(true, nil).Once() + + // Gate fires: a non-closed channel exists (e.g., the channel is Closing after off-chain Finalize). + mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil).Once() + mockTxStore.On("HasNonClosedHomeChannel", userWallet, asset).Return(true, nil).Once() + + reqPayload := rpc.ChannelsV1RequestCreationRequest{ + State: rpc.StateV1{ + ID: core.GetStateID(userWallet, asset, 1, 1), + UserWallet: userWallet, + Asset: asset, + Epoch: "1", + Version: "1", + HomeChannelID: &homeChannelID, + Transition: rpc.TransitionV1{Amount: "100"}, + HomeLedger: rpc.LedgerV1{ + TokenAddress: tokenAddress, + BlockchainID: "1", + UserBalance: "100", + UserNetFlow: "100", + NodeBalance: "0", + NodeNetFlow: "0", + }, + }, + ChannelDefinition: rpc.ChannelDefinitionV1{ + Nonce: strconv.FormatUint(nonce, 10), + Challenge: challenge, + ApprovedSigValidators: "0x03", + }, + } + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{ + RequestID: 1, + Method: rpc.ChannelsV1RequestCreationMethod.String(), + Payload: payload, + }, + } + + handler.RequestCreation(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.Error(t, respErr) + assert.Contains(t, respErr.Error(), "not yet closed") + + mockTxStore.AssertExpectations(t) + // GetLastUserState, CreateChannel, StoreUserState must NOT be called. + mockTxStore.AssertNotCalled(t, "GetLastUserState", mock.Anything, mock.Anything, mock.Anything) + mockTxStore.AssertNotCalled(t, "CreateChannel", mock.Anything) + mockTxStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) +} diff --git a/nitronode/api/channel_v1/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index 68de5c238..c3c9f8c40 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -1,8 +1,11 @@ package channel_v1 import ( + "errors" + "strings" "time" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/log" "github.com/layer-3/nitrolite/pkg/rpc" @@ -44,6 +47,11 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return } + if strings.EqualFold(coreState.UserAddress, coreState.SessionKey) { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key must differ from user_address"), "") + return + } + if coreState.Version == 0 { c.Fail(rpc.Errorf("invalid_session_key_state: version must be greater than 0"), "") return @@ -60,19 +68,52 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: user_sig is required"), "") return } + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") + return + } - // Validate user's signature over the session key state - if err := core.ValidateChannelSessionKeyAuthSigV1(coreState); err != nil { + // Validate both signatures: wallet's user_sig and session-key holder's session_key_sig. + if err := core.ValidateChannelSessionKeyStateV1(coreState); err != nil { c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") return } // Validate version and store the session key state err = h.useStoreInTx(func(tx Store) error { - // Check the latest version for this (user_address, session_key) pair; 0 means no state exists - latestVersion, err := tx.GetLastChannelSessionKeyVersion(coreState.UserAddress, coreState.SessionKey) + // Lock the (user, session_key, channel) pointer row for the duration of the tx so that + // concurrent submits for the same (user, session_key) serialize cleanly and report a + // proper "expected version" error rather than racing on the history UNIQUE constraint. + latestVersion, err := tx.LockSessionKeyState(coreState.UserAddress, coreState.SessionKey, database.SessionKeyKindChannel) if err != nil { - return rpc.Errorf("failed to check existing session key state: %v", err) + if errors.Is(err, database.ErrSessionKeyNotAllowed) { + logger.Warn("session key registration collision", + "userAddress", coreState.UserAddress, + "sessionKey", coreState.SessionKey, + "kind", database.SessionKeyKindChannel) + return rpc.Errorf("invalid_session_key_state: session_key not allowed") + } + return rpc.Errorf("failed to lock session key state: %v", err) + } + + // Enforce the per-user cap when registering a new session key. Existing keys (latestVersion > 0) + // can always be updated regardless of the cap so that legitimate rotation is never blocked. + // + // TODO(MF-H01-followup): the row lock above only serializes submits for the same + // (user, session_key, kind), so two concurrent submits registering *different* new keys + // for the same user can both observe the same count and both pass the check, ending up + // at most maxSessionKeysPerUser + (concurrent new-key writers - 1) keys. The cap is a + // soft DOS bound, not a hard quota — a small over-shoot under genuine concurrency is + // acceptable. If a hard quota is ever required, take a per-user advisory lock here + // (pg_advisory_xact_lock(hashtext(user_address))) before counting. + if latestVersion == 0 && h.maxSessionKeysPerUser > 0 { + count, err := tx.CountSessionKeysForUser(coreState.UserAddress) + if err != nil { + return rpc.Errorf("failed to count session keys for user: %v", err) + } + if count >= uint32(h.maxSessionKeysPerUser) { + return rpc.Errorf("invalid_session_key_state: user has reached the session key limit of %d", h.maxSessionKeysPerUser) + } } if coreState.Version != latestVersion+1 { diff --git a/nitronode/api/channel_v1/submit_session_key_state_test.go b/nitronode/api/channel_v1/submit_session_key_state_test.go index b50de2b9c..c09706ba0 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -14,20 +14,25 @@ import ( "github.com/stretchr/testify/require" "github.com/layer-3/nitrolite/nitronode/metrics" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/rpc" "github.com/layer-3/nitrolite/pkg/sign" ) // buildSignedChannelSessionKeyStateReq creates a properly signed ChannelsV1SubmitSessionKeyState request. -func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, assets []string, expiresAt time.Time, signer sign.Signer) rpc.ChannelsV1SubmitSessionKeyStateRequest { +// Both signer (wallet UserSig) and keySigner (SessionKeySig) sign over the same +// PackChannelKeyStateV1 payload. session_key is bound into the metadata hash, so a signature +// minted for one key cannot be replayed as ownership of another. Pass nil for keySigner to +// leave SessionKeySig empty for negative-path tests. +func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey string, version uint64, assets []string, expiresAt time.Time, signer, keySigner sign.Signer) rpc.ChannelsV1SubmitSessionKeyStateRequest { t.Helper() if assets == nil { assets = []string{} } - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(strings.ToLower(userAddress), version, assets, expiresAt.Unix()) require.NoError(t, err) packed, err := core.PackChannelKeyStateV1(strings.ToLower(sessionKey), metadataHash) @@ -36,16 +41,22 @@ func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey sig, err := signer.Sign(packed) require.NoError(t, err) - return rpc.ChannelsV1SubmitSessionKeyStateRequest{ - State: rpc.ChannelSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Version: strconv.FormatUint(version, 10), - Assets: assets, - ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), - UserSig: hexutil.Encode(sig), - }, + state := rpc.ChannelSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Version: strconv.FormatUint(version, 10), + Assets: assets, + ExpiresAt: strconv.FormatInt(expiresAt.Unix(), 10), + UserSig: hexutil.Encode(sig), + } + + if keySigner != nil { + keySig, err := keySigner.Sign(packed) + require.NoError(t, err) + state.SessionKeySig = hexutil.Encode(keySig) } + + return rpc.ChannelsV1SubmitSessionKeyStateRequest{State: state} } func TestChannelSubmitSessionKeyState_Success(t *testing.T) { @@ -66,9 +77,9 @@ func TestChannelSubmitSessionKeyState_Success(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) assets := []string{"USDC"} - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("GetLastChannelSessionKeyVersion", userAddress, sessionKeyAddress).Return(uint64(0), nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -144,9 +155,9 @@ func TestChannelSubmitSessionKeyState_AtMaxLimit(t *testing.T) { // Exactly at max (2) should pass validation assets := []string{"USDC", "ETH"} - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("GetLastChannelSessionKeyVersion", userAddress, sessionKeyAddress).Return(uint64(0), nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) payload, err := rpc.NewPayload(reqPayload) @@ -293,9 +304,9 @@ func TestChannelSubmitSessionKeyState_VersionMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Submit version 3 when latest is 0 (expects 1) - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, expiresAt, userSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, expiresAt, userSigner, sessionKeySigner) - mockStore.On("GetLastChannelSessionKeyVersion", userAddress, sessionKeyAddress).Return(uint64(0), nil) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -314,6 +325,85 @@ func TestChannelSubmitSessionKeyState_VersionMismatch(t *testing.T) { mockStore.AssertExpectations(t) } +func TestChannelSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + maxSessionKeysPerUser: 3, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) + mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session key limit of 3") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreChannelSessionKeyState", mock.Anything) +} + +func TestChannelSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + maxSessionKeysPerUser: 3, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // Existing key at version 4: submit version 5. Cap must NOT block updates. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(4, nil) + mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) +} + func TestChannelSubmitSessionKeyState_SignatureMismatch(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -333,7 +423,7 @@ func TestChannelSubmitSessionKeyState_SignatureMismatch(t *testing.T) { expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Sign with differentSigner but claim userAddress - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, expiresAt, differentSigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{}, expiresAt, differentSigner, sessionKeySigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -350,3 +440,144 @@ func TestChannelSubmitSessionKeyState_SignatureMismatch(t *testing.T) { require.NotNil(t, respErr) assert.Contains(t, respErr.Error(), "does not match wallet") } + +func TestChannelSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, userAddress, 1, []string{"USDC"}, expiresAt, userSigner, userSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key must differ from user_address") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // keySigner=nil → SessionKeySig field stays empty. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig is required") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + otherSigner := NewMockSigner() + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + // SessionKeySig from a key that does not match the declared session_key. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, otherSigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key_sig does not match session_key") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + +func TestChannelSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) + sessionKeySigner := NewMockSigner() + sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) + + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) + + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel). + Return(0, database.ErrSessionKeyNotAllowed) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.ChannelsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "session_key not allowed") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "StoreChannelSessionKeyState", mock.Anything) +} diff --git a/nitronode/api/channel_v1/submit_state.go b/nitronode/api/channel_v1/submit_state.go index 650cfe06d..b5fa1e349 100644 --- a/nitronode/api/channel_v1/submit_state.go +++ b/nitronode/api/channel_v1/submit_state.go @@ -49,12 +49,12 @@ func (h *Handler) SubmitState(c *rpc.Context) { return rpc.Errorf("failed to lock user state: %v", err) } - approvedSigValidators, userHasOpenChannel, err := tx.CheckOpenChannel(incomingState.UserWallet, incomingState.Asset) + approvedSigValidators, channelStatus, err := tx.CheckActiveChannel(incomingState.UserWallet, incomingState.Asset) if err != nil { - return rpc.Errorf("failed to check open channel: %v", err) + return rpc.Errorf("failed to check active channel: %v", err) } - if !userHasOpenChannel { - return rpc.Errorf("user has no open channel") + if channelStatus == nil { + return rpc.Errorf("user has no active channel") } logger.Debug("processing incoming state", @@ -70,6 +70,15 @@ func (h *Handler) SubmitState(c *rpc.Context) { // FIXME: // var extraTransitions []core.Transition switch incomingTransition.Type { + case core.TransitionTypeMutualLock, core.TransitionTypeEscrowLock: + // Reject before node signs. Home channel must be materialized onchain + // before co-signing escrow transitions — onchain _isChannelHomeChain() + // returns false while status == VOID, so initiateEscrowDeposit() on the + // home chain would not take the home-chain path until createChannel() runs. + if *channelStatus != core.ChannelStatusOpen { + return rpc.Errorf("home channel is not materialized onchain") + } + return rpc.Errorf("transition is not supported yet") case core.TransitionTypeEscrowDeposit, core.TransitionTypeEscrowWithdraw, core.TransitionTypeMigrate: return rpc.Errorf("transition is not supported yet") // latestStateVersion := currentState.Version @@ -167,6 +176,13 @@ func (h *Handler) SubmitState(c *rpc.Context) { return rpc.Errorf("failed to create transaction: %v", err) } case core.TransitionTypeMutualLock: + // Require home channel materialized onchain before co-signing a MutualLock. + // Onchain _isChannelHomeChain() returns false while status == VOID, so + // initiateEscrowDeposit() on the home chain would not take the home-chain + // path until the prior creation/checkpoint state is submitted via createChannel(). + if *channelStatus != core.ChannelStatusOpen { + return rpc.Errorf("home channel is not materialized onchain") + } return rpc.Errorf("transition is not supported yet") // if err := h.createEscrowChannel(tx, incomingState); err != nil { // return err @@ -177,6 +193,12 @@ func (h *Handler) SubmitState(c *rpc.Context) { // return rpc.Errorf("failed to create transaction: %v", err) // } case core.TransitionTypeEscrowLock: + // Require home channel materialized onchain before co-signing an EscrowLock. + // Same reason as MutualLock — the onchain home-chain path depends on the home + // channel having been created via createChannel() first. + if *channelStatus != core.ChannelStatusOpen { + return rpc.Errorf("home channel is not materialized onchain") + } return rpc.Errorf("transition is not supported yet") // if err := h.createEscrowChannel(tx, incomingState); err != nil { // return err @@ -213,10 +235,26 @@ func (h *Handler) SubmitState(c *rpc.Context) { // } // logger.Info("extra state issued", "userID", extraState.UserWallet, "asset", extraState.Asset, "version", extraState.Version) case core.TransitionTypeFinalize: + if *channelStatus != core.ChannelStatusOpen { + return rpc.Errorf("home channel is not materialized onchain") + } transaction, err = core.NewTransactionFromTransition(&incomingState, nil, incomingTransition) if err != nil { return rpc.Errorf("failed to create transaction: %v", err) } + // Atomically mark the channel as Closing so no further user-initiated state + // transitions are accepted until the on-chain close event is confirmed. + channel, err := tx.GetChannelByID(*incomingState.HomeChannelID) + if err != nil { + return rpc.Errorf("failed to get channel for finalize: %v", err) + } + if channel == nil { + return rpc.Errorf("channel not found for finalize: %s", *incomingState.HomeChannelID) + } + channel.Status = core.ChannelStatusClosing + if err := tx.UpdateChannel(*channel); err != nil { + return rpc.Errorf("failed to update channel status to closing: %v", err) + } case core.TransitionTypeMigrate: return rpc.Errorf("transition is not supported yet") // extraState, err := h.issueExtraState(ctx, tx, incomingState) diff --git a/nitronode/api/channel_v1/submit_state_test.go b/nitronode/api/channel_v1/submit_state_test.go index 5aedb6bff..52ba52dca 100644 --- a/nitronode/api/channel_v1/submit_state_test.go +++ b/nitronode/api/channel_v1/submit_state_test.go @@ -2,6 +2,7 @@ package channel_v1 import ( "context" + "fmt" "strconv" "strings" "testing" @@ -156,7 +157,7 @@ func TestSubmitState_TransferSend_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockTxStore.On("LockUserState", senderWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", senderWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", senderWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", senderWallet, asset, false).Return(currentSenderState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", senderWallet, asset).Return(nil) mockStatePacker.On("PackState", mock.Anything).Return(packedSenderState, nil).Maybe() @@ -164,7 +165,7 @@ func TestSubmitState_TransferSend_Success(t *testing.T) { // For issueTransferReceiverState mockTxStore.On("LockUserState", receiverWallet, asset).Return(decimal.Zero, nil) mockTxStore.On("GetLastUserState", receiverWallet, asset, false).Return(currentReceiverState, nil) - mockTxStore.On("GetLastUserState", receiverWallet, asset, true).Return(nil, nil) + mockTxStore.On("EnsureNoOngoingEscrowOperation", receiverWallet, asset).Return(nil) mockTxStore.On("StoreUserState", mock.MatchedBy(func(state core.State) bool { // Verify receiver state return state.UserWallet == receiverWallet && @@ -324,22 +325,11 @@ func TestSubmitState_TransferSend_ReceiverWithEscrowLock_Rejected(t *testing.T) NodeSig: nil, } - // Receiver's last signed state has an active escrow channel - escrowChannelID := "0xEscrowChannel456" - lastSignedReceiverState := core.State{ - Asset: asset, - UserWallet: receiverWallet, - Epoch: 1, - Version: 1, - HomeChannelID: &homeChannelID, - EscrowChannelID: &escrowChannelID, - } - // Mock expectations mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockTxStore.On("LockUserState", senderWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", senderWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", senderWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", senderWallet, asset, false).Return(currentSenderState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", senderWallet, asset).Return(nil) mockStatePacker.On("PackState", mock.Anything).Return(packedSenderState, nil).Maybe() @@ -352,7 +342,7 @@ func TestSubmitState_TransferSend_ReceiverWithEscrowLock_Rejected(t *testing.T) // For issueTransferReceiverState - receiver has an active escrow lock mockTxStore.On("LockUserState", receiverWallet, asset).Return(decimal.Zero, nil) mockTxStore.On("GetLastUserState", receiverWallet, asset, false).Return(currentReceiverState, nil) - mockTxStore.On("GetLastUserState", receiverWallet, asset, true).Return(lastSignedReceiverState, nil) + mockTxStore.On("EnsureNoOngoingEscrowOperation", receiverWallet, asset).Return(fmt.Errorf("escrow lock is still ongoing")) // Create RPC request rpcState := toRPCState(*incomingSenderState) @@ -379,7 +369,7 @@ func TestSubmitState_TransferSend_ReceiverWithEscrowLock_Rejected(t *testing.T) require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr, "Expected error when receiver has active escrow lock") - assert.Contains(t, respErr.Error(), "last signed state is a lock with escrow channel") + assert.Contains(t, respErr.Error(), "escrow lock is still ongoing") mockTxStore.AssertExpectations(t) } @@ -455,7 +445,7 @@ func TestSubmitState_TransferSend_SameWalletCaseInsensitive_Rejected(t *testing. // Mock expectations — should reach the issueTransferReceiverState check mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockTxStore.On("LockUserState", senderWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", senderWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", senderWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", senderWallet, asset, false).Return(currentSenderState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", senderWallet, asset).Return(nil) mockStatePacker.On("PackState", mock.Anything).Return(packedSenderState, nil).Maybe() @@ -591,7 +581,7 @@ func TestSubmitState_EscrowLock_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(2), "0xTokenAddress").Return(uint8(6), nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) mockTxStore.On("GetChannelByID", homeChannelID).Return(&homeChannel, nil) @@ -751,7 +741,7 @@ func TestSubmitState_EscrowWithdraw_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(2), "0xTokenAddress").Return(uint8(6), nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentUnsignedState, nil) mockTxStore.On("GetLastUserState", userWallet, asset, true).Return(currentSignedState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) @@ -890,7 +880,7 @@ func TestSubmitState_HomeDeposit_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) @@ -1025,7 +1015,7 @@ func TestSubmitState_HomeWithdrawal_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) @@ -1179,7 +1169,7 @@ func TestSubmitState_MutualLock_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(2), "0xTokenAddress").Return(uint8(6), nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) mockTxStore.On("GetChannelByID", homeChannelID).Return(&homeChannel, nil) @@ -1340,7 +1330,7 @@ func TestSubmitState_EscrowDeposit_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(2), "0xTokenAddress").Return(uint8(6), nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentUnsignedState, nil) mockTxStore.On("GetLastUserState", userWallet, asset, true).Return(currentSignedState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) @@ -1480,11 +1470,19 @@ func TestSubmitState_Finalize_Success(t *testing.T) { mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil) mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) + openChannel := &core.Channel{ + ChannelID: homeChannelID, + Status: core.ChannelStatusOpen, + } + mockTxStore.On("GetChannelByID", homeChannelID).Return(openChannel, nil) + mockTxStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == homeChannelID && ch.Status == core.ChannelStatusClosing + })).Return(nil) mockTxStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool { return tx.TxType == core.TransactionTypeFinalize && tx.FromAccount == userWallet && @@ -1617,7 +1615,7 @@ func TestSubmitState_Acknowledgement_Success(t *testing.T) { // Mock expectations mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) - mockTxStore.On("CheckOpenChannel", userWallet, asset).Return("0x03", true, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil).Maybe() @@ -1668,6 +1666,274 @@ func TestSubmitState_Acknowledgement_Success(t *testing.T) { mockTxStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) } +func TestSubmitState_MutualLock_VoidHomeChannel_Rejected(t *testing.T) { + // Setup + mockTxStore := new(MockStore) + mockMemoryStore := new(MockMemoryStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + nodeAddress := mockSigner.PublicKey().Address().String() + minChallenge := uint32(3600) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockTxStore) + }, + memoryStore: mockMemoryStore, + nodeSigner: nodeSigner, + nodeAddress: nodeAddress, + minChallenge: minChallenge, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + actionGateway: &MockActionGateway{}, + } + + userSigner := NewMockSigner() + userWalletSigner, _ := core.NewChannelDefaultSigner(userSigner) + userWallet := userSigner.PublicKey().Address().String() + asset := "USDC" + homeChannelID := "0xHomeChannel123" + lockAmount := decimal.NewFromInt(100) + + currentState := core.State{ + ID: core.GetStateID(userWallet, asset, 1, 1), + Transition: core.Transition{}, + Asset: asset, + UserWallet: userWallet, + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + TokenAddress: "0xTokenAddress", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.NewFromInt(500), + NodeBalance: decimal.NewFromInt(0), + NodeNetFlow: decimal.NewFromInt(0), + }, + } + + incomingState := currentState.NextState() + _, err := incomingState.ApplyMutualLockTransition(2, "0xTokenAddress", lockAmount) + require.NoError(t, err) + + mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() + mockAssetStore.On("GetTokenDecimals", uint64(2), "0xTokenAddress").Return(uint8(6), nil).Maybe() + packedState, _ := core.PackState(*incomingState, mockAssetStore) + userSig, _ := userWalletSigner.Sign(packedState) + userSigStr := userSig.String() + incomingState.UserSig = &userSigStr + + mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) + mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusVoid, nil) + mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) + mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) + mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) + mockTxStore.On("StoreUserState", mock.Anything, mock.Anything).Return(nil).Maybe() + + rpcState := toRPCState(*incomingState) + reqPayload := rpc.ChannelsV1SubmitStateRequest{State: rpcState} + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.submit_state", Payload: payload}, + } + + handler.SubmitState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "Expected error when home channel is Void") + assert.Contains(t, respErr.Error(), "home channel is not materialized onchain") + + mockTxStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) + mockTxStore.AssertNotCalled(t, "CreateChannel", mock.Anything) +} + +func TestSubmitState_EscrowLock_VoidHomeChannel_Rejected(t *testing.T) { + // Setup + mockTxStore := new(MockStore) + mockMemoryStore := new(MockMemoryStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + nodeAddress := mockSigner.PublicKey().Address().String() + minChallenge := uint32(3600) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockTxStore) + }, + memoryStore: mockMemoryStore, + nodeSigner: nodeSigner, + nodeAddress: nodeAddress, + minChallenge: minChallenge, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + actionGateway: &MockActionGateway{}, + } + + userSigner := NewMockSigner() + userWalletSigner, _ := core.NewChannelDefaultSigner(userSigner) + userWallet := userSigner.PublicKey().Address().String() + asset := "USDC" + homeChannelID := "0xHomeChannel123" + lockAmount := decimal.NewFromInt(100) + + currentState := core.State{ + ID: core.GetStateID(userWallet, asset, 1, 1), + Transition: core.Transition{}, + Asset: asset, + UserWallet: userWallet, + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + TokenAddress: "0xTokenAddress", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.NewFromInt(500), + NodeBalance: decimal.NewFromInt(0), + NodeNetFlow: decimal.NewFromInt(0), + }, + } + + incomingState := currentState.NextState() + _, err := incomingState.ApplyEscrowLockTransition(2, "0xTokenAddress", lockAmount) + require.NoError(t, err) + + mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() + mockAssetStore.On("GetTokenDecimals", uint64(2), "0xTokenAddress").Return(uint8(6), nil).Maybe() + packedState, _ := core.PackState(*incomingState, mockAssetStore) + userSig, _ := userWalletSigner.Sign(packedState) + userSigStr := userSig.String() + incomingState.UserSig = &userSigStr + + mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) + mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("0x03", core.ChannelStatusVoid, nil) + mockTxStore.On("GetLastUserState", userWallet, asset, false).Return(currentState, nil) + mockTxStore.On("EnsureNoOngoingStateTransitions", userWallet, asset).Return(nil) + mockStatePacker.On("PackState", mock.Anything).Return(packedState, nil) + mockTxStore.On("StoreUserState", mock.Anything, mock.Anything).Return(nil).Maybe() + + rpcState := toRPCState(*incomingState) + reqPayload := rpc.ChannelsV1SubmitStateRequest{State: rpcState} + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.submit_state", Payload: payload}, + } + + handler.SubmitState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "Expected error when home channel is Void") + assert.Contains(t, respErr.Error(), "home channel is not materialized onchain") + + mockTxStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) + mockTxStore.AssertNotCalled(t, "CreateChannel", mock.Anything) + mockTxStore.AssertNotCalled(t, "ScheduleInitiateEscrowWithdrawal", mock.Anything, mock.Anything) +} + +// TestSubmitState_ClosingChannel_Rejected verifies that SubmitState rejects any transition +// when the channel is Closing or Challenged (status > ChannelStatusOpen), preventing new +// states from being accepted on a channel that has already been finalized off-chain. +func TestSubmitState_ClosingChannel_Rejected(t *testing.T) { + mockTxStore := new(MockStore) + mockMemoryStore := new(MockMemoryStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + nodeAddress := mockSigner.PublicKey().Address().String() + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(h StoreTxHandler) error { + return h(mockTxStore) + }, + memoryStore: mockMemoryStore, + nodeSigner: nodeSigner, + nodeAddress: nodeAddress, + minChallenge: 3600, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + actionGateway: &MockActionGateway{}, + } + + userSigner := NewMockSigner() + userWalletSigner, _ := core.NewChannelDefaultSigner(userSigner) + userWallet := userSigner.PublicKey().Address().String() + asset := "USDC" + homeChannelID := "0xHomeChannel123" + + currentState := core.State{ + ID: core.GetStateID(userWallet, asset, 1, 1), + Asset: asset, + UserWallet: userWallet, + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + TokenAddress: "0xTokenAddress", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.NewFromInt(500), + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + } + + incomingState := currentState.NextState() + _, err := incomingState.ApplyHomeDepositTransition(decimal.NewFromInt(100)) + require.NoError(t, err) + + mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() + packedState, _ := core.PackState(*incomingState, mockAssetStore) + userSig, _ := userWalletSigner.Sign(packedState) + userSigStr := userSig.String() + incomingState.UserSig = &userSigStr + + mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) + // CheckActiveChannel returns nil status for Closing/Challenged channels (status > ChannelStatusOpen). + mockTxStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockTxStore.On("CheckActiveChannel", userWallet, asset).Return("", nil, nil) + + rpcState := toRPCState(*incomingState) + payload, err := rpc.NewPayload(rpc.ChannelsV1SubmitStateRequest{State: rpcState}) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.submit_state", Payload: payload}, + } + + handler.SubmitState(ctx) + + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "Expected error when channel is Closing") + assert.Contains(t, respErr.Error(), "user has no active channel") + + mockTxStore.AssertNotCalled(t, "GetLastUserState", mock.Anything, mock.Anything, mock.Anything) + mockTxStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) + mockTxStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) +} + // Helper function to create a string pointer func stringPtr(s string) *string { return &s diff --git a/nitronode/api/channel_v1/testing.go b/nitronode/api/channel_v1/testing.go index f2d8af3ac..45ff79d87 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/layer-3/nitrolite/nitronode/action_gateway" + "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/sign" ) @@ -40,9 +41,14 @@ func (m *MockStore) GetLastUserState(wallet, asset string, signed bool) (*core.S return &state, args.Error(1) } -func (m *MockStore) CheckOpenChannel(wallet, asset string) (string, bool, error) { +func (m *MockStore) CheckActiveChannel(wallet, asset string) (string, *core.ChannelStatus, error) { args := m.Called(wallet, asset) - return args.String(0), args.Bool(1), args.Error(2) + var status *core.ChannelStatus + if v := args.Get(1); v != nil { + s := v.(core.ChannelStatus) + status = &s + } + return args.String(0), status, args.Error(2) } func (m *MockStore) StoreUserState(state core.State, applicationID string) error { @@ -55,6 +61,11 @@ func (m *MockStore) EnsureNoOngoingStateTransitions(wallet, asset string) error return args.Error(0) } +func (m *MockStore) EnsureNoOngoingEscrowOperation(wallet, asset string) error { + args := m.Called(wallet, asset) + return args.Error(0) +} + func (m *MockStore) ScheduleInitiateEscrowWithdrawal(stateID string, chainID uint64) error { args := m.Called(stateID, chainID) return args.Error(0) @@ -86,6 +97,24 @@ func (m *MockStore) GetActiveHomeChannel(wallet, asset string) (*core.Channel, e return args.Get(0).(*core.Channel), args.Error(1) } +func (m *MockStore) GetNotClosedHomeChannel(wallet, asset string) (*core.Channel, error) { + args := m.Called(wallet, asset) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*core.Channel), args.Error(1) +} + +func (m *MockStore) UpdateChannel(channel core.Channel) error { + args := m.Called(channel) + return args.Error(0) +} + +func (m *MockStore) HasNonClosedHomeChannel(wallet, asset string) (bool, error) { + args := m.Called(wallet, asset) + return args.Bool(0), args.Error(1) +} + func (m *MockStore) GetUserChannels(wallet string, status *core.ChannelStatus, asset *string, channelType *core.ChannelType, limit, offset uint32) ([]core.Channel, uint32, error) { args := m.Called(wallet, status, asset, channelType, limit, offset) if args.Get(0) == nil { @@ -94,6 +123,16 @@ func (m *MockStore) GetUserChannels(wallet string, status *core.ChannelStatus, a return args.Get(0).([]core.Channel), args.Get(1).(uint32), args.Error(2) } +func (m *MockStore) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) { + args := m.Called(userAddress, sessionKey, kind) + return uint64(args.Int(0)), args.Error(1) +} + +func (m *MockStore) CountSessionKeysForUser(userAddress string) (uint32, error) { + args := m.Called(userAddress) + return uint32(args.Int(0)), args.Error(1) +} + func (m *MockStore) StoreChannelSessionKeyState(state core.ChannelSessionKeyStateV1) error { args := m.Called(state) return args.Error(0) @@ -104,12 +143,12 @@ func (m *MockStore) GetLastChannelSessionKeyVersion(wallet, sessionKey string) ( return args.Get(0).(uint64), args.Error(1) } -func (m *MockStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string) ([]core.ChannelSessionKeyStateV1, error) { - args := m.Called(wallet, sessionKey) +func (m *MockStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) { + args := m.Called(wallet, sessionKey, includeInactive, limit, offset) if args.Get(0) == nil { - return nil, args.Error(1) + return nil, uint32(args.Int(1)), args.Error(2) } - return args.Get(0).([]core.ChannelSessionKeyStateV1), args.Error(1) + return args.Get(0).([]core.ChannelSessionKeyStateV1), uint32(args.Int(1)), args.Error(2) } func (m *MockStore) ValidateChannelSessionKeyForAsset(wallet, sessionKey, asset, metadataHash string) (bool, error) { diff --git a/nitronode/api/channel_v1/utils.go b/nitronode/api/channel_v1/utils.go index d7e85bb72..364a6d775 100644 --- a/nitronode/api/channel_v1/utils.go +++ b/nitronode/api/channel_v1/utils.go @@ -135,6 +135,8 @@ func channelStatusToString(s core.ChannelStatus) string { return "open" case core.ChannelStatusChallenged: return "challenged" + case core.ChannelStatusClosing: + return "closing" case core.ChannelStatusClosed: return "closed" default: @@ -224,23 +226,25 @@ func unmapChannelSessionKeyStateV1(state *rpc.ChannelSessionKeyStateV1) (core.Ch } return core.ChannelSessionKeyStateV1{ - UserAddress: strings.ToLower(state.UserAddress), - SessionKey: strings.ToLower(state.SessionKey), - Version: version, - Assets: assets, - ExpiresAt: time.Unix(expiresAtUnix, 0), - UserSig: state.UserSig, + UserAddress: strings.ToLower(state.UserAddress), + SessionKey: strings.ToLower(state.SessionKey), + Version: version, + Assets: assets, + ExpiresAt: time.Unix(expiresAtUnix, 0), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } // mapChannelSessionKeyStateV1 converts a core.ChannelSessionKeyStateV1 to an RPC ChannelSessionKeyStateV1. func mapChannelSessionKeyStateV1(state *core.ChannelSessionKeyStateV1) rpc.ChannelSessionKeyStateV1 { return rpc.ChannelSessionKeyStateV1{ - UserAddress: state.UserAddress, - SessionKey: state.SessionKey, - Version: strconv.FormatUint(state.Version, 10), - Assets: state.Assets, - ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), - UserSig: state.UserSig, + UserAddress: state.UserAddress, + SessionKey: state.SessionKey, + Version: strconv.FormatUint(state.Version, 10), + Assets: state.Assets, + ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } diff --git a/nitronode/api/rate_limits.go b/nitronode/api/rate_limits.go index 55041e2c4..33577b302 100644 --- a/nitronode/api/rate_limits.go +++ b/nitronode/api/rate_limits.go @@ -6,10 +6,11 @@ import ( "github.com/layer-3/nitrolite/pkg/rpc" ) -const ( - // rateLimitStorageKey is the key used to store the token bucket in connection storage. - rateLimitStorageKey = "rate_limiter" -) +// rateLimitStorageKey is the per-connection SafeStorage key for the request-rate +// token bucket. The bucket is allocated on the first request and mutated in +// place on subsequent ones — processRequests dispatches the middleware chain +// serially per connection, so a single load is sufficient. +const rateLimitStorageKey = "rate_limiter" // tokenBucket holds the mutable state for per-connection rate limiting. type tokenBucket struct { @@ -17,35 +18,42 @@ type tokenBucket struct { last time.Time } -// RateLimitMiddleware enforces per-connection rate limiting using a token bucket algorithm. -// It stores the token bucket in the connection's Storage for persistence across requests. +// RateLimitMiddleware enforces a per-connection request-count token bucket. +// It complements the per-frame byte budget enforced by FrameRateLimiter at the +// connection layer: bytes guard bandwidth, this guards RPC throughput so a +// flood of small requests cannot bypass the byte cap. +// +// On overrun the request fails with an RPC error and the connection stays +// open; the byte limiter is the layer that closes connections. func (r *RPCRouter) RateLimitMiddleware(c *rpc.Context) { - bucket := &tokenBucket{ - tokens: r.rateLimitBurst, - last: time.Now().Add(-time.Second), - } - if val, ok := c.Storage.Get(rateLimitStorageKey); ok { - if b, ok := val.(*tokenBucket); ok { - bucket = b - } - } + bucket := loadOrInitBucket(c, r.rateLimitBurst) now := time.Now() - elapsed := now.Sub(bucket.last).Seconds() - bucket.last = now - - // Refill tokens based on elapsed time - bucket.tokens += elapsed * r.rateLimitPerSec + bucket.tokens += now.Sub(bucket.last).Seconds() * r.rateLimitPerSec if bucket.tokens > r.rateLimitBurst { bucket.tokens = r.rateLimitBurst } + bucket.last = now if bucket.tokens < 1 { - c.Fail(nil, "rate limit exceeded") + c.Fail(rpc.Errorf("rate limit exceeded"), "") return } bucket.tokens-- - c.Storage.Set(rateLimitStorageKey, bucket) c.Next() } + +// loadOrInitBucket returns the bucket stored on the connection, allocating a +// fresh one pre-filled to burst on first use. The bucket is stored as a +// pointer; later mutations are visible without re-Set. +func loadOrInitBucket(c *rpc.Context, burst float64) *tokenBucket { + if v, ok := c.Storage.Get(rateLimitStorageKey); ok { + if b, ok := v.(*tokenBucket); ok { + return b + } + } + b := &tokenBucket{tokens: burst, last: time.Now()} + c.Storage.Set(rateLimitStorageKey, b) + return b +} diff --git a/nitronode/api/rpc_router.go b/nitronode/api/rpc_router.go index 8583bf8ac..9952e2611 100644 --- a/nitronode/api/rpc_router.go +++ b/nitronode/api/rpc_router.go @@ -38,6 +38,7 @@ type RPCRouterConfig struct { MaxAppMetadataLen int MaxRebalanceSignedUpdates int MaxSessionKeyIDs int + MaxSessionKeysPerUser int RateLimitPerSec float64 RateLimitBurst float64 @@ -101,9 +102,9 @@ func NewRPCRouter( panic("failed to create channel wallet signer: " + err.Error()) } - channelV1Handler := channel_v1.NewHandler(useChannelV1StoreInTx, memoryStore, actionGateway, nodeChannelSigner, stateAdvancer, statePacker, nodeAddress, cfg.MinChallenge, cfg.MaxChallenge, runtimeMetrics, cfg.MaxSessionKeyIDs) + channelV1Handler := channel_v1.NewHandler(useChannelV1StoreInTx, memoryStore, actionGateway, nodeChannelSigner, stateAdvancer, statePacker, nodeAddress, cfg.MinChallenge, cfg.MaxChallenge, runtimeMetrics, cfg.MaxSessionKeyIDs, cfg.MaxSessionKeysPerUser) appSessionV1Handler := app_session_v1.NewHandler(useAppSessionV1StoreInTx, memoryStore, actionGateway, nodeChannelSigner, stateAdvancer, statePacker, nodeAddress, cfg.AppRegistryEnabled, runtimeMetrics, - cfg.MaxParticipants, cfg.MaxSessionDataLen, cfg.MaxSessionKeyIDs, cfg.MaxRebalanceSignedUpdates) + cfg.MaxParticipants, cfg.MaxSessionDataLen, cfg.MaxSessionKeyIDs, cfg.MaxRebalanceSignedUpdates, cfg.MaxSessionKeysPerUser) appsV1Handler := apps_v1.NewHandler(dbStore, useAppV1StoreInTx, actionGateway, cfg.MaxAppMetadataLen) nodeV1Handler := node_v1.NewHandler(memoryStore, nodeAddress, cfg.NodeVersion) userV1Handler := user_v1.NewHandler(dbStore, useUserV1StoreInTx, actionGateway) diff --git a/nitronode/blockchain_worker.go b/nitronode/blockchain_worker.go index db25b43fe..a77923cc3 100644 --- a/nitronode/blockchain_worker.go +++ b/nitronode/blockchain_worker.go @@ -37,20 +37,32 @@ const ( ) type BlockchainWorker struct { - blockchainID uint64 - client core.BlockchainClient - store BlockchainWorkerStore - logger log.Logger - metrics MetricsExporter + blockchainID uint64 + client core.BlockchainClient + store BlockchainWorkerStore + channelSigner core.ChannelSigner + assetStore core.AssetStore + logger log.Logger + metrics MetricsExporter } -func NewBlockchainWorker(blockchainID uint64, client core.BlockchainClient, store BlockchainWorkerStore, logger log.Logger, m MetricsExporter) *BlockchainWorker { +func NewBlockchainWorker( + blockchainID uint64, + client core.BlockchainClient, + store BlockchainWorkerStore, + channelSigner core.ChannelSigner, + assetStore core.AssetStore, + logger log.Logger, + m MetricsExporter, +) *BlockchainWorker { return &BlockchainWorker{ - blockchainID: blockchainID, - client: client, - store: store, - logger: logger.WithName("bw").WithKV("blockchainID", blockchainID), - metrics: m, + blockchainID: blockchainID, + client: client, + store: store, + channelSigner: channelSigner, + assetStore: assetStore, + logger: logger.WithName("bw").WithKV("blockchainID", blockchainID), + metrics: m, } } @@ -166,6 +178,9 @@ func (w *BlockchainWorker) processAction(_ context.Context, action database.Bloc case database.ActionTypeCheckpoint: txHash, err = w.client.Checkpoint(*state) + case database.ActionTypeChallenge: + txHash, err = w.submitChallenge(*state) + // case database.ActionTypeInitiateEscrowDeposit: // txHash, err = w.processInitiateEscrow(state, w.client.InitiateEscrowDeposit) @@ -198,6 +213,20 @@ func (w *BlockchainWorker) processAction(_ context.Context, action database.Bloc return true } +// submitChallenge produces a node challenger signature for the given state and submits +// challengeChannel(...) on the channel's home blockchain. +func (w *BlockchainWorker) submitChallenge(state core.State) (string, error) { + packed, err := core.PackChallengeState(state, w.assetStore) + if err != nil { + return "", fmt.Errorf("failed to pack challenge state: %w", err) + } + challengerSig, err := w.channelSigner.Sign(packed) + if err != nil { + return "", fmt.Errorf("failed to sign challenge state: %w", err) + } + return w.client.Challenge(state, challengerSig, core.ChannelParticipantNode) +} + // func (w *BlockchainWorker) processInitiateEscrow(state *core.State, initiate func(core.ChannelDefinition, core.State) (string, error)) (string, error) { // if state.EscrowChannelID == nil { // return "", fmt.Errorf("state has no escrow channel ID") diff --git a/nitronode/chart/README.md b/nitronode/chart/README.md index bd523a3c2..8bd9587c9 100644 --- a/nitronode/chart/README.md +++ b/nitronode/chart/README.md @@ -98,6 +98,73 @@ helm delete my-release | stressTest.maxErrorRate | string | `"0.01"` | Default max error rate threshold (0.01 = 1%) | | stressTest.pods | list | see values.yaml | List of stress test pods to run | +## WebSocket DoS hardening + +Defense layered top-down. Each layer sheds load before the next. + +### Cloudflare (recommended for public-facing envs) + +WAF Rate Limiting rules — production / sandbox only. Skip for `stress-v1` +(test traffic, no Cloudflare zone configured). + +Suggested rule: + +| Field | Value | +|------------|----------------------------------------------------------------------------------------| +| Match | `(http.host eq "" and http.request.uri.path eq "/v1/ws")` | +| Threshold | 60 requests per 1 minute per IP | +| Action | Block 10m | +| Counting | All HTTP statuses | + +Pair with Bot Fight Mode + Managed Challenge on the same hostname for low-rep +sources. + +### NGINX Ingress (per-IP, per-conn) + +The env templates already wire these annotations on the WebSocket Ingress: + +```yaml +nginx.ingress.kubernetes.io/limit-connections: "50" # concurrent / IP +nginx.ingress.kubernetes.io/limit-rps: "20" # new conns/s / IP +nginx.ingress.kubernetes.io/limit-burst-multiplier: "3" +``` + +> Note: `proxy-body-size` (nginx `client_max_body_size`) intentionally not set. +> It applies to HTTP request bodies only; after the WebSocket upgrade the +> ingress proxies the TCP stream transparently and cannot enforce a per-frame +> size limit. Frame size is capped at the application layer +> (`NITRONODE_WS_MAX_MESSAGE_SIZE` → `SetReadLimit`). + +**Real-IP requirement.** ingress-nginx must see the client IP, not the CF +edge IP or LB pod IP. Cluster-wide ConfigMap (one-time, ops-owned): + +```yaml +use-forwarded-headers: "true" +compute-full-forwarded-for: "true" +forwarded-for-header: "CF-Connecting-IP" # if behind Cloudflare +proxy-real-ip-cidr: "" +``` + +Without this, all traffic appears to come from a handful of LB IPs and the +per-IP limiters are useless. + +For envs without Cloudflare in front (e.g. `stress-v1`), ensure the +ingress-nginx Service has `externalTrafficPolicy: Local` so the GCP LB +preserves source IPs to the pods. + +### Application (`pkg/rpc`) + +Per-connection caps configured via env on the nitronode pod: + +| Env | Default | Purpose | +|----------------------------------|-------------|--------------------------------------------------------------------------| +| `NITRONODE_WS_MAX_MESSAGE_SIZE` | `131072` | Hard cap on inbound frame (bytes). Exceeded → close 1009 before alloc. | +| `NITRONODE_WS_BYTES_PER_SEC` | `262144` | Steady-state byte budget per connection. Set ≤ 0 to disable. | +| `NITRONODE_WS_BYTES_BURST` | `1048576` | Burst capacity for the per-connection byte bucket. | + +Disable the byte-rate cap (`WS_BYTES_PER_SEC=-1`) for canary rollout if +false positives are suspected. The frame size cap stays on regardless. + ## Gateway Configuration By default, the chart creates an API Gateway and configures it to use TLS via cert-manager. To use this feature: diff --git a/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl b/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl index 30e93161a..bf71eb7a5 100644 --- a/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl +++ b/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl @@ -9,6 +9,9 @@ config: port: 5432 name: nitronode_stress_v1 user: nitronode_stress_v1_admin + # pgbouncer runs in-cluster on a private network, so TLS is not required + # here. Override the chart's `require` default explicitly. + sslmode: disable envSecret: nitronode-secret-env extraEnvs: NITRONODE_DATABASE_MAX_OPEN_CONNS: "{{ $p.database.maxOpenConns }}" @@ -21,6 +24,13 @@ config: NITRONODE_MAX_SESSION_DATA_LEN: "1024" NITRONODE_MAX_SIGNED_UPDATES: "0" NITRONODE_MAX_SESSION_KEY_IDS: "256" + # WebSocket DoS hardening. + # Frame cap: largest legit v1 RPC ≈ a few KB; 128 KiB leaves headroom. + # Byte budget: 256 KiB/s steady, 1 MiB burst. Comfortably absorbs reload + # reconnect storms (auth + subscribe ≈ 20 KB / tab) without throttling. + NITRONODE_WS_MAX_MESSAGE_SIZE: "131072" + NITRONODE_WS_BYTES_PER_SEC: "262144" + NITRONODE_WS_BYTES_BURST: "1048576" image: repository: ghcr.io/layer-3/nitrolite/nitronode @@ -66,6 +76,20 @@ networking: nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-connect-timeout: "10" nginx.ingress.kubernetes.io/proxy-buffering: "off" + # ── DoS hardening (per-IP, NGINX-level) ────────────────────────────── + # Caps abusive sources before WebSocket upgrade and JSON decode. + # Effective only when ingress-nginx sees real client IPs — see chart + # README "WebSocket DoS hardening" for required ConfigMap settings. + # stress-v1 has no Cloudflare in front; source IP comes from the GCP + # LB. Verify externalTrafficPolicy=Local on the ingress-nginx Service + # before relying on per-IP limits. + nginx.ingress.kubernetes.io/limit-connections: "50" # concurrent / IP + nginx.ingress.kubernetes.io/limit-rps: "20" # new conns/s / IP + nginx.ingress.kubernetes.io/limit-burst-multiplier: "3" + # Note: nginx proxy-body-size applies to HTTP request bodies only. After + # the WebSocket upgrade, ingress-nginx proxies the TCP stream transparently + # and cannot enforce a per-frame size limit. The frame cap is enforced at + # the application layer via NITRONODE_WS_MAX_MESSAGE_SIZE / SetReadLimit. stressTest: enabled: false diff --git a/nitronode/chart/templates/helpers/_common.tpl b/nitronode/chart/templates/helpers/_common.tpl index 6848628cb..ed39a50db 100644 --- a/nitronode/chart/templates/helpers/_common.tpl +++ b/nitronode/chart/templates/helpers/_common.tpl @@ -87,6 +87,8 @@ Returns common environment variables value: "{{ print .port }}" - name: NITRONODE_DATABASE_USERNAME value: {{ .user }} +- name: NITRONODE_DATABASE_SSLMODE + value: {{ .sslmode | default "require" | quote }} {{- end }} {{- range $key, $value := .Values.config.extraEnvs }} - name: {{ $key | upper }} diff --git a/nitronode/chart/values.yaml b/nitronode/chart/values.yaml index 602f5e172..c8e85e5bd 100644 --- a/nitronode/chart/values.yaml +++ b/nitronode/chart/values.yaml @@ -21,8 +21,13 @@ config: user: changeme # -- Database password password: changeme - # -- Database SSL mode (disable, require, verify-ca, verify-full) - sslmode: disable + # -- Database SSL mode (disable, require, verify-ca, verify-full). + # Defaults to `require` so any deployment that exposes Postgres over an + # untrusted network gets TLS without extra configuration. Override to + # `disable` for setups where the database is only reachable on a private + # network (e.g. a cluster-internal pgbouncer / VPC-only Cloud SQL) and TLS + # is not required; use `verify-ca` / `verify-full` for strict cert checking. + sslmode: require # -- Name of the secret containing GCP SA Credentials (Optional) gcpSaSecret: "" # -- Additional environment variables as key-value pairs diff --git a/nitronode/config/migrations/postgres/20251222000000_initial_schema.sql b/nitronode/config/migrations/postgres/20251222000000_initial_schema.sql index fd7ba50a3..d26e6cca0 100644 --- a/nitronode/config/migrations/postgres/20251222000000_initial_schema.sql +++ b/nitronode/config/migrations/postgres/20251222000000_initial_schema.sql @@ -12,7 +12,7 @@ CREATE TABLE channels ( challenge_expires_at TIMESTAMPTZ, nonce NUMERIC(20,0) NOT NULL DEFAULT 0, approved_sig_validators VARCHAR(66) NOT NULL DEFAULT 0, - status SMALLINT NOT NULL, -- ChannelStatus enum: 0=void, 1=open, 2=challenged, 3=closed + status SMALLINT NOT NULL, -- ChannelStatus enum: 0=void, 1=open, 2=challenged, 3=closing, 4=closed state_version NUMERIC(20,0) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() diff --git a/nitronode/config/migrations/postgres/20260507000000_add_current_session_key_states.sql b/nitronode/config/migrations/postgres/20260507000000_add_current_session_key_states.sql new file mode 100644 index 000000000..d121e12f0 --- /dev/null +++ b/nitronode/config/migrations/postgres/20260507000000_add_current_session_key_states.sql @@ -0,0 +1,39 @@ +-- +goose Up +-- Pointer table holding the latest version per (user_address, session_key, kind). +-- Reads of the get_last_key_states endpoints filter this table by user_address (+ optional +-- session_key) and JOIN the corresponding history table on (user_address, session_key, version). +-- This eliminates the GROUP BY scan over history that grows with version churn and bounds +-- per-request DB work to O(distinct keys for user, kind). +-- +-- kind values (SessionKeyKind enum on the Go side): +-- 1 = channel +-- 2 = app_session +CREATE TABLE current_session_key_states_v1 ( + user_address CHAR(42) NOT NULL, + session_key CHAR(42) NOT NULL, + kind SMALLINT NOT NULL, + version NUMERIC(20,0) NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_address, session_key, kind) +); + +CREATE INDEX idx_current_session_key_states_v1_user_kind + ON current_session_key_states_v1(user_address, kind); + +-- Backfill from app session key history: latest version per (user_address, session_key). +INSERT INTO current_session_key_states_v1 (user_address, session_key, kind, version, updated_at) +SELECT user_address, session_key, 2, MAX(version), NOW() +FROM app_session_key_states_v1 +GROUP BY user_address, session_key +ON CONFLICT (user_address, session_key, kind) DO NOTHING; + +-- Backfill from channel session key history: latest version per (user_address, session_key). +INSERT INTO current_session_key_states_v1 (user_address, session_key, kind, version, updated_at) +SELECT user_address, session_key, 1, MAX(version), NOW() +FROM channel_session_key_states_v1 +GROUP BY user_address, session_key +ON CONFLICT (user_address, session_key, kind) DO NOTHING; + +-- +goose Down +DROP INDEX IF EXISTS idx_current_session_key_states_v1_user_kind; +DROP TABLE IF EXISTS current_session_key_states_v1; diff --git a/nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql b/nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql new file mode 100644 index 000000000..4e40d42ba --- /dev/null +++ b/nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql @@ -0,0 +1,66 @@ +-- +goose Up +-- Bind a session_key to a single owner (per kind) and require co-signature at submit time. + +-- Co-signature: the session-key holder proves possession at registration and on every update. +-- Nullable to accommodate rows written before this column existed; new submits enforce non-null +-- in application code. Columns add first so a constraint failure below does not leave the +-- session_key_sig schema partially applied. +ALTER TABLE app_session_key_states_v1 + ADD COLUMN session_key_sig TEXT; + +ALTER TABLE channel_session_key_states_v1 + ADD COLUMN session_key_sig TEXT; + +-- Pre-flight: refuse the migration if duplicate (session_key, kind) rows are present in +-- current_session_key_states_v1. Such rows are evidence of cross-wallet collisions that +-- the old code path allowed; manual remediation is required before the constraint adds. +-- +goose StatementBegin +DO $$ +DECLARE + dup_count bigint; +BEGIN + SELECT COUNT(*) INTO dup_count + FROM ( + SELECT session_key, kind + FROM current_session_key_states_v1 + GROUP BY session_key, kind + HAVING COUNT(*) > 1 + ) AS dups; + + IF dup_count > 0 THEN + RAISE EXCEPTION 'duplicate (session_key, kind) rows detected (%); manual remediation required before applying constraint', dup_count; + END IF; +END $$; +-- +goose StatementEnd + +ALTER TABLE current_session_key_states_v1 + ADD CONSTRAINT current_session_key_states_v1_key_kind_uniq UNIQUE (session_key, kind); + +-- Fail closed at the DB layer for new history rows during rolling deploys: a pre-MF-H02 binary +-- (already running the MF-H01 schema where current_session_key_states_v1 exists) would happily +-- insert history rows without session_key_sig, and the new GetAppSessionKeyOwner/GetChannelSessionKeyOwner +-- lookups would then trust those unproven rows as legitimate owners. NOT VALID skips the legacy +-- backfill scan so pre-existing rows are not blocked; only future inserts are checked. +ALTER TABLE app_session_key_states_v1 + ADD CONSTRAINT app_session_key_states_v1_session_key_sig_present_chk + CHECK (session_key_sig IS NOT NULL AND session_key_sig <> '') NOT VALID; + +ALTER TABLE channel_session_key_states_v1 + ADD CONSTRAINT channel_session_key_states_v1_session_key_sig_present_chk + CHECK (session_key_sig IS NOT NULL AND session_key_sig <> '') NOT VALID; + +-- +goose Down +ALTER TABLE channel_session_key_states_v1 + DROP CONSTRAINT IF EXISTS channel_session_key_states_v1_session_key_sig_present_chk; + +ALTER TABLE app_session_key_states_v1 + DROP CONSTRAINT IF EXISTS app_session_key_states_v1_session_key_sig_present_chk; + +ALTER TABLE current_session_key_states_v1 + DROP CONSTRAINT IF EXISTS current_session_key_states_v1_key_kind_uniq; + +ALTER TABLE channel_session_key_states_v1 + DROP COLUMN IF EXISTS session_key_sig; + +ALTER TABLE app_session_key_states_v1 + DROP COLUMN IF EXISTS session_key_sig; diff --git a/nitronode/config/migrations/postgres/20260513000000_add_channel_status_closing.sql b/nitronode/config/migrations/postgres/20260513000000_add_channel_status_closing.sql new file mode 100644 index 000000000..438e90159 --- /dev/null +++ b/nitronode/config/migrations/postgres/20260513000000_add_channel_status_closing.sql @@ -0,0 +1,20 @@ +-- +goose Up + +-- Introduce ChannelStatusClosing (3) between Challenged (2) and Closed (4). +-- Prior to this migration Closed was encoded as 3; shift it to 4 first, +-- then the gap at 3 becomes the new Closing value. +-- +-- Status enum mapping after this migration: +-- 0 = void +-- 1 = open +-- 2 = challenged +-- 3 = closing (co-signed Finalize stored off-chain; on-chain close pending) +-- 4 = closed + +UPDATE channels SET status = 4 WHERE status = 3; + +-- +goose Down + +UPDATE channels SET status = 3 WHERE status = 4; +-- Note: any rows with status = 3 (Closing) at rollback time are left as-is; +-- they have no valid representation in the old schema and must be resolved manually. diff --git a/nitronode/event_handlers/service.go b/nitronode/event_handlers/service.go index 72fdc4ead..76637ee2d 100644 --- a/nitronode/event_handlers/service.go +++ b/nitronode/event_handlers/service.go @@ -64,6 +64,10 @@ func (s *EventHandlerService) HandleHomeChannelCreated(ctx context.Context, tx c return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled HomeChannelCreated event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } @@ -110,13 +114,17 @@ func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled HomeChannelCheckpointed event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } // HandleHomeChannelChallenged processes the HomeChannelChallenged event emitted when a potentially // stale state is submitted on-chain. It marks the channel as Challenged and persists the challenge -// expiry so subsequent state-submission paths (CheckOpenChannel, RefreshUserEnforcedBalance) stop +// expiry so subsequent state-submission paths (CheckActiveChannel, RefreshUserEnforcedBalance) stop // treating the channel as open. Automatic challenge response is intentionally disabled: the latest // signed state may carry an intent (e.g. CLOSE, escrow initiate/finalize, migration) that cannot // be resolved via ScheduleCheckpoint, and silently queueing an impossible transaction risks @@ -146,6 +154,9 @@ func (s *EventHandlerService) HandleHomeChannelChallenged(ctx context.Context, t } channel.StateVersion = event.StateVersion + // Closing → Challenged is an expected transition: a co-signed Finalize may race an + // on-chain challenge. The chain takes precedence; the off-chain close flow is abandoned + // and the channel follows the Challenged → Closed path instead. channel.Status = core.ChannelStatusChallenged expirationTime := time.Unix(int64(event.ChallengeExpiry), 0) channel.ChallengeExpiresAt = &expirationTime @@ -158,6 +169,10 @@ func (s *EventHandlerService) HandleHomeChannelChallenged(ctx context.Context, t return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Warn("home channel challenged", "channelId", chanID, "userWallet", channel.UserWallet, @@ -199,6 +214,10 @@ func (s *EventHandlerService) HandleHomeChannelClosed(ctx context.Context, tx co return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled HomeChannelClosed event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } @@ -241,14 +260,22 @@ func (s *EventHandlerService) HandleEscrowDepositInitiated(ctx context.Context, } } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled EscrowDepositInitiated event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } // HandleEscrowDepositChallenged processes the EscrowDepositChallenged event emitted when an escrow -// deposit is challenged on-chain. Similar to home channel challenges, it marks the channel as Challenged, -// sets the expiration time, and automatically schedules a checkpoint with the latest signed state -// to resolve the challenge. +// deposit is challenged on-chain. It marks the channel as Challenged and sets the expiration time. +// Resolution policy depends on whether the node holds a newer fully-signed state for this channel: +// - If a newer signed FINALIZE_ESCROW_DEPOSIT exists, finalize the escrow on the non-home chain. +// - Otherwise the user is withholding finalize: defend the node allocation on the home chain by +// scheduling challengeChannel(...) with the INITIATE_ESCROW_DEPOSIT state. Without this, the user +// can let the non-home challenge expire, recover escrow-chain funds, and still threaten the +// home-chain finalize path against the node's locked allocation. func (s *EventHandlerService) HandleEscrowDepositChallenged(ctx context.Context, tx core.ChannelHubEventHandlerStore, event *core.EscrowDepositChallengedEvent) error { logger := log.FromContext(ctx) chanID := event.ChannelID @@ -284,11 +311,7 @@ func (s *EventHandlerService) HandleEscrowDepositChallenged(ctx context.Context, if err != nil { return err } - if lastSignedState == nil { - logger.Warn("no state found for channel during EscrowDepositChallenged event", "channelId", chanID) - } else if lastSignedState.Version <= event.StateVersion { - logger.Warn("last signed state version is not greater than challenged state version", "channelId", chanID, "lastSignedStateVersion", lastSignedState.Version, "challengedStateVersion", event.StateVersion) - } else { + if lastSignedState != nil && lastSignedState.Version > event.StateVersion { if lastSignedState.EscrowLedger == nil { logger.Warn("last signed state has no escrow ledger during EscrowDepositChallenged event", "channelId", chanID) } else { @@ -296,12 +319,77 @@ func (s *EventHandlerService) HandleEscrowDepositChallenged(ctx context.Context, return err } } + } else { + if err := s.scheduleHomeChannelChallengeForEscrowDeposit(ctx, tx, chanID, event.StateVersion); err != nil { + return err + } + } + + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err } logger.Info("handled EscrowDepositChallenged event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } +// scheduleHomeChannelChallengeForEscrowDeposit queues a challengeChannel(...) submission on the home +// chain using the INITIATE_ESCROW_DEPOSIT state referenced by the escrow event. This anchors the +// home channel in DISPUTED so the user cannot later push a withheld FINALIZE state on home, and +// starts the home-chain challenge timer the operator uses to recover the node allocation. +func (s *EventHandlerService) scheduleHomeChannelChallengeForEscrowDeposit(ctx context.Context, tx core.ChannelHubEventHandlerStore, escrowChanID string, stateVersion uint64) error { + logger := log.FromContext(ctx) + + initiateState, err := tx.GetStateByChannelIDAndVersion(escrowChanID, stateVersion) + if err != nil { + return err + } + if initiateState == nil { + logger.Error("INITIATE_ESCROW_DEPOSIT state missing locally, cannot defend home channel automatically", "escrowChannelId", escrowChanID, "stateVersion", stateVersion) + return nil + } + if initiateState.HomeChannelID == nil { + logger.Error("INITIATE_ESCROW_DEPOSIT state has no home channel ID, cannot defend home channel automatically", "escrowChannelId", escrowChanID, "stateVersion", stateVersion) + return nil + } + + homeChannel, err := tx.GetChannelByID(*initiateState.HomeChannelID) + if err != nil { + return err + } + if homeChannel == nil { + logger.Error("home channel not found, cannot defend home channel automatically", "homeChannelId", *initiateState.HomeChannelID, "escrowChannelId", escrowChanID) + return nil + } + if homeChannel.Status != core.ChannelStatusOpen { + switch homeChannel.Status { + case core.ChannelStatusChallenged: + logger.Warn("home channel already Challenged, skipping auto-challenge", "homeChannelId", *initiateState.HomeChannelID, "escrowChannelId", escrowChanID) + case core.ChannelStatusClosed: + logger.Error("home channel Closed, defense window passed", "homeChannelId", *initiateState.HomeChannelID, "escrowChannelId", escrowChanID) + default: + logger.Warn("home channel not Open, skipping auto-challenge", "homeChannelId", *initiateState.HomeChannelID, "homeStatus", homeChannel.Status, "escrowChannelId", escrowChanID) + } + return nil + } + + if initiateState.HomeLedger.BlockchainID == 0 { + logger.Error("INITIATE_ESCROW_DEPOSIT state has zero home BlockchainID, cannot defend home channel automatically", "homeChannelId", *initiateState.HomeChannelID, "escrowChannelId", escrowChanID) + return nil + } + + if err := tx.ScheduleChallenge(initiateState.ID, initiateState.HomeLedger.BlockchainID); err != nil { + return err + } + + logger.Warn("scheduled home-channel challenge to defend node allocation against withheld escrow finalize", + "homeChannelId", *initiateState.HomeChannelID, + "escrowChannelId", escrowChanID, + "stateVersion", stateVersion, + ) + return nil +} + // HandleEscrowDepositFinalized processes the EscrowDepositFinalized event emitted when an escrow // deposit is successfully finalized on-chain. It updates the channel status to Closed and sets // the final state version, completing the deposit lifecycle. @@ -328,10 +416,52 @@ func (s *EventHandlerService) HandleEscrowDepositFinalized(ctx context.Context, return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled EscrowDepositFinalized event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } +// HandleEscrowDepositsPurged processes the EscrowDepositsPurged event emitted when expired escrow deposits +// are finalized by the on-chain purge queue without a signed FINALIZE_ESCROW_DEPOSIT state. It marks each +// corresponding escrow channel as Closed, preserving its existing StateVersion. +// +// TODO: consider scoping the DB transaction per channel update instead of wrapping the whole batch, +// so a single failure does not roll back already-processed channels in the same purge event. +func (s *EventHandlerService) HandleEscrowDepositsPurged(ctx context.Context, tx core.ChannelHubEventHandlerStore, event *core.EscrowDepositsPurgedEvent) error { + logger := log.FromContext(ctx) + closedCount := 0 + + for _, escrowID := range event.EscrowIDs { + channel, err := tx.GetChannelByID(escrowID) + if err != nil { + return err + } + if channel == nil { + logger.Debug("channel not found in DB during EscrowDepositsPurged event", "escrowId", escrowID) + continue + } + if channel.Type != core.ChannelTypeEscrow { + logger.Warn("channel type mismatch during EscrowDepositsPurged event", "escrowId", escrowID, "expectedType", core.ChannelTypeEscrow, "actualType", channel.Type) + continue + } + if channel.Status == core.ChannelStatusClosed { + continue + } + + channel.Status = core.ChannelStatusClosed + if err := tx.UpdateChannel(*channel); err != nil { + return err + } + closedCount++ + } + + logger.Info("handled EscrowDepositsPurged event", "purgedCount", len(event.EscrowIDs), "closedCount", closedCount) + return nil +} + // HandleEscrowWithdrawalInitiated processes the EscrowWithdrawalInitiated event emitted when an escrow // withdrawal operation begins on-chain. It updates the escrow channel status to Open and sets the state // version to reflect the initiated withdrawal. @@ -358,6 +488,10 @@ func (s *EventHandlerService) HandleEscrowWithdrawalInitiated(ctx context.Contex return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled EscrowWithdrawalInitiated event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } @@ -414,6 +548,10 @@ func (s *EventHandlerService) HandleEscrowWithdrawalChallenged(ctx context.Conte } } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled EscrowWithdrawalChallenged event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } @@ -444,6 +582,10 @@ func (s *EventHandlerService) HandleEscrowWithdrawalFinalized(ctx context.Contex return err } + if err := tx.UpdateStateUserSigIfMissing(event.ChannelID, event.StateVersion, event.UserSig); err != nil { + return err + } + logger.Info("handled EscrowWithdrawalFinalized event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } diff --git a/nitronode/event_handlers/service_test.go b/nitronode/event_handlers/service_test.go index 9ca8f2dc4..98dc484ea 100644 --- a/nitronode/event_handlers/service_test.go +++ b/nitronode/event_handlers/service_test.go @@ -47,6 +47,7 @@ func TestHandleHomeChannelCreated_Success(t *testing.T) { ch.StateVersion == 1 })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(1), "").Return(nil) // Execute err := service.HandleHomeChannelCreated(ctx, mockStore, event) @@ -91,6 +92,7 @@ func TestHandleHomeChannelCheckpointed_Success(t *testing.T) { ch.StateVersion == 5 })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(5), "").Return(nil) // Execute err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) @@ -101,7 +103,7 @@ func TestHandleHomeChannelCheckpointed_Success(t *testing.T) { } func TestHandleHomeChannelChallenged_PersistsChallenge(t *testing.T) { - // Channel must be marked Challenged with the challenge expiry so CheckOpenChannel and + // Channel must be marked Challenged with the challenge expiry so CheckActiveChannel and // RefreshUserEnforcedBalance stop treating it as open. Auto-checkpoint stays disabled: // non-checkpointable intents (CLOSE, escrow initiate/finalize, migration) cannot be // resolved via ScheduleCheckpoint, so operator action is required. @@ -138,6 +140,7 @@ func TestHandleHomeChannelChallenged_PersistsChallenge(t *testing.T) { ch.ChallengeExpiresAt.Unix() == int64(challengeExpiry) })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(4), "").Return(nil) err := service.HandleHomeChannelChallenged(ctx, mockStore, event) @@ -233,6 +236,50 @@ func TestHandleHomeChannelChallenged_TypeMismatch(t *testing.T) { mockStore.AssertExpectations(t) } +func TestHandleHomeChannelChallenged_FromClosingState(t *testing.T) { + // Closing → Challenged is an expected transition: a co-signed Finalize may race an + // on-chain challenge. The chain takes precedence; status must become Challenged. + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusClosing, + StateVersion: 3, + } + + event := &core.HomeChannelChallengedEvent{ + ChannelID: channelID, + StateVersion: 4, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusChallenged && + ch.StateVersion == 4 && + ch.ChallengeExpiresAt != nil && + ch.ChallengeExpiresAt.Unix() == int64(challengeExpiry) + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(4), "").Return(nil) + + err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) +} + func TestHandleHomeChannelClosed_Success(t *testing.T) { // Setup mockStore := new(MockStore) @@ -266,6 +313,7 @@ func TestHandleHomeChannelClosed_Success(t *testing.T) { ch.StateVersion == 10 })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(10), "").Return(nil) // Execute err := service.HandleHomeChannelClosed(ctx, mockStore, event) @@ -320,6 +368,7 @@ func TestHandleEscrowDepositInitiated_Success(t *testing.T) { })).Return(nil) mockStore.On("GetStateByChannelIDAndVersion", channelID, uint64(1)).Return(state, nil) mockStore.On("ScheduleInitiateEscrowDeposit", "state123", uint64(0)).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(1), "").Return(nil) // Execute err := service.HandleEscrowDepositInitiated(ctx, mockStore, event) @@ -374,6 +423,7 @@ func TestHandleEscrowDepositChallenged_Success(t *testing.T) { })).Return(nil) mockStore.On("GetLastStateByChannelID", channelID, true).Return(state, nil) mockStore.On("ScheduleFinalizeEscrowDeposit", "state123", uint64(2)).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(3), "").Return(nil) // Execute err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) @@ -383,6 +433,395 @@ func TestHandleEscrowDepositChallenged_Success(t *testing.T) { mockStore.AssertExpectations(t) } +func TestHandleEscrowDepositChallenged_NoFinalize_SchedulesHomeChallenge(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + homeChannelID := "0xHomeChannel456" + userWallet := "0x1234567890123456789012345678901234567890" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + homeChannel := &core.Channel{ + ChannelID: homeChannelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + BlockchainID: 1, + } + + initiateState := &core.State{ + ID: "initiate-state-id", + Version: 3, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + BlockchainID: 1, + }, + EscrowLedger: &core.Ledger{ + BlockchainID: 2, + }, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == escrowChannelID && + ch.Status == core.ChannelStatusChallenged && + ch.StateVersion == 3 + })).Return(nil) + // No newer signed FINALIZE state available locally — node only has the INITIATE state. + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(initiateState, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(initiateState, nil) + mockStore.On("GetChannelByID", homeChannelID).Return(homeChannel, nil) + mockStore.On("ScheduleChallenge", "initiate-state-id", uint64(1)).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", escrowChannelID, uint64(3), "").Return(nil) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleFinalizeEscrowDeposit", mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_NoFinalize_HomeChannelNotOpen_SkipsChallenge(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + homeChannelID := "0xHomeChannel456" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + homeChannel := &core.Channel{ + ChannelID: homeChannelID, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + BlockchainID: 1, + } + + initiateState := &core.State{ + ID: "initiate-state-id", + Version: 3, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + BlockchainID: 1, + }, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(initiateState, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(initiateState, nil) + mockStore.On("GetChannelByID", homeChannelID).Return(homeChannel, nil) + mockStore.On("UpdateStateUserSigIfMissing", escrowChannelID, uint64(3), "").Return(nil) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "ScheduleFinalizeEscrowDeposit", mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_NoLocalState_NoSchedule(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(nil, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(nil, nil) + mockStore.On("UpdateStateUserSigIfMissing", escrowChannelID, uint64(3), "").Return(nil) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "ScheduleFinalizeEscrowDeposit", mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_HomeChannelIDNil_SkipsChallenge(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + initiateState := &core.State{ + ID: "initiate-state-id", + Version: 3, + HomeChannelID: nil, + HomeLedger: core.Ledger{ + BlockchainID: 1, + }, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(initiateState, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(initiateState, nil) + mockStore.On("UpdateStateUserSigIfMissing", escrowChannelID, uint64(3), "").Return(nil) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "ScheduleFinalizeEscrowDeposit", mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_HomeChannelNotFound_SkipsChallenge(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + homeChannelID := "0xHomeChannel456" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + initiateState := &core.State{ + ID: "initiate-state-id", + Version: 3, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + BlockchainID: 1, + }, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(initiateState, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(initiateState, nil) + mockStore.On("GetChannelByID", homeChannelID).Return((*core.Channel)(nil), nil) + mockStore.On("UpdateStateUserSigIfMissing", escrowChannelID, uint64(3), "").Return(nil) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "ScheduleFinalizeEscrowDeposit", mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_GetStateByVersionError_Propagates(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + dbErr := errors.New("db boom") + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(nil, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(nil, dbErr) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.ErrorIs(t, err, dbErr) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateUserSigIfMissing", mock.Anything, mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_GetHomeChannelError_Propagates(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + homeChannelID := "0xHomeChannel456" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + initiateState := &core.State{ + ID: "initiate-state-id", + Version: 3, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + BlockchainID: 1, + }, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + dbErr := errors.New("db boom") + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(initiateState, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(initiateState, nil) + mockStore.On("GetChannelByID", homeChannelID).Return((*core.Channel)(nil), dbErr) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.ErrorIs(t, err, dbErr) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateUserSigIfMissing", mock.Anything, mock.Anything, mock.Anything) +} + +func TestHandleEscrowDepositChallenged_HomeBlockchainIDZero_SkipsChallenge(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + escrowChannelID := "0xEscrowChannel123" + homeChannelID := "0xHomeChannel456" + challengeExpiry := uint64(time.Now().Add(time.Hour).Unix()) + + escrowChannel := &core.Channel{ + ChannelID: escrowChannelID, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + homeChannel := &core.Channel{ + ChannelID: homeChannelID, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + BlockchainID: 1, + } + + initiateState := &core.State{ + ID: "initiate-state-id", + Version: 3, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + BlockchainID: 0, + }, + } + + event := &core.EscrowDepositChallengedEvent{ + ChannelID: escrowChannelID, + StateVersion: 3, + ChallengeExpiry: challengeExpiry, + } + + mockStore.On("GetChannelByID", escrowChannelID).Return(escrowChannel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("GetLastStateByChannelID", escrowChannelID, true).Return(initiateState, nil) + mockStore.On("GetStateByChannelIDAndVersion", escrowChannelID, uint64(3)).Return(initiateState, nil) + mockStore.On("GetChannelByID", homeChannelID).Return(homeChannel, nil) + mockStore.On("UpdateStateUserSigIfMissing", escrowChannelID, uint64(3), "").Return(nil) + + err := service.HandleEscrowDepositChallenged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "ScheduleChallenge", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "ScheduleFinalizeEscrowDeposit", mock.Anything, mock.Anything) +} + func TestHandleEscrowDepositFinalized_Success(t *testing.T) { // Setup mockStore := new(MockStore) @@ -415,6 +854,7 @@ func TestHandleEscrowDepositFinalized_Success(t *testing.T) { ch.Status == core.ChannelStatusClosed && ch.StateVersion == 5 })).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(5), "").Return(nil) // Execute err := service.HandleEscrowDepositFinalized(ctx, mockStore, event) @@ -456,6 +896,7 @@ func TestHandleEscrowWithdrawalInitiated_Success(t *testing.T) { ch.Status == core.ChannelStatusOpen && ch.StateVersion == 1 })).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(1), "").Return(nil) // Execute err := service.HandleEscrowWithdrawalInitiated(ctx, mockStore, event) @@ -510,6 +951,7 @@ func TestHandleEscrowWithdrawalChallenged_Success(t *testing.T) { })).Return(nil) mockStore.On("GetLastStateByChannelID", channelID, true).Return(state, nil) mockStore.On("ScheduleFinalizeEscrowWithdrawal", "state123", uint64(2)).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(3), "").Return(nil) // Execute err := service.HandleEscrowWithdrawalChallenged(ctx, mockStore, event) @@ -551,6 +993,7 @@ func TestHandleEscrowWithdrawalFinalized_Success(t *testing.T) { ch.Status == core.ChannelStatusClosed && ch.StateVersion == 5 })).Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(5), "").Return(nil) // Execute err := service.HandleEscrowWithdrawalFinalized(ctx, mockStore, event) @@ -589,6 +1032,88 @@ func TestHandleUserLockedBalanceUpdated_Success(t *testing.T) { mockStore.AssertExpectations(t) } +// TestHandleHomeChannelCheckpointed_BackfillsUserSig covers the recovery path for the wedge +// scenario: a node-only state was checkpointed on chain (e.g. the receiver of a transfer signed +// the receiver state and submitted it directly). The reactor extracts the user signature from the +// event and the handler must forward it to the store so the local row matches what is enforced +// on chain. Without this, EnsureNoOngoingStateTransitions stays blocked on the now-stale prior +// bilateral state and the channel can only be unblocked via on-chain challenge. +func TestHandleHomeChannelCheckpointed_BackfillsUserSig(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + userSig := "0xabcdef0123456789" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 4, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, + UserSig: userSig, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.StateVersion == 5 + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(5), userSig).Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) +} + +// TestHandleHomeChannelCheckpointed_BackfillError surfaces store errors from the backfill so +// the surrounding event-processing transaction rolls back and the event can be retried. +func TestHandleHomeChannelCheckpointed_BackfillError(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 4, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, + UserSig: "0xdeadbeef", + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateUserSigIfMissing", channelID, uint64(5), "0xdeadbeef").Return(errors.New("db error")) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + + require.Error(t, err) + require.Contains(t, err.Error(), "db error") + mockStore.AssertExpectations(t) +} + func TestHandleUserLockedBalanceUpdated_StoreError(t *testing.T) { // Setup mockStore := new(MockStore) @@ -618,3 +1143,55 @@ func TestHandleUserLockedBalanceUpdated_StoreError(t *testing.T) { require.Contains(t, err.Error(), "db error") mockStore.AssertExpectations(t) } + +func TestHandleEscrowDepositsPurged_ClosesEscrowChannels(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + openEscrow := &core.Channel{ + ChannelID: "0xEscrow001", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + } + // Already closed — UpdateChannel must NOT be called for it. + closedEscrow := &core.Channel{ + ChannelID: "0xEscrow002", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusClosed, + } + + event := &core.EscrowDepositsPurgedEvent{ + EscrowIDs: []string{"0xEscrow001", "0xEscrow002", "0xUnknown003"}, + } + + mockStore.On("GetChannelByID", "0xEscrow001").Return(openEscrow, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == "0xEscrow001" && ch.Status == core.ChannelStatusClosed + })).Return(nil) + mockStore.On("GetChannelByID", "0xEscrow002").Return(closedEscrow, nil) + mockStore.On("GetChannelByID", "0xUnknown003").Return(nil, nil) + + err := service.HandleEscrowDepositsPurged(ctx, mockStore, event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) +} + +func TestHandleEscrowDepositsPurged_StoreError_Propagates(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + service := &EventHandlerService{} + + event := &core.EscrowDepositsPurgedEvent{ + EscrowIDs: []string{"0xEscrow001"}, + } + + mockStore.On("GetChannelByID", "0xEscrow001").Return(nil, errors.New("db error")) + + err := service.HandleEscrowDepositsPurged(ctx, mockStore, event) + + require.Error(t, err) + require.Contains(t, err.Error(), "db error") + mockStore.AssertExpectations(t) +} diff --git a/nitronode/event_handlers/testing.go b/nitronode/event_handlers/testing.go index 659abc428..7a59fcc77 100644 --- a/nitronode/event_handlers/testing.go +++ b/nitronode/event_handlers/testing.go @@ -51,6 +51,12 @@ func (m *MockStore) ScheduleCheckpoint(stateID string, chainID uint64) error { return args.Error(0) } +// ScheduleChallenge mocks scheduling a challenge operation +func (m *MockStore) ScheduleChallenge(stateID string, chainID uint64) error { + args := m.Called(stateID, chainID) + return args.Error(0) +} + // ScheduleInitiateEscrowDeposit mocks scheduling an escrow deposit checkpoint func (m *MockStore) ScheduleInitiateEscrowDeposit(stateID string, chainID uint64) error { args := m.Called(stateID, chainID) @@ -86,3 +92,9 @@ func (m *MockStore) UpdateUserStaked(wallet string, blockchainID uint64, amount args := m.Called(wallet, blockchainID, amount) return args.Error(0) } + +// UpdateStateUserSigIfMissing mocks backfilling a user signature for a stored state. +func (m *MockStore) UpdateStateUserSigIfMissing(channelID string, version uint64, userSig string) error { + args := m.Called(channelID, version, userSig) + return args.Error(0) +} diff --git a/nitronode/main.go b/nitronode/main.go index 8c596bfeb..185ef6e4b 100644 --- a/nitronode/main.go +++ b/nitronode/main.go @@ -52,6 +52,7 @@ func main() { MaxAppMetadataLen: vl.MaxAppMetadataLen, MaxRebalanceSignedUpdates: vl.MaxSignedUpdates, MaxSessionKeyIDs: vl.MaxSessionKeyIDs, + MaxSessionKeysPerUser: vl.MaxSessionKeysPerUser, RateLimitPerSec: bb.RateLimitPerSec, RateLimitBurst: bb.RateLimitBurst, } @@ -78,6 +79,11 @@ func main() { eventHandlerService := event_handlers.NewEventHandlerService() + nodeChannelSigner, err := core.NewChannelDefaultSigner(bb.StateSigner) + if err != nil { + logger.Fatal("failed to build node channel signer", "error", err) + } + for _, b := range blockchains { rpcURL, ok := bb.BlockchainRPCs[b.ID] if !ok { @@ -125,7 +131,7 @@ func main() { } }) - worker := NewBlockchainWorker(b.ID, blockchainClient, bb.DbStore, logger, bb.RuntimeMetrics) + worker := NewBlockchainWorker(b.ID, blockchainClient, bb.DbStore, nodeChannelSigner, bb.MemoryStore, logger, bb.RuntimeMetrics) worker.Start(blockchainCtx, func(err error) { if err != nil { logger.Fatal("blockchain worker stopped", "error", err, "blockchainID", b.ID) diff --git a/nitronode/metrics/exporter.go b/nitronode/metrics/exporter.go index ca0742cd9..d2a7262d9 100644 --- a/nitronode/metrics/exporter.go +++ b/nitronode/metrics/exporter.go @@ -289,11 +289,15 @@ func NewRuntimeMetricExporter(reg prometheus.Registerer) (RuntimeMetricExporter, rpcConnectionsTotal: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: MetricNamespace, Name: "rpc_connections_active", - Help: "Active RPC (WebSocket) connections. Labels: region, origin " + - "(both sourced from request headers / client metadata at connect " + - "time). Operator-decided values — bounded only by the set of " + - "connecting clients.", - }, []string{"region", "origin"}), + Help: "Active RPC (WebSocket) connections, labeled by application_id " + + "sourced from the app_id query parameter at connect time. " + + "Connections without an app_id are bucketed under _DEFAULT. " + + "Series for an application_id are deleted once its count drops to 0. " + + "NOTE: cardinality is bounded by the app_id format check " + + "(^[a-z0-9_-]{1,66}$) and by series shedding on disconnect — long-lived " + + "connections from many distinct but format-valid app_ids are not gated " + + "by a registry or per-app connection cap.", + }, []string{"application_id"}), rpcInflight: prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: MetricNamespace, Name: "rpc_inflight", @@ -412,8 +416,15 @@ func (m *runtimeMetricExporter) ObserveRPCDuration(method, path string, success m.rpcRequestDurationSeconds.WithLabelValues(method, path, result.String()).Observe(duration.Seconds()) } -func (m *runtimeMetricExporter) SetRPCConnections(region, origin string, count uint32) { - m.rpcConnectionsTotal.WithLabelValues(region, origin).Set(float64(count)) +func (m *runtimeMetricExporter) SetRPCConnections(applicationID string, count uint32) { + label := getApplicationIDLabelValue(applicationID) + if count == 0 { + // Shed the series when the bucket empties so unique application_id values + // from clients cannot accumulate unbounded gauge labels over time. + m.rpcConnectionsTotal.DeleteLabelValues(label) + return + } + m.rpcConnectionsTotal.WithLabelValues(label).Set(float64(count)) } func (m *runtimeMetricExporter) IncRPCInflight(method string) { diff --git a/nitronode/metrics/interface.go b/nitronode/metrics/interface.go index c7392d4b6..1e519a2d4 100644 --- a/nitronode/metrics/interface.go +++ b/nitronode/metrics/interface.go @@ -37,7 +37,7 @@ type RuntimeMetricExporter interface { IncRPCMessage(msgType rpc.MsgType, method string) IncRPCRequest(method, path string, success bool) ObserveRPCDuration(method, path string, success bool, duration time.Duration) - SetRPCConnections(region, origin string, count uint32) + SetRPCConnections(applicationID string, count uint32) IncRPCInflight(method string) DecRPCInflight(method string) @@ -68,7 +68,7 @@ func (noopRuntimeMetricExporter) IncAppSessionKeys() func (noopRuntimeMetricExporter) IncRPCMessage(rpc.MsgType, string) {} func (noopRuntimeMetricExporter) IncRPCRequest(string, string, bool) {} func (noopRuntimeMetricExporter) ObserveRPCDuration(string, string, bool, time.Duration) {} -func (noopRuntimeMetricExporter) SetRPCConnections(string, string, uint32) {} +func (noopRuntimeMetricExporter) SetRPCConnections(string, uint32) {} func (noopRuntimeMetricExporter) IncAppStateUpdate(string) {} func (noopRuntimeMetricExporter) IncAppSessionUpdateSigValidation(string, app.AppSessionSignerTypeV1, bool) { } diff --git a/nitronode/runtime.go b/nitronode/runtime.go index b114ee132..2daf1b82f 100644 --- a/nitronode/runtime.go +++ b/nitronode/runtime.go @@ -80,6 +80,14 @@ type FullConfig struct { RateLimitBurst float64 `yaml:"rate_limit_burst" env:"NITRONODE_RATE_LIMIT_BURST" env-default:"20"` WsProcessBufferSize int `yaml:"ws_process_buffer_size" env:"NITRONODE_WS_PROCESS_BUFFER_SIZE" env-default:"64"` WsWriteBufferSize int `yaml:"ws_write_buffer_size" env:"NITRONODE_WS_WRITE_BUFFER_SIZE" env-default:"64"` + // WsMaxMessageSize caps inbound WebSocket frame size in bytes. Frames over the + // cap close the connection with WebSocket close code 1009 before allocation. + // 128 KiB fits any legitimate v1 RPC with substantial headroom. + WsMaxMessageSize int64 `yaml:"ws_max_message_size" env:"NITRONODE_WS_MAX_MESSAGE_SIZE" env-default:"131072"` + // WsBytesPerSec is the steady-state byte budget per connection. Set <0 to disable. + WsBytesPerSec float64 `yaml:"ws_bytes_per_sec" env:"NITRONODE_WS_BYTES_PER_SEC" env-default:"262144"` + // WsBytesBurst is the burst capacity of the per-connection byte bucket. + WsBytesBurst float64 `yaml:"ws_bytes_burst" env:"NITRONODE_WS_BYTES_BURST" env-default:"1048576"` } type SignerConfig struct { @@ -90,11 +98,37 @@ type SignerConfig struct { // ValidationLimits defines configurable upper bounds for dynamic-length request fields. type ValidationLimits struct { - MaxParticipants int `yaml:"max_participants" env:"NITRONODE_MAX_PARTICIPANTS" env-default:"32"` - MaxSessionDataLen int `yaml:"max_session_data_len" env:"NITRONODE_MAX_SESSION_DATA_LEN" env-default:"1024"` - MaxAppMetadataLen int `yaml:"max_app_metadata_len" env:"NITRONODE_MAX_APP_METADATA_LEN" env-default:"1024"` - MaxSessionKeyIDs int `yaml:"max_session_key_ids" env:"NITRONODE_MAX_SESSION_KEY_IDS" env-default:"10"` - MaxSignedUpdates int `yaml:"max_signed_updates" env:"NITRONODE_MAX_SIGNED_UPDATES" env-default:"0"` + MaxParticipants int `yaml:"max_participants" env:"NITRONODE_MAX_PARTICIPANTS" env-default:"32"` + MaxSessionDataLen int `yaml:"max_session_data_len" env:"NITRONODE_MAX_SESSION_DATA_LEN" env-default:"1024"` + MaxAppMetadataLen int `yaml:"max_app_metadata_len" env:"NITRONODE_MAX_APP_METADATA_LEN" env-default:"1024"` + MaxSessionKeyIDs int `yaml:"max_session_key_ids" env:"NITRONODE_MAX_SESSION_KEY_IDS" env-default:"10"` + MaxSignedUpdates int `yaml:"max_signed_updates" env:"NITRONODE_MAX_SIGNED_UPDATES" env-default:"0"` + MaxSessionKeysPerUser int `yaml:"max_session_keys_per_user" env:"NITRONODE_MAX_SESSION_KEYS_PER_USER" env-default:"100"` +} + +func validateChannelChallengeConfig(minChallenge, maxChallenge uint32) error { + if minChallenge < core.ChannelMinChallengeDuration { + return fmt.Errorf( + "NITRONODE_CHANNEL_MIN_CHALLENGE_DURATION must be at least %d seconds, got %d", + core.ChannelMinChallengeDuration, + minChallenge, + ) + } + if maxChallenge > core.ChannelMaxChallengeDuration { + return fmt.Errorf( + "NITRONODE_CHANNEL_MAX_CHALLENGE_DURATION must be at most %d seconds, got %d", + core.ChannelMaxChallengeDuration, + maxChallenge, + ) + } + if minChallenge > maxChallenge { + return fmt.Errorf( + "NITRONODE_CHANNEL_MIN_CHALLENGE_DURATION must be <= NITRONODE_CHANNEL_MAX_CHALLENGE_DURATION, got min=%d max=%d", + minChallenge, + maxChallenge, + ) + } + return nil } // InitBackbone initializes the backbone components of the application. @@ -117,6 +151,9 @@ func InitBackbone() *Backbone { if err := cleanenv.ReadEnv(&conf); err != nil { logger.Fatal("failed to read env", "err", err) } + if err := validateChannelChallengeConfig(conf.ChannelMinChallengeDuration, conf.ChannelMaxChallengeDuration); err != nil { + logger.Fatal("invalid channel challenge duration config", "error", err) + } logger.Info("config loaded", "version", Version) @@ -195,11 +232,50 @@ func InitBackbone() *Backbone { // RPC Node // ------------------------------------------------ + // MF-C01 requires a hard frame-size cap; refuse to start without one even if + // the operator zeroed the env. The library would substitute its own default, + // but we want the misconfiguration surfaced rather than papered over. + if conf.WsMaxMessageSize <= 0 { + logger.Fatal( + "NITRONODE_WS_MAX_MESSAGE_SIZE must be > 0; the WebSocket frame cap cannot be disabled", + "ws_max_message_size", conf.WsMaxMessageSize, + ) + } + + bytesPerSec := conf.WsBytesPerSec + bytesBurst := conf.WsBytesBurst + // When the per-connection byte budget is enabled, the burst must be at least + // the max message size; otherwise every legitimate frame that passes + // SetReadLimit would still be rejected by the bucket on arrival, taking the + // node out via config alone. Fail fast at startup. + if bytesPerSec > 0 { + if bytesBurst <= 0 { + logger.Fatal( + "NITRONODE_WS_BYTES_BURST must be > 0 when NITRONODE_WS_BYTES_PER_SEC is enabled", + "ws_bytes_burst", bytesBurst, + "ws_bytes_per_sec", bytesPerSec, + ) + } + if bytesBurst < float64(conf.WsMaxMessageSize) { + logger.Fatal( + "NITRONODE_WS_BYTES_BURST must be >= NITRONODE_WS_MAX_MESSAGE_SIZE when byte limiting is enabled", + "ws_bytes_burst", bytesBurst, + "ws_max_message_size", conf.WsMaxMessageSize, + ) + } + } rpcNode, err := rpc.NewWebsocketNode(rpc.WebsocketNodeConfig{ Logger: logger, ObserveConnections: runtimeMetrics.SetRPCConnections, WsConnProcessBufferSize: conf.WsProcessBufferSize, WsConnWriteBufferSize: conf.WsWriteBufferSize, + WsConnMaxMessageSize: conf.WsMaxMessageSize, + NewFrameRateLimiter: func() rpc.FrameRateLimiter { + if bytesPerSec <= 0 { + return rpc.NoopFrameRateLimiter{} + } + return rpc.NewByteTokenBucket(bytesPerSec, bytesBurst) + }, }) if err != nil { logger.Fatal("failed to initialize RPC node", "error", err) diff --git a/nitronode/runtime_config_test.go b/nitronode/runtime_config_test.go new file mode 100644 index 000000000..e77148e34 --- /dev/null +++ b/nitronode/runtime_config_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "strings" + "testing" + + "github.com/layer-3/nitrolite/pkg/core" +) + +func TestValidateChannelChallengeConfig(t *testing.T) { + tests := []struct { + name string + minChallenge uint32 + maxChallenge uint32 + wantErr bool + errorContains string + }{ + { + name: "default range passes", + minChallenge: core.ChannelMinChallengeDuration, + maxChallenge: core.ChannelMaxChallengeDuration, + }, + { + name: "stricter range passes", + minChallenge: 172800, + maxChallenge: 345600, + }, + { + name: "max above contract limit fails", + minChallenge: core.ChannelMinChallengeDuration, + maxChallenge: core.ChannelMaxChallengeDuration + 1, + wantErr: true, + errorContains: "NITRONODE_CHANNEL_MAX_CHALLENGE_DURATION", + }, + { + name: "min below contract limit fails", + minChallenge: core.ChannelMinChallengeDuration - 1, + maxChallenge: core.ChannelMaxChallengeDuration, + wantErr: true, + errorContains: "NITRONODE_CHANNEL_MIN_CHALLENGE_DURATION", + }, + { + name: "min greater than max fails", + minChallenge: core.ChannelMaxChallengeDuration, + maxChallenge: core.ChannelMinChallengeDuration, + wantErr: true, + errorContains: "must be <=", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChannelChallengeConfig(tt.minChallenge, tt.maxChallenge) + + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errorContains) { + t.Fatalf("expected error containing %q, got %q", tt.errorContains, err.Error()) + } + return + } + + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} diff --git a/nitronode/store/database/app_session_key_state.go b/nitronode/store/database/app_session_key_state.go index 76bcdd597..1c693fd40 100644 --- a/nitronode/store/database/app_session_key_state.go +++ b/nitronode/store/database/app_session_key_state.go @@ -20,6 +20,7 @@ type AppSessionKeyStateV1 struct { AppSessionIDs []AppSessionKeyAppSessionIDV1 `gorm:"foreignKey:SessionKeyStateID;references:ID"` ExpiresAt time.Time `gorm:"column:expires_at;not null"` UserSig string `gorm:"column:user_sig;not null"` + SessionKeySig string `gorm:"column:session_key_sig"` CreatedAt time.Time } @@ -58,12 +59,13 @@ func (s *DBStore) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error } dbState := AppSessionKeyStateV1{ - ID: id, - UserAddress: userAddress, - SessionKey: sessionKey, - Version: state.Version, - ExpiresAt: state.ExpiresAt.UTC(), - UserSig: state.UserSig, + ID: id, + UserAddress: userAddress, + SessionKey: sessionKey, + Version: state.Version, + ExpiresAt: state.ExpiresAt.UTC(), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } if err := s.db.Create(&dbState).Error; err != nil { @@ -96,35 +98,58 @@ func (s *DBStore) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error } } + if err := upsertCurrentSessionKeyState(s.db, userAddress, sessionKey, SessionKeyKindAppSession, state.Version); err != nil { + return err + } + return nil } // GetLastAppSessionKeyStates retrieves the latest session key states for a user with optional filtering. -// Returns only the highest-version row per session key that has not expired. -func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string) ([]app.AppSessionKeyStateV1, error) { +// Reads filter the current_session_key_states_v1 pointer table by (user_address, kind=app_session) +// and JOIN history on (user_address, session_key, version). Per-request DB work is bounded by +// the number of distinct session keys for the user, regardless of version churn in history. +// When includeInactive is false the same now is applied to both the count and the list query so +// pagination stays consistent across the two reads. Results are paginated; totalCount is the +// unpaginated total of matching session keys. +func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) { wallet = strings.ToLower(wallet) + now := time.Now().UTC() - subQuery := s.db.Model(&AppSessionKeyStateV1{}). - Select("user_address, session_key, MAX(version) as max_version"). - Where("user_address = ?", wallet). - Group("user_address, session_key") - + pointerQuery := s.db.Table("current_session_key_states_v1 AS c"). + Where("c.user_address = ? AND c.kind = ? AND c.version > 0", wallet, SessionKeyKindAppSession) if sessionKey != nil && *sessionKey != "" { - subQuery = subQuery.Where("session_key = ?", strings.ToLower(*sessionKey)) + pointerQuery = pointerQuery.Where("c.session_key = ?", strings.ToLower(*sessionKey)) + } + if !includeInactive { + pointerQuery = pointerQuery. + Joins("JOIN app_session_key_states_v1 h ON h.user_address = c.user_address AND h.session_key = c.session_key AND h.version = c.version"). + Where("h.expires_at > ?", now) } - query := s.db. - Joins("JOIN (?) AS latest ON app_session_key_states_v1.user_address = latest.user_address AND app_session_key_states_v1.session_key = latest.session_key AND app_session_key_states_v1.version = latest.max_version", subQuery). + var totalCount int64 + if err := pointerQuery.Count(&totalCount).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count session key states: %w", err) + } + + query := s.db.Model(&AppSessionKeyStateV1{}). + Joins("JOIN current_session_key_states_v1 c ON c.user_address = app_session_key_states_v1.user_address AND c.session_key = app_session_key_states_v1.session_key AND c.version = app_session_key_states_v1.version"). + Where("c.user_address = ? AND c.kind = ? AND c.version > 0", wallet, SessionKeyKindAppSession). Preload("ApplicationIDs"). Preload("AppSessionIDs"). - Order("app_session_key_states_v1.created_at DESC") + Order("app_session_key_states_v1.created_at DESC, app_session_key_states_v1.id ASC"). + Limit(int(limit)). + Offset(int(offset)) + if sessionKey != nil && *sessionKey != "" { + query = query.Where("c.session_key = ?", strings.ToLower(*sessionKey)) + } + if !includeInactive { + query = query.Where("app_session_key_states_v1.expires_at > ?", now) + } var dbStates []AppSessionKeyStateV1 if err := query.Find(&dbStates).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return []app.AppSessionKeyStateV1{}, nil - } - return nil, fmt.Errorf("failed to get session key states: %w", err) + return nil, 0, fmt.Errorf("failed to get session key states: %w", err) } states := make([]app.AppSessionKeyStateV1, len(dbStates)) @@ -132,23 +157,20 @@ func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string) states[i] = dbSessionKeyStateToCore(&dbState) } - return states, nil + return states, uint32(totalCount), nil } // GetLastAppSessionKeyVersion returns the latest version of a session key state for a user. -// Returns 0 if no state exists. +// Reads from the pointer table; returns 0 if no state exists or the pointer is at its seeded +// value (LockSessionKeyState created the row but no submit has succeeded yet). func (s *DBStore) GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint64, error) { wallet = strings.ToLower(wallet) sessionKey = strings.ToLower(sessionKey) - var result struct { - Version uint64 - } - err := s.db.Model(&AppSessionKeyStateV1{}). - Select("version"). - Where("user_address = ? AND session_key = ?", wallet, sessionKey). - Order("version DESC"). - Take(&result).Error + var pointer CurrentSessionKeyStateV1 + err := s.db. + Where("user_address = ? AND session_key = ? AND kind = ?", wallet, sessionKey, SessionKeyKindAppSession). + Take(&pointer).Error if err != nil { if err == gorm.ErrRecordNotFound { @@ -157,20 +179,20 @@ func (s *DBStore) GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint64 return 0, fmt.Errorf("failed to check session key state: %w", err) } - return result.Version, nil + return pointer.Version, nil } // GetLastAppSessionKeyState retrieves the latest version of a specific session key for a user. -// A newer version always supersedes older ones, even if expired. -// Returns nil if no state exists. +// A newer version always supersedes older ones, even if expired. Resolved via the pointer +// table; returns nil if no state exists. func (s *DBStore) GetLastAppSessionKeyState(wallet, sessionKey string) (*app.AppSessionKeyStateV1, error) { wallet = strings.ToLower(wallet) sessionKey = strings.ToLower(sessionKey) var dbState AppSessionKeyStateV1 err := s.db. - Where("user_address = ? AND session_key = ?", wallet, sessionKey). - Order("version DESC"). + Joins("JOIN current_session_key_states_v1 c ON c.user_address = app_session_key_states_v1.user_address AND c.session_key = app_session_key_states_v1.session_key AND c.version = app_session_key_states_v1.version AND c.kind = ?", SessionKeyKindAppSession). + Where("app_session_key_states_v1.user_address = ? AND app_session_key_states_v1.session_key = ? AND c.version > 0", wallet, sessionKey). Preload("ApplicationIDs"). Preload("AppSessionIDs"). First(&dbState).Error @@ -196,16 +218,13 @@ func (s *DBStore) GetAppSessionKeyOwner(sessionKey, appSessionId string) (string // Subquery to get the application ID from the app session appSubQuery := s.db.Model(&AppSessionV1{}).Select("application_id").Where("id = ?", appSessionId) - maxVersionSubQ := s.db.Model(&AppSessionKeyStateV1{}). - Select("MAX(version)"). - Where("session_key = ?", sessionKey) - var dbState AppSessionKeyStateV1 err := s.db. + Joins("JOIN current_session_key_states_v1 c ON c.user_address = app_session_key_states_v1.user_address AND c.session_key = app_session_key_states_v1.session_key AND c.version = app_session_key_states_v1.version AND c.kind = ?", SessionKeyKindAppSession). Joins("LEFT JOIN app_session_key_app_sessions_v1 ON app_session_key_app_sessions_v1.session_key_state_id = app_session_key_states_v1.id"). Joins("LEFT JOIN app_session_key_applications_v1 ON app_session_key_applications_v1.session_key_state_id = app_session_key_states_v1.id"). - Where("app_session_key_states_v1.session_key = ? AND app_session_key_states_v1.version = (?) AND app_session_key_states_v1.expires_at > ? AND (app_session_key_app_sessions_v1.app_session_id = ? OR app_session_key_applications_v1.application_id = (?))", - sessionKey, maxVersionSubQ, time.Now().UTC(), appSessionId, appSubQuery). + Where("app_session_key_states_v1.session_key = ? AND c.version > 0 AND app_session_key_states_v1.expires_at > ? AND (app_session_key_app_sessions_v1.app_session_id = ? OR app_session_key_applications_v1.application_id = (?))", + sessionKey, time.Now().UTC(), appSessionId, appSubQuery). First(&dbState).Error if err != nil { @@ -233,6 +252,7 @@ func dbSessionKeyStateToCore(dbState *AppSessionKeyStateV1) app.AppSessionKeySta UserAddress: dbState.UserAddress, SessionKey: dbState.SessionKey, Version: dbState.Version, + SessionKeySig: dbState.SessionKeySig, ApplicationIDs: applicationIDs, AppSessionIDs: appSessionIDs, ExpiresAt: dbState.ExpiresAt, diff --git a/nitronode/store/database/app_session_key_state_test.go b/nitronode/store/database/app_session_key_state_test.go index 823f76049..dc6bf2019 100644 --- a/nitronode/store/database/app_session_key_state_test.go +++ b/nitronode/store/database/app_session_key_state_test.go @@ -406,7 +406,7 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreAppSessionKeyState(stateB1)) - results, err := store.GetLastAppSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) @@ -455,7 +455,7 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { require.NoError(t, store.StoreAppSessionKeyState(stateB)) sessionKey := testKeyA - results, err := store.GetLastAppSessionKeyStates(testUser1, &sessionKey) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, &sessionKey, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 1) @@ -488,7 +488,7 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreAppSessionKeyState(stateB)) - results, err := store.GetLastAppSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) // Both keys returned — caller is responsible for checking expiration @@ -501,7 +501,7 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { store := NewDBStore(db) - results, err := store.GetLastAppSessionKeyStates("0x0000000000000000000000000000000000000099", nil) + results, _, err := store.GetLastAppSessionKeyStates("0x0000000000000000000000000000000000000099", nil, true, 100, 0) require.NoError(t, err) assert.Empty(t, results) }) @@ -530,12 +530,198 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreAppSessionKeyState(state2)) - results, err := store.GetLastAppSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, testUser1, results[0].UserAddress) }) + + t.Run("Pagination - limit and offset bound results, totalCount reflects unpaginated total", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + const numKeys = 5 + for i := 0; i < numKeys; i++ { + state := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: fakeSessionKey(i), + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreAppSessionKeyState(state)) + } + + page1, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 2, 0) + require.NoError(t, err) + assert.Len(t, page1, 2) + assert.Equal(t, uint32(numKeys), total) + + page2, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 2, 2) + require.NoError(t, err) + assert.Len(t, page2, 2) + assert.Equal(t, uint32(numKeys), total) + + page3, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 2, 4) + require.NoError(t, err) + assert.Len(t, page3, 1) + assert.Equal(t, uint32(numKeys), total) + + seen := map[string]struct{}{} + for _, s := range page1 { + seen[s.SessionKey] = struct{}{} + } + for _, s := range page2 { + _, dup := seen[s.SessionKey] + assert.False(t, dup, "page2 overlaps page1 for %s", s.SessionKey) + seen[s.SessionKey] = struct{}{} + } + for _, s := range page3 { + _, dup := seen[s.SessionKey] + assert.False(t, dup, "page3 overlaps earlier page for %s", s.SessionKey) + } + }) + + t.Run("includeInactive=false filters out expired latest states and matches count", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + // Active latest + active := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsigA", + } + require.NoError(t, store.StoreAppSessionKeyState(active)) + + // Expired latest + expired := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyB, + Version: 1, + ExpiresAt: time.Now().Add(-1 * time.Hour), + UserSig: "0xsigB", + } + require.NoError(t, store.StoreAppSessionKeyState(expired)) + + results, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, false, 100, 0) + require.NoError(t, err) + assert.Len(t, results, 1) + assert.Equal(t, testKeyA, results[0].SessionKey) + assert.Equal(t, uint32(1), total) + + // includeInactive=true surfaces both, with count matching + all, allTotal, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) + require.NoError(t, err) + assert.Len(t, all, 2) + assert.Equal(t, uint32(2), allTotal) + }) + + t.Run("Pagination - mixed active/expired with offset>0 keeps count and list consistent", func(t *testing.T) { + // Regression guard: with includeInactive=false the store must apply the + // expires_at filter to *both* the page slice and the unpaginated count + // (using the same `now` binding), otherwise pagination drifts when the + // caller walks past offset 0. + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + const numActive = 3 + const numExpired = 2 + for i := 0; i < numActive; i++ { + state := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: fakeSessionKey(i), + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreAppSessionKeyState(state)) + } + for i := 0; i < numExpired; i++ { + state := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: fakeSessionKey(numActive + i), + Version: 1, + ExpiresAt: time.Now().Add(-1 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreAppSessionKeyState(state)) + } + + page1, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, false, 2, 0) + require.NoError(t, err) + assert.Len(t, page1, 2) + assert.Equal(t, uint32(numActive), total) + + page2, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, false, 2, 2) + require.NoError(t, err) + assert.Len(t, page2, 1) + assert.Equal(t, uint32(numActive), total) + + empty, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, false, 2, 4) + require.NoError(t, err) + assert.Empty(t, empty) + assert.Equal(t, uint32(numActive), total) + + seen := map[string]struct{}{} + for _, s := range append(append([]app.AppSessionKeyStateV1{}, page1...), page2...) { + assert.True(t, s.ExpiresAt.After(time.Now()), "expired state surfaced for %s", s.SessionKey) + _, dup := seen[s.SessionKey] + assert.False(t, dup, "duplicate session key %s across pages", s.SessionKey) + seen[s.SessionKey] = struct{}{} + } + + allPage1, allTotal, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 2, 0) + require.NoError(t, err) + assert.Len(t, allPage1, 2) + assert.Equal(t, uint32(numActive+numExpired), allTotal) + + allPage2, allTotal, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 2, 2) + require.NoError(t, err) + assert.Len(t, allPage2, 2) + assert.Equal(t, uint32(numActive+numExpired), allTotal) + + allPage3, allTotal, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 2, 4) + require.NoError(t, err) + assert.Len(t, allPage3, 1) + assert.Equal(t, uint32(numActive+numExpired), allTotal) + }) + + t.Run("includeInactive=false combined with session_key filter excludes the expired match", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + expired := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(-1 * time.Hour), + UserSig: "0xsigA", + } + require.NoError(t, store.StoreAppSessionKeyState(expired)) + + sessionKey := testKeyA + results, total, err := store.GetLastAppSessionKeyStates(testUser1, &sessionKey, false, 100, 0) + require.NoError(t, err) + assert.Empty(t, results) + assert.Equal(t, uint32(0), total) + + all, allTotal, err := store.GetLastAppSessionKeyStates(testUser1, &sessionKey, true, 100, 0) + require.NoError(t, err) + assert.Len(t, all, 1) + assert.Equal(t, uint32(1), allTotal) + }) } func TestDBStore_AppSessionKeyState_ForeignRelations(t *testing.T) { @@ -736,7 +922,7 @@ func TestDBStore_AppSessionKeyState_ForeignRelations(t *testing.T) { require.NoError(t, store.StoreAppSessionKeyState(stateB)) // GetLastAppSessionKeyStates returns both — verify preloaded relations are correct - results, err := store.GetLastAppSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) diff --git a/nitronode/store/database/blockchain_action.go b/nitronode/store/database/blockchain_action.go index eaa69c374..3d5d66c7a 100644 --- a/nitronode/store/database/blockchain_action.go +++ b/nitronode/store/database/blockchain_action.go @@ -12,6 +12,7 @@ type BlockchainActionType uint8 const ( ActionTypeCheckpoint BlockchainActionType = 1 + ActionTypeChallenge BlockchainActionType = 2 ActionTypeInitiateEscrowDeposit BlockchainActionType = 10 ActionTypeFinalizeEscrowDeposit BlockchainActionType = 11 @@ -24,6 +25,8 @@ func (t BlockchainActionType) String() string { switch t { case ActionTypeCheckpoint: return "checkpoint" + case ActionTypeChallenge: + return "challenge" case ActionTypeInitiateEscrowDeposit: return "initiate_escrow_deposit" case ActionTypeFinalizeEscrowDeposit: @@ -68,6 +71,13 @@ func (s *DBStore) ScheduleCheckpoint(stateID string, blockchainID uint64) error return s.scheduleStateEnforcement(stateID, blockchainID, ActionTypeCheckpoint) } +// ScheduleChallenge queues a blockchain action to challenge a channel on its home blockchain +// using the provided state. The worker submits the state via challengeChannel(...) with a +// node-produced challenger signature. +func (s *DBStore) ScheduleChallenge(stateID string, blockchainID uint64) error { + return s.scheduleStateEnforcement(stateID, blockchainID, ActionTypeChallenge) +} + // ScheduleInitiateEscrowDeposit queues a blockchain action to initiate escrow deposit on home blockchain. func (s *DBStore) ScheduleInitiateEscrowDeposit(stateID string, blockchainID uint64) error { return s.scheduleStateEnforcement(stateID, blockchainID, ActionTypeInitiateEscrowDeposit) diff --git a/nitronode/store/database/channel.go b/nitronode/store/database/channel.go index feb605bef..33a94c45e 100644 --- a/nitronode/store/database/channel.go +++ b/nitronode/store/database/channel.go @@ -79,6 +79,10 @@ func (s *DBStore) GetChannelByID(channelID string) (*core.Channel, error) { } // GetActiveHomeChannel retrieves the active home channel for a user's wallet and asset. +// "Active" means the node has co-signed the channel definition (status Void or Open) — it +// does NOT guarantee the channel has been materialized onchain. Callers requiring onchain +// materialization (e.g., cross-chain escrow operations) must additionally check that +// Status == ChannelStatusOpen. func (s *DBStore) GetActiveHomeChannel(wallet, asset string) (*core.Channel, error) { var dbChannel Channel err := s.db. @@ -95,27 +99,72 @@ func (s *DBStore) GetActiveHomeChannel(wallet, asset string) (*core.Channel, err return databaseChannelToCore(&dbChannel), nil } -// CheckOpenChannel verifies if a user has an active channel for the given asset -// and returns the approved signature validators if such a channel exists. -func (s *DBStore) CheckOpenChannel(wallet, asset string) (string, bool, error) { - var approvedSigValidators string +// GetNotClosedHomeChannel retrieves the home channel for a user's wallet and asset as long +// as it has not reached ChannelStatusClosed. This is broader than GetActiveHomeChannel +// (which stops at Open) and is intended for read paths that must remain functional after +// an off-chain Finalize, such as fetching channel data before submitting an on-chain close. +func (s *DBStore) GetNotClosedHomeChannel(wallet, asset string) (*core.Channel, error) { + var dbChannel Channel + err := s.db. + Where("user_wallet = ? AND asset = ?", strings.ToLower(wallet), strings.ToLower(asset)). + Where("status != ? AND type = ?", core.ChannelStatusClosed, core.ChannelTypeHome). + First(&dbChannel).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get non-closed home channel: %w", err) + } + + return databaseChannelToCore(&dbChannel), nil +} + +// HasNonClosedHomeChannel returns true if any home channel for (wallet, asset) has a +// status other than Closed, meaning a channel lifecycle is still in progress. +func (s *DBStore) HasNonClosedHomeChannel(wallet, asset string) (bool, error) { + var count int64 + err := s.db.Model(&Channel{}). + Where("user_wallet = ? AND asset = ? AND type = ? AND status != ?", + strings.ToLower(wallet), strings.ToLower(asset), + core.ChannelTypeHome, core.ChannelStatusClosed). + Count(&count).Error + if err != nil { + return false, fmt.Errorf("failed to check non-closed home channel: %w", err) + } + return count > 0, nil +} + +// CheckActiveChannel verifies if a user has an active home channel for the given asset +// and returns its approved signature validators along with the channel status. +// "Active" includes Void (DB-only, awaiting onchain confirmation) and Open (materialized +// onchain). This is intentional: non-escrow offchain transitions (transfers, etc.) are +// permitted before onchain confirmation lands. Callers operating on cross-chain escrow +// flows that depend on onchain home-channel materialization must check that the returned +// status is ChannelStatusOpen. +// +// A nil status pointer means no active channel was found. +func (s *DBStore) CheckActiveChannel(wallet, asset string) (string, *core.ChannelStatus, error) { + var row struct { + ApprovedSigValidators string `gorm:"column:approved_sig_validators"` + Status core.ChannelStatus `gorm:"column:status"` + } result := s.db.Raw(` - SELECT approved_sig_validators + SELECT approved_sig_validators, status FROM channels WHERE user_wallet = ? AND asset = ? AND status <= ? AND type = ? LIMIT 1 - `, strings.ToLower(wallet), strings.ToLower(asset), core.ChannelStatusOpen, core.ChannelTypeHome).Scan(&approvedSigValidators) + `, strings.ToLower(wallet), strings.ToLower(asset), core.ChannelStatusOpen, core.ChannelTypeHome).Scan(&row) if result.Error != nil { - return "", false, fmt.Errorf("failed to check open channel: %w", result.Error) + return "", nil, fmt.Errorf("failed to check active channel: %w", result.Error) } if result.RowsAffected == 0 { - return "", false, nil + return "", nil, nil } - return approvedSigValidators, true, nil + return row.ApprovedSigValidators, &row.Status, nil } // GetUserChannels retrieves all channels for a user with optional status, asset, and type filters. diff --git a/nitronode/store/database/channel_session_key_state.go b/nitronode/store/database/channel_session_key_state.go index aa41159a7..750b36cdb 100644 --- a/nitronode/store/database/channel_session_key_state.go +++ b/nitronode/store/database/channel_session_key_state.go @@ -11,15 +11,16 @@ import ( // ChannelSessionKeyStateV1 represents a channel session key state in the database. type ChannelSessionKeyStateV1 struct { - ID string `gorm:"column:id;primaryKey"` - UserAddress string `gorm:"column:user_address;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:1"` - SessionKey string `gorm:"column:session_key;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:2"` - Version uint64 `gorm:"column:version;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:3"` - Assets []ChannelSessionKeyAssetV1 `gorm:"foreignKey:SessionKeyStateID;references:ID"` - MetadataHash string `gorm:"column:metadata_hash;type:char(66);not null"` - ExpiresAt time.Time `gorm:"column:expires_at;not null"` - UserSig string `gorm:"column:user_sig;not null"` - CreatedAt time.Time + ID string `gorm:"column:id;primaryKey"` + UserAddress string `gorm:"column:user_address;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:1"` + SessionKey string `gorm:"column:session_key;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:2"` + Version uint64 `gorm:"column:version;not null;uniqueIndex:idx_channel_session_key_states_v1_user_key_ver,priority:3"` + Assets []ChannelSessionKeyAssetV1 `gorm:"foreignKey:SessionKeyStateID;references:ID"` + MetadataHash string `gorm:"column:metadata_hash;type:char(66);not null"` + ExpiresAt time.Time `gorm:"column:expires_at;not null"` + UserSig string `gorm:"column:user_sig;not null"` + SessionKeySig string `gorm:"column:session_key_sig"` + CreatedAt time.Time } func (ChannelSessionKeyStateV1) TableName() string { @@ -46,19 +47,20 @@ func (s *DBStore) StoreChannelSessionKeyState(state core.ChannelSessionKeyStateV return fmt.Errorf("failed to generate session key state ID: %w", err) } - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.Version, state.Assets, state.ExpiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(userAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) if err != nil { return fmt.Errorf("failed to compute metadata hash: %w", err) } dbState := ChannelSessionKeyStateV1{ - ID: id, - UserAddress: userAddress, - SessionKey: sessionKey, - Version: state.Version, - MetadataHash: strings.ToLower(metadataHash.Hex()), - ExpiresAt: state.ExpiresAt.UTC(), - UserSig: state.UserSig, + ID: id, + UserAddress: userAddress, + SessionKey: sessionKey, + Version: state.Version, + MetadataHash: strings.ToLower(metadataHash.Hex()), + ExpiresAt: state.ExpiresAt.UTC(), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } if err := s.db.Create(&dbState).Error; err != nil { @@ -78,34 +80,57 @@ func (s *DBStore) StoreChannelSessionKeyState(state core.ChannelSessionKeyStateV } } + if err := upsertCurrentSessionKeyState(s.db, userAddress, sessionKey, SessionKeyKindChannel, state.Version); err != nil { + return err + } + return nil } // GetLastChannelSessionKeyStates retrieves the latest channel session key states for a user with optional filtering. -// Returns only the highest-version row per session key that has not expired. -func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string) ([]core.ChannelSessionKeyStateV1, error) { +// Reads filter the current_session_key_states_v1 pointer table by (user_address, kind=channel) +// and JOIN history on (user_address, session_key, version). Per-request DB work is bounded by +// the number of distinct session keys for the user, regardless of version churn in history. +// When includeInactive is false the same now is applied to both the count and the list query so +// pagination stays consistent across the two reads. Results are paginated; totalCount is the +// unpaginated total of matching session keys. +func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) { wallet = strings.ToLower(wallet) + now := time.Now().UTC() - subQuery := s.db.Model(&ChannelSessionKeyStateV1{}). - Select("user_address, session_key, MAX(version) as max_version"). - Where("user_address = ?", wallet). - Group("user_address, session_key") - + pointerQuery := s.db.Table("current_session_key_states_v1 AS c"). + Where("c.user_address = ? AND c.kind = ? AND c.version > 0", wallet, SessionKeyKindChannel) if sessionKey != nil && *sessionKey != "" { - subQuery = subQuery.Where("session_key = ?", strings.ToLower(*sessionKey)) + pointerQuery = pointerQuery.Where("c.session_key = ?", strings.ToLower(*sessionKey)) + } + if !includeInactive { + pointerQuery = pointerQuery. + Joins("JOIN channel_session_key_states_v1 h ON h.user_address = c.user_address AND h.session_key = c.session_key AND h.version = c.version"). + Where("h.expires_at > ?", now) + } + + var totalCount int64 + if err := pointerQuery.Count(&totalCount).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count channel session key states: %w", err) } - query := s.db. - Joins("JOIN (?) AS latest ON channel_session_key_states_v1.user_address = latest.user_address AND channel_session_key_states_v1.session_key = latest.session_key AND channel_session_key_states_v1.version = latest.max_version", subQuery). + query := s.db.Model(&ChannelSessionKeyStateV1{}). + Joins("JOIN current_session_key_states_v1 c ON c.user_address = channel_session_key_states_v1.user_address AND c.session_key = channel_session_key_states_v1.session_key AND c.version = channel_session_key_states_v1.version"). + Where("c.user_address = ? AND c.kind = ? AND c.version > 0", wallet, SessionKeyKindChannel). Preload("Assets"). - Order("channel_session_key_states_v1.created_at DESC") + Order("channel_session_key_states_v1.created_at DESC, channel_session_key_states_v1.id ASC"). + Limit(int(limit)). + Offset(int(offset)) + if sessionKey != nil && *sessionKey != "" { + query = query.Where("c.session_key = ?", strings.ToLower(*sessionKey)) + } + if !includeInactive { + query = query.Where("channel_session_key_states_v1.expires_at > ?", now) + } var dbStates []ChannelSessionKeyStateV1 if err := query.Find(&dbStates).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return []core.ChannelSessionKeyStateV1{}, nil - } - return nil, fmt.Errorf("failed to get channel session key states: %w", err) + return nil, 0, fmt.Errorf("failed to get channel session key states: %w", err) } states := make([]core.ChannelSessionKeyStateV1, len(dbStates)) @@ -113,23 +138,20 @@ func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *stri states[i] = dbChannelSessionKeyStateToCore(&dbState) } - return states, nil + return states, uint32(totalCount), nil } // GetLastChannelSessionKeyVersion returns the latest version of a channel session key state. -// Returns 0 if no state exists. +// Reads from the pointer table; returns 0 if no state exists or the pointer is at its seeded +// value (LockSessionKeyState created the row but no submit has succeeded yet). func (s *DBStore) GetLastChannelSessionKeyVersion(wallet, sessionKey string) (uint64, error) { wallet = strings.ToLower(wallet) sessionKey = strings.ToLower(sessionKey) - var result struct { - Version uint64 - } - err := s.db.Model(&ChannelSessionKeyStateV1{}). - Select("version"). - Where("user_address = ? AND session_key = ?", wallet, sessionKey). - Order("version DESC"). - Take(&result).Error + var pointer CurrentSessionKeyStateV1 + err := s.db. + Where("user_address = ? AND session_key = ? AND kind = ?", wallet, sessionKey, SessionKeyKindChannel). + Take(&pointer).Error if err != nil { if err == gorm.ErrRecordNotFound { @@ -138,7 +160,7 @@ func (s *DBStore) GetLastChannelSessionKeyVersion(wallet, sessionKey string) (ui return 0, fmt.Errorf("failed to check channel session key state: %w", err) } - return result.Version, nil + return pointer.Version, nil } // ValidateChannelSessionKeyForAsset checks in a single query that: @@ -155,15 +177,12 @@ func (s *DBStore) ValidateChannelSessionKeyForAsset(wallet, sessionKey, asset, m now := time.Now().UTC() - maxVersionSubQ := s.db.Model(&ChannelSessionKeyStateV1{}). - Select("MAX(version)"). - Where("user_address = ? AND session_key = ?", wallet, sessionKey) - var count int64 err := s.db.Model(&ChannelSessionKeyStateV1{}). - Where("user_address = ? AND session_key = ? AND expires_at > ? AND metadata_hash = ? AND version = (?)", - wallet, sessionKey, now, metadataHash, maxVersionSubQ). + Joins("JOIN current_session_key_states_v1 c ON c.user_address = channel_session_key_states_v1.user_address AND c.session_key = channel_session_key_states_v1.session_key AND c.version = channel_session_key_states_v1.version AND c.kind = ?", SessionKeyKindChannel). Joins("JOIN channel_session_key_assets_v1 ON channel_session_key_assets_v1.session_key_state_id = channel_session_key_states_v1.id AND channel_session_key_assets_v1.asset = ?", asset). + Where("channel_session_key_states_v1.user_address = ? AND channel_session_key_states_v1.session_key = ? AND channel_session_key_states_v1.expires_at > ? AND channel_session_key_states_v1.metadata_hash = ?", + wallet, sessionKey, now, metadataHash). Count(&count).Error if err != nil { @@ -180,11 +199,12 @@ func dbChannelSessionKeyStateToCore(dbState *ChannelSessionKeyStateV1) core.Chan } return core.ChannelSessionKeyStateV1{ - UserAddress: dbState.UserAddress, - SessionKey: dbState.SessionKey, - Version: dbState.Version, - Assets: assets, - ExpiresAt: dbState.ExpiresAt, - UserSig: dbState.UserSig, + UserAddress: dbState.UserAddress, + SessionKey: dbState.SessionKey, + Version: dbState.Version, + Assets: assets, + ExpiresAt: dbState.ExpiresAt, + UserSig: dbState.UserSig, + SessionKeySig: dbState.SessionKeySig, } } diff --git a/nitronode/store/database/channel_session_key_state_test.go b/nitronode/store/database/channel_session_key_state_test.go index 4263cc63d..9781ca0e8 100644 --- a/nitronode/store/database/channel_session_key_state_test.go +++ b/nitronode/store/database/channel_session_key_state_test.go @@ -44,7 +44,7 @@ func TestDBStore_StoreChannelSessionKeyState(t *testing.T) { require.NoError(t, err) // Verify via GetLastChannelSessionKeyStates - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) require.Len(t, results, 1) @@ -75,7 +75,7 @@ func TestDBStore_StoreChannelSessionKeyState(t *testing.T) { err := store.StoreChannelSessionKeyState(state) require.NoError(t, err) - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) require.Len(t, results, 1) assert.Empty(t, results[0].Assets) @@ -100,7 +100,7 @@ func TestDBStore_StoreChannelSessionKeyState(t *testing.T) { require.NoError(t, err) // Query with mixed case - should still find it - results, err := store.GetLastChannelSessionKeyStates("0xAbCdEf1234567890AbCdEf1234567890AbCdEf12", nil) + results, _, err := store.GetLastChannelSessionKeyStates("0xAbCdEf1234567890AbCdEf1234567890AbCdEf12", nil, true, 100, 0) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, "0xabcdef1234567890abcdef1234567890abcdef12", results[0].UserAddress) @@ -157,7 +157,7 @@ func TestDBStore_StoreChannelSessionKeyState(t *testing.T) { require.NoError(t, store.StoreChannelSessionKeyState(state2)) // Should return only the latest version - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) require.Len(t, results, 1) assert.Equal(t, uint64(2), results[0].Version) @@ -223,7 +223,7 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(stateB1)) - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) @@ -272,7 +272,7 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { require.NoError(t, store.StoreChannelSessionKeyState(stateB)) sessionKey := testKeyA - results, err := store.GetLastChannelSessionKeyStates(testUser1, &sessionKey) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, &sessionKey, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 1) @@ -305,7 +305,7 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(stateB)) - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) // Both keys returned — caller is responsible for checking expiration @@ -318,7 +318,7 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { store := NewDBStore(db) - results, err := store.GetLastChannelSessionKeyStates("0x0000000000000000000000000000000000000099", nil) + results, _, err := store.GetLastChannelSessionKeyStates("0x0000000000000000000000000000000000000099", nil, true, 100, 0) require.NoError(t, err) assert.Empty(t, results) }) @@ -347,12 +347,212 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(state2)) - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 1) assert.Equal(t, testUser1, results[0].UserAddress) }) + + t.Run("Pagination - limit and offset bound results, totalCount reflects unpaginated total", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + // Insert 5 distinct session keys for testUser1 + const numKeys = 5 + for i := 0; i < numKeys; i++ { + state := core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: fakeSessionKey(i), + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreChannelSessionKeyState(state)) + } + + // First page: 2 of 5 + page1, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 2, 0) + require.NoError(t, err) + assert.Len(t, page1, 2) + assert.Equal(t, uint32(numKeys), total) + + // Second page: 2 of 5 + page2, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 2, 2) + require.NoError(t, err) + assert.Len(t, page2, 2) + assert.Equal(t, uint32(numKeys), total) + + // Third page: 1 of 5 + page3, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 2, 4) + require.NoError(t, err) + assert.Len(t, page3, 1) + assert.Equal(t, uint32(numKeys), total) + + // Pages must not overlap + seen := map[string]struct{}{} + for _, s := range page1 { + seen[s.SessionKey] = struct{}{} + } + for _, s := range page2 { + _, dup := seen[s.SessionKey] + assert.False(t, dup, "page2 overlaps page1 for %s", s.SessionKey) + seen[s.SessionKey] = struct{}{} + } + for _, s := range page3 { + _, dup := seen[s.SessionKey] + assert.False(t, dup, "page3 overlaps earlier page for %s", s.SessionKey) + } + }) + + t.Run("includeInactive=false filters out expired latest states and matches count", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + active := core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + Assets: []string{testAsset1}, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsigA", + } + require.NoError(t, store.StoreChannelSessionKeyState(active)) + + expired := core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyB, + Version: 1, + Assets: []string{testAsset2}, + ExpiresAt: time.Now().Add(-1 * time.Hour), + UserSig: "0xsigB", + } + require.NoError(t, store.StoreChannelSessionKeyState(expired)) + + results, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, false, 100, 0) + require.NoError(t, err) + assert.Len(t, results, 1) + assert.Equal(t, testKeyA, results[0].SessionKey) + assert.Equal(t, uint32(1), total) + + all, allTotal, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) + require.NoError(t, err) + assert.Len(t, all, 2) + assert.Equal(t, uint32(2), allTotal) + }) + + t.Run("Pagination - mixed active/expired with offset>0 keeps count and list consistent", func(t *testing.T) { + // Regression guard: with includeInactive=false the store must apply the + // expires_at filter to *both* the page slice and the unpaginated count + // (using the same `now` binding), otherwise pagination drifts when the + // caller walks past offset 0. + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + const numActive = 3 + const numExpired = 2 + for i := 0; i < numActive; i++ { + state := core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: fakeSessionKey(i), + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreChannelSessionKeyState(state)) + } + for i := 0; i < numExpired; i++ { + state := core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: fakeSessionKey(numActive + i), + Version: 1, + ExpiresAt: time.Now().Add(-1 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreChannelSessionKeyState(state)) + } + + // includeInactive=false: count reflects active-only total across all pages. + page1, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, false, 2, 0) + require.NoError(t, err) + assert.Len(t, page1, 2) + assert.Equal(t, uint32(numActive), total) + + page2, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, false, 2, 2) + require.NoError(t, err) + assert.Len(t, page2, 1) + assert.Equal(t, uint32(numActive), total) + + // Asking past the active-only total returns empty without changing count. + empty, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, false, 2, 4) + require.NoError(t, err) + assert.Empty(t, empty) + assert.Equal(t, uint32(numActive), total) + + // None of the paged rows may be expired, and pages must not overlap. + seen := map[string]struct{}{} + for _, s := range append(append([]core.ChannelSessionKeyStateV1{}, page1...), page2...) { + assert.True(t, s.ExpiresAt.After(time.Now()), "expired state surfaced for %s", s.SessionKey) + _, dup := seen[s.SessionKey] + assert.False(t, dup, "duplicate session key %s across pages", s.SessionKey) + seen[s.SessionKey] = struct{}{} + } + + // includeInactive=true: count reflects every latest state, paging through reaches both buckets. + allPage1, allTotal, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 2, 0) + require.NoError(t, err) + assert.Len(t, allPage1, 2) + assert.Equal(t, uint32(numActive+numExpired), allTotal) + + allPage2, allTotal, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 2, 2) + require.NoError(t, err) + assert.Len(t, allPage2, 2) + assert.Equal(t, uint32(numActive+numExpired), allTotal) + + allPage3, allTotal, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 2, 4) + require.NoError(t, err) + assert.Len(t, allPage3, 1) + assert.Equal(t, uint32(numActive+numExpired), allTotal) + }) + + t.Run("includeInactive=false combined with session_key filter excludes the expired match", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + expired := core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + Assets: []string{testAsset1}, + ExpiresAt: time.Now().Add(-1 * time.Hour), + UserSig: "0xsigA", + } + require.NoError(t, store.StoreChannelSessionKeyState(expired)) + + sessionKey := testKeyA + results, total, err := store.GetLastChannelSessionKeyStates(testUser1, &sessionKey, false, 100, 0) + require.NoError(t, err) + assert.Empty(t, results) + assert.Equal(t, uint32(0), total) + + all, allTotal, err := store.GetLastChannelSessionKeyStates(testUser1, &sessionKey, true, 100, 0) + require.NoError(t, err) + assert.Len(t, all, 1) + assert.Equal(t, uint32(1), allTotal) + }) +} + +// fakeSessionKey returns a deterministic 20-byte hex address for the given index. +func fakeSessionKey(i int) string { + return strings.ToLower("0x" + strings.Repeat("0", 38) + string("0123456789abcdef"[i%16]) + string("0123456789abcdef"[(i/16)%16])) } func TestDBStore_GetLastChannelSessionKeyVersion(t *testing.T) { @@ -453,7 +653,7 @@ func TestDBStore_ValidateChannelSessionKeyForAsset(t *testing.T) { // Helper to compute metadata hash for a given state computeMetadataHash := func(t *testing.T, version uint64, assets []string, expiresAt time.Time) string { t.Helper() - hash, err := core.GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + hash, err := core.GetChannelSessionKeyAuthMetadataHashV1(testUser1, version, assets, expiresAt.Unix()) require.NoError(t, err) return strings.ToLower(hash.Hex()) } @@ -802,7 +1002,7 @@ func TestDBStore_ChannelSessionKeyState_ForeignRelations(t *testing.T) { require.NoError(t, store.StoreChannelSessionKeyState(stateB)) // GetLastChannelSessionKeyStates returns both — verify preloaded relations are correct - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) @@ -854,7 +1054,7 @@ func TestDBStore_ChannelSessionKeyState_ForeignRelations(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(stateB)) - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) diff --git a/nitronode/store/database/channel_test.go b/nitronode/store/database/channel_test.go index 9d0bf9894..2b72cf921 100644 --- a/nitronode/store/database/channel_test.go +++ b/nitronode/store/database/channel_test.go @@ -377,7 +377,71 @@ func TestDBStore_GetActiveHomeChannel(t *testing.T) { }) } -func TestDBStore_CheckOpenChannel(t *testing.T) { +func TestDBStore_GetNotClosedHomeChannel(t *testing.T) { + makeChannel := func(id, wallet, asset string, status core.ChannelStatus, chType core.ChannelType) core.Channel { + return core.Channel{ + ChannelID: id, + UserWallet: wallet, + Asset: asset, + Type: chType, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: status, + StateVersion: 1, + } + } + + for _, tc := range []struct { + name string + status core.ChannelStatus + expectFound bool + }{ + {"returns Void channel", core.ChannelStatusVoid, true}, + {"returns Open channel", core.ChannelStatusOpen, true}, + {"returns Challenged channel", core.ChannelStatusChallenged, true}, + {"returns Closing channel", core.ChannelStatusClosing, true}, + {"returns nil for Closed channel", core.ChannelStatusClosed, false}, + } { + t.Run(tc.name, func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.CreateChannel(makeChannel("0xch1", "0xuser123", "usdc", tc.status, core.ChannelTypeHome))) + + result, err := store.GetNotClosedHomeChannel("0xuser123", "USDC") + require.NoError(t, err) + if tc.expectFound { + require.NotNil(t, result) + assert.Equal(t, tc.status, result.Status) + } else { + assert.Nil(t, result) + } + }) + } + + t.Run("returns nil when no channel exists", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + result, err := NewDBStore(db).GetNotClosedHomeChannel("0xuser123", "USDC") + require.NoError(t, err) + assert.Nil(t, result) + }) + + t.Run("ignores escrow channels", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(makeChannel("0xesc1", "0xuser123", "usdc", core.ChannelStatusOpen, core.ChannelTypeEscrow))) + result, err := store.GetNotClosedHomeChannel("0xuser123", "USDC") + require.NoError(t, err) + assert.Nil(t, result) + }) +} + +func TestDBStore_CheckActiveChannel(t *testing.T) { t.Run("Success - Has open channel", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() @@ -420,25 +484,26 @@ func TestDBStore_CheckOpenChannel(t *testing.T) { } require.NoError(t, store.StoreUserState(state, "")) - approvedSigValidators, hasOpenChannel, err := store.CheckOpenChannel("0xuser123", "USDC") + approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "USDC") require.NoError(t, err) - assert.True(t, hasOpenChannel) + require.NotNil(t, status) + assert.Equal(t, core.ChannelStatusOpen, *status) assert.Equal(t, "0x2", approvedSigValidators) }) - t.Run("No open channel - user not found", func(t *testing.T) { + t.Run("No active channel - user not found", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() store := NewDBStore(db) - approvedSigValidators, hasOpenChannel, err := store.CheckOpenChannel("0xnonexistent", "USDC") + approvedSigValidators, status, err := store.CheckActiveChannel("0xnonexistent", "USDC") require.NoError(t, err) - assert.False(t, hasOpenChannel) + assert.Nil(t, status) assert.Equal(t, "", approvedSigValidators) }) - t.Run("No open channel - channel is closed", func(t *testing.T) { + t.Run("No active channel - channel is closed", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() @@ -479,13 +544,13 @@ func TestDBStore_CheckOpenChannel(t *testing.T) { } require.NoError(t, store.StoreUserState(state, "")) - approvedSigValidators, hasOpenChannel, err := store.CheckOpenChannel("0xuser123", "USDC") + approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "USDC") require.NoError(t, err) - assert.False(t, hasOpenChannel) + assert.Nil(t, status) assert.Equal(t, "", approvedSigValidators) }) - t.Run("No open channel - wrong asset", func(t *testing.T) { + t.Run("No active channel - wrong asset", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() @@ -527,11 +592,49 @@ func TestDBStore_CheckOpenChannel(t *testing.T) { require.NoError(t, store.StoreUserState(state, "")) // Check for different asset - approvedSigValidators, hasOpenChannel, err := store.CheckOpenChannel("0xuser123", "ETH") + approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "ETH") require.NoError(t, err) - assert.False(t, hasOpenChannel) + assert.Nil(t, status) assert.Equal(t, "", approvedSigValidators) }) + + // These two cases pin the status <= ChannelStatusOpen invariant: Closing and Challenged + // channels must not be returned as active, so post-finalize state submissions are rejected. + for _, tc := range []struct { + name string + status core.ChannelStatus + }{ + {"No active channel - channel is closing", core.ChannelStatusClosing}, + {"No active channel - channel is challenged", core.ChannelStatusChallenged}, + } { + t.Run(tc.name, func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + homeChannelID := "0xhomechannel123" + + channel := core.Channel{ + ChannelID: homeChannelID, + UserWallet: "0xuser123", + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: tc.status, + StateVersion: 1, + } + require.NoError(t, store.CreateChannel(channel)) + + approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "USDC") + require.NoError(t, err) + assert.Nil(t, status) + assert.Equal(t, "", approvedSigValidators) + }) + } } func TestDBStore_UpdateChannel(t *testing.T) { @@ -906,3 +1009,99 @@ func TestDBStore_GetChannelsCountByLabels(t *testing.T) { assert.Equal(t, uint64(1), countMap["usdc/"+core.ChannelStatusClosed.String()]) }) } + +func TestDBStore_HasNonClosedHomeChannel(t *testing.T) { + newChannel := func(id, wallet, asset string, status core.ChannelStatus, nonce uint64) core.Channel { + return core.Channel{ + ChannelID: id, + UserWallet: wallet, + Asset: asset, + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken", + ChallengeDuration: 86400, + Nonce: nonce, + Status: status, + } + } + + t.Run("no channels returns false", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + result, err := store.HasNonClosedHomeChannel("0xuser", "USDC") + require.NoError(t, err) + assert.False(t, result) + }) + + t.Run("only closed channel returns false", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.CreateChannel(newChannel("0xch1", "0xuser", "usdc", core.ChannelStatusClosed, 1))) + + result, err := store.HasNonClosedHomeChannel("0xuser", "USDC") + require.NoError(t, err) + assert.False(t, result) + }) + + for _, status := range []core.ChannelStatus{ + core.ChannelStatusVoid, + core.ChannelStatusOpen, + core.ChannelStatusChallenged, + core.ChannelStatusClosing, + } { + status := status + t.Run("returns true for status "+status.String(), func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.CreateChannel(newChannel("0xch1", "0xuser", "usdc", status, 1))) + + result, err := store.HasNonClosedHomeChannel("0xuser", "USDC") + require.NoError(t, err) + assert.True(t, result) + }) + } + + t.Run("escrow channel does not count", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + escrow := newChannel("0xch1", "0xuser", "usdc", core.ChannelStatusOpen, 1) + escrow.Type = core.ChannelTypeEscrow + require.NoError(t, store.CreateChannel(escrow)) + + result, err := store.HasNonClosedHomeChannel("0xuser", "USDC") + require.NoError(t, err) + assert.False(t, result) + }) + + t.Run("different wallet is not counted", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.CreateChannel(newChannel("0xch1", "0xother", "usdc", core.ChannelStatusOpen, 1))) + + result, err := store.HasNonClosedHomeChannel("0xuser", "USDC") + require.NoError(t, err) + assert.False(t, result) + }) + + t.Run("wallet lookup is case-insensitive", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.CreateChannel(newChannel("0xch1", "0xUser123", "usdc", core.ChannelStatusClosing, 1))) + + result, err := store.HasNonClosedHomeChannel("0xuser123", "USDC") + require.NoError(t, err) + assert.True(t, result) + }) +} diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go new file mode 100644 index 000000000..2fd124b32 --- /dev/null +++ b/nitronode/store/database/current_session_key_state.go @@ -0,0 +1,154 @@ +package database + +import ( + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// ErrSessionKeyNotAllowed is returned by LockSessionKeyState when the session key for the +// requested kind is bound to a wallet other than the submitter. The message is intentionally +// generic so the API does not confirm whether a given session_key is registered elsewhere. +var ErrSessionKeyNotAllowed = errors.New("session key not allowed") + +// SessionKeyKind discriminates the two session-key flavors stored in +// current_session_key_states_v1. Stored as SMALLINT in the DB. +type SessionKeyKind uint8 + +const ( + SessionKeyKindChannel SessionKeyKind = 1 + SessionKeyKindAppSession SessionKeyKind = 2 +) + +// CurrentSessionKeyStateV1 is the latest-version pointer per (user_address, session_key, kind). +// Reads of get_last_key_states JOIN this table to the corresponding history table +// (channel_session_key_states_v1 or app_session_key_states_v1) on +// (user_address, session_key, version), bounding per-request DB work to O(distinct keys). +// +// The uniqueIndex on (session_key, kind) mirrors the postgres constraint added by +// 20260508000000_session_key_ownership_constraints.sql so AutoMigrate (sqlite) enforces the +// same one-owner-per-key invariant that LockSessionKeyState relies on. The index name +// matches the postgres constraint name so both paths converge on a single source of truth. +type CurrentSessionKeyStateV1 struct { + UserAddress string `gorm:"column:user_address;primaryKey;size:42"` + SessionKey string `gorm:"column:session_key;primaryKey;size:42;uniqueIndex:current_session_key_states_v1_key_kind_uniq,priority:1"` + Kind SessionKeyKind `gorm:"column:kind;primaryKey;type:smallint;uniqueIndex:current_session_key_states_v1_key_kind_uniq,priority:2"` + Version uint64 `gorm:"column:version;not null"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (CurrentSessionKeyStateV1) TableName() string { + return "current_session_key_states_v1" +} + +// upsertCurrentSessionKeyState writes the latest version for (user_address, session_key, kind). +// EXCLUDED.version > version guard prevents an out-of-order writer from regressing the pointer. +func upsertCurrentSessionKeyState(tx *gorm.DB, userAddress, sessionKey string, kind SessionKeyKind, version uint64) error { + row := CurrentSessionKeyStateV1{ + UserAddress: strings.ToLower(userAddress), + SessionKey: strings.ToLower(sessionKey), + Kind: kind, + Version: version, + UpdatedAt: time.Now().UTC(), + } + + res := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "user_address"}, + {Name: "session_key"}, + {Name: "kind"}, + }, + DoUpdates: clause.Assignments(map[string]interface{}{ + "version": gorm.Expr("EXCLUDED.version"), + "updated_at": gorm.Expr("EXCLUDED.updated_at"), + }), + Where: clause.Where{Exprs: []clause.Expression{ + gorm.Expr("EXCLUDED.version > current_session_key_states_v1.version"), + }}, + }).Create(&row) + + if err := res.Error; err != nil { + return fmt.Errorf("failed to upsert current session key state: %w", err) + } + return nil +} + +// LockSessionKeyState seeds the pointer row for (userAddress, session_key, kind) if absent +// and locks the (session_key, kind) row for the surrounding transaction. Returns the latest +// stored version for the caller's row, or ErrSessionKeyNotAllowed if the key is bound to a +// different wallet for this kind. +// +// The (session_key, kind) unique constraint guarantees there is at most one pointer row per +// (session_key, kind), so the SELECT ... FOR UPDATE that follows the no-op-on-conflict insert +// always converges on the same physical row regardless of who tried to seed first. A foreign +// wallet that races a legitimate owner ends up reading the legitimate owner back from the +// locked row and is rejected here, without parsing constraint-violation errors at write time. +// +// SELECT ... FOR UPDATE is postgres-only; on sqlite the locking clause is skipped and the +// surrounding transaction provides the necessary ordering for the in-process test setup. +// +// Seed-row permanence: the version=0 row written below is intentionally never deleted on +// failure paths (sig validation, version mismatch, cap exceeded, mid-tx errors). Once a wallet +// has staked a claim on (session_key, kind), no other wallet can take it for that kind — the +// seed is the ownership reservation, not a transient placeholder. CountSessionKeysForUser +// excludes version=0 rows so the per-user cap is unaffected, but the (session_key, kind) +// ownership bind is permanent by design. +func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (uint64, error) { + userAddress = strings.ToLower(userAddress) + sessionKey = strings.ToLower(sessionKey) + + seed := CurrentSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Kind: kind, + Version: 0, + UpdatedAt: time.Now().UTC(), + } + if err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&seed).Error; err != nil { + return 0, fmt.Errorf("failed to ensure current session key state row exists: %w", err) + } + + query := s.db.Where("session_key = ? AND kind = ?", sessionKey, kind) + if s.db.Dialector.Name() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + + var locked CurrentSessionKeyStateV1 + err := query.First(&locked).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + // Must not happen: the seed insert above either created our row or no-op'd on + // an existing one, so a SELECT keyed on (session_key, kind) must hit a row. + // Treat as a hard error rather than falling through as unowned — silently + // returning version 0 here would let a submit bypass ownership enforcement. + return 0, fmt.Errorf("session key pointer row missing after seed insert for (session_key=%s, kind=%d)", sessionKey, kind) + } + return 0, fmt.Errorf("failed to lock current session key state: %w", err) + } + + if !strings.EqualFold(locked.UserAddress, userAddress) { + return 0, ErrSessionKeyNotAllowed + } + return locked.Version, nil +} + +// CountSessionKeysForUser returns the number of distinct session keys recorded for the wallet +// in the pointer table, across both kinds. Drives the per-user cap at submit time. +// Rows seeded by LockSessionKeyState (version=0) are excluded so that a failed-cap rejection +// does not itself leave a phantom row counted toward the cap. +func (s *DBStore) CountSessionKeysForUser(userAddress string) (uint32, error) { + userAddress = strings.ToLower(userAddress) + + var count int64 + err := s.db.Model(&CurrentSessionKeyStateV1{}). + Where("user_address = ? AND version > 0", userAddress). + Count(&count).Error + if err != nil { + return 0, fmt.Errorf("failed to count session keys for user: %w", err) + } + return uint32(count), nil +} diff --git a/nitronode/store/database/current_session_key_state_test.go b/nitronode/store/database/current_session_key_state_test.go new file mode 100644 index 000000000..91d15cb9e --- /dev/null +++ b/nitronode/store/database/current_session_key_state_test.go @@ -0,0 +1,283 @@ +package database + +import ( + "errors" + "testing" + "time" + + "github.com/layer-3/nitrolite/pkg/app" + "github.com/layer-3/nitrolite/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCurrentSessionKeyStateV1_TableName(t *testing.T) { + assert.Equal(t, "current_session_key_states_v1", CurrentSessionKeyStateV1{}.TableName()) +} + +// TestCurrentSessionKeyStateV1_UniqueKeyKindConstraint pins the (session_key, kind) uniqueness +// invariant at the database layer on every supported dialect. Postgres gets it from migration +// 20260508000000; sqlite gets it from the uniqueIndex gorm tag via AutoMigrate. Without the +// tag, sqlite would silently accept two pointer rows for the same key/kind under different +// wallets, breaking LockSessionKeyState's read-first-then-check ownership flow. +func TestCurrentSessionKeyStateV1_UniqueKeyKindConstraint(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + now := time.Now().UTC() + first := CurrentSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Kind: SessionKeyKindAppSession, + Version: 1, + UpdatedAt: now, + } + require.NoError(t, db.Create(&first).Error) + + // Foreign wallet attempting the same (session_key, kind) must be rejected at the + // database layer, not just by application logic. + collision := CurrentSessionKeyStateV1{ + UserAddress: testUser2, + SessionKey: testSessionKey, + Kind: SessionKeyKindAppSession, + Version: 1, + UpdatedAt: now, + } + err := db.Create(&collision).Error + require.Error(t, err) + + // Same (session_key) under a different kind is allowed — the constraint is composite. + otherKind := CurrentSessionKeyStateV1{ + UserAddress: testUser2, + SessionKey: testSessionKey, + Kind: SessionKeyKindChannel, + Version: 1, + UpdatedAt: now, + } + require.NoError(t, db.Create(&otherKind).Error) +} + +func TestDBStore_LockSessionKeyState(t *testing.T) { + t.Run("Seeds row at version=0 on first call", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + v, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + assert.Equal(t, uint64(0), v) + + // Second call returns the same seeded row. + v2, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + assert.Equal(t, uint64(0), v2) + }) + + t.Run("Returns latest version after a successful submit", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + state := app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + } + require.NoError(t, store.StoreAppSessionKeyState(state)) + + v, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + assert.Equal(t, uint64(1), v) + }) + + t.Run("Channel and app_session kinds are independent", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + // Submit channel session key v1. + require.NoError(t, store.StoreChannelSessionKeyState(core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + + channelV, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) + require.NoError(t, err) + assert.Equal(t, uint64(1), channelV) + + // App-session pointer for the same (user, session_key) is unaffected. + appV, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + assert.Equal(t, uint64(0), appV) + }) + + t.Run("Foreign wallet trying to claim an already-owned (session_key, kind) is rejected", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + // User1 owns the session key for the app-session kind. + _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + + // User2 attempts to lock the same (session_key, kind) — must surface the generic + // not-allowed sentinel without leaking that the key belongs to someone else. + _, err = store.LockSessionKeyState(testUser2, testSessionKey, SessionKeyKindAppSession) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrSessionKeyNotAllowed)) + }) + + t.Run("Same (user, session_key) across both kinds is allowed", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + _, err := store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindChannel) + require.NoError(t, err) + _, err = store.LockSessionKeyState(testUser1, testSessionKey, SessionKeyKindAppSession) + require.NoError(t, err) + }) + + t.Run("Lowercases user_address and session_key", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + _, err := store.LockSessionKeyState( + "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + SessionKeyKindAppSession, + ) + require.NoError(t, err) + + // Lower-case query returns the same row (no duplicate seeded). + v, err := store.LockSessionKeyState( + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + SessionKeyKindAppSession, + ) + require.NoError(t, err) + assert.Equal(t, uint64(0), v) + + count, err := store.CountSessionKeysForUser("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + require.NoError(t, err) + // Seeded-only rows (version=0) must not count toward the cap. + assert.Equal(t, uint32(0), count) + }) +} + +func TestDBStore_CountSessionKeysForUser(t *testing.T) { + t.Run("Counts only rows with version > 0", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + // Seed-only row (Lock with no submit) must not be counted. + _, err := store.LockSessionKeyState(testUser1, testKeyA, SessionKeyKindAppSession) + require.NoError(t, err) + + count, err := store.CountSessionKeysForUser(testUser1) + require.NoError(t, err) + assert.Equal(t, uint32(0), count) + + // Real submit -> counted. + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + count, err = store.CountSessionKeysForUser(testUser1) + require.NoError(t, err) + assert.Equal(t, uint32(1), count) + }) + + t.Run("Counts across both kinds for the same user", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + require.NoError(t, store.StoreChannelSessionKeyState(core.ChannelSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyB, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + + count, err := store.CountSessionKeysForUser(testUser1) + require.NoError(t, err) + assert.Equal(t, uint32(2), count) + }) + + t.Run("Does not count keys belonging to a different user", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testKeyA, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig", + })) + + count, err := store.CountSessionKeysForUser(testUser2) + require.NoError(t, err) + assert.Equal(t, uint32(0), count) + }) +} + +func TestDBStore_CurrentPointer_VersionMonotonic(t *testing.T) { + // Out-of-order writers must not regress the pointer (EXCLUDED.version > current.version). + db, cleanup := SetupTestDB(t) + defer cleanup() + store := NewDBStore(db) + + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 1, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig1", + })) + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 3, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig3", + })) + + // Pointer reflects version 3. + v, err := store.GetLastAppSessionKeyVersion(testUser1, testSessionKey) + require.NoError(t, err) + assert.Equal(t, uint64(3), v) + + // A late-arriving v2 must not regress the pointer back to 2 (the EXCLUDED.version > current + // guard is what protects this; the history row insert itself is allowed). + require.NoError(t, store.StoreAppSessionKeyState(app.AppSessionKeyStateV1{ + UserAddress: testUser1, + SessionKey: testSessionKey, + Version: 2, + ExpiresAt: time.Now().Add(24 * time.Hour), + UserSig: "0xsig2", + })) + v, err = store.GetLastAppSessionKeyVersion(testUser1, testSessionKey) + require.NoError(t, err) + assert.Equal(t, uint64(3), v) +} diff --git a/nitronode/store/database/database.go b/nitronode/store/database/database.go index d6789e0ba..87796ed8e 100644 --- a/nitronode/store/database/database.go +++ b/nitronode/store/database/database.go @@ -21,6 +21,9 @@ import ( // // To connect to sqlite, you just need to specify "sqlite" driver. // By default it will use in-memory database. You can provide NITRONODE_DATABASE_NAME to use the file. +// +// For Postgresql, NITRONODE_DATABASE_URL takes precedence: when set, it is used verbatim +// and the individual Username/Password/Host/Port/Name/SSLMode fields are ignored. type DatabaseConfig struct { URL string `env:"NITRONODE_DATABASE_URL" env-default:""` Name string `env:"NITRONODE_DATABASE_NAME" env-default:""` @@ -30,6 +33,7 @@ type DatabaseConfig struct { Password string `env:"NITRONODE_DATABASE_PASSWORD" env-default:"your-super-secret-and-long-postgres-password"` Host string `env:"NITRONODE_DATABASE_HOST" env-default:"localhost"` Port string `env:"NITRONODE_DATABASE_PORT" env-default:"5432"` + SSLMode string `env:"NITRONODE_DATABASE_SSLMODE" env-default:"require"` Retries int `env:"NITRONODE_DATABASE_RETRIES" env-default:"5"` // Connection pool settings @@ -131,12 +135,37 @@ func connectToSqlite(cnf DatabaseConfig) (*gorm.DB, error) { return db, nil } +// validPostgresSSLModes lists sslmode values accepted by libpq / pgx. +// See https://www.postgresql.org/docs/current/libpq-ssl.html. +var validPostgresSSLModes = map[string]struct{}{ + "disable": {}, + "allow": {}, + "prefer": {}, + "require": {}, + "verify-ca": {}, + "verify-full": {}, +} + func postgresqlDbUrl(cnf DatabaseConfig) (string, error) { switch cnf.Driver { case "postgres": + // URL, when supplied, is used verbatim. The operator owns sslmode, search_path, + // and any other parameters encoded in it. + if cnf.URL != "" { + return cnf.URL, nil + } + + sslMode := cnf.SSLMode + if sslMode == "" { + sslMode = "require" + } + if _, ok := validPostgresSSLModes[sslMode]; !ok { + return "", fmt.Errorf("invalid sslmode %q: must be one of disable, allow, prefer, require, verify-ca, verify-full", sslMode) + } + dsn := fmt.Sprintf( - "user=%s password=%s host=%s port=%s dbname=%s sslmode=disable", - cnf.Username, cnf.Password, cnf.Host, cnf.Port, cnf.Name, + "user=%s password=%s host=%s port=%s dbname=%s sslmode=%s", + cnf.Username, cnf.Password, cnf.Host, cnf.Port, cnf.Name, sslMode, ) if cnf.Schema != "" { @@ -231,6 +260,7 @@ func migrateSqlite(db *gorm.DB) error { &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, + &CurrentSessionKeyStateV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, diff --git a/nitronode/store/database/database_test.go b/nitronode/store/database/database_test.go new file mode 100644 index 000000000..5cdc5c784 --- /dev/null +++ b/nitronode/store/database/database_test.go @@ -0,0 +1,77 @@ +package database + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostgresqlDbUrl(t *testing.T) { + base := DatabaseConfig{ + Driver: "postgres", + Username: "user", + Password: "pass", + Host: "db.example.com", + Port: "5432", + Name: "nitronode", + } + + t.Run("DefaultsToRequireWhenSSLModeEmpty", func(t *testing.T) { + dsn, err := postgresqlDbUrl(base) + require.NoError(t, err) + assert.Contains(t, dsn, "sslmode=require") + assert.NotContains(t, dsn, "sslmode=disable") + }) + + t.Run("HonorsExplicitSSLMode", func(t *testing.T) { + cnf := base + cnf.SSLMode = "verify-full" + dsn, err := postgresqlDbUrl(cnf) + require.NoError(t, err) + assert.Contains(t, dsn, "sslmode=verify-full") + }) + + t.Run("AllowsDisableForLocalDev", func(t *testing.T) { + cnf := base + cnf.SSLMode = "disable" + dsn, err := postgresqlDbUrl(cnf) + require.NoError(t, err) + assert.Contains(t, dsn, "sslmode=disable") + }) + + t.Run("RejectsInvalidSSLMode", func(t *testing.T) { + cnf := base + cnf.SSLMode = "totally-bogus" + _, err := postgresqlDbUrl(cnf) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid sslmode") + }) + + t.Run("AppendsSearchPathWhenSchemaSet", func(t *testing.T) { + cnf := base + cnf.Schema = "tenant_a" + dsn, err := postgresqlDbUrl(cnf) + require.NoError(t, err) + assert.Contains(t, dsn, "search_path=tenant_a") + }) + + t.Run("URLOverridesIndividualFields", func(t *testing.T) { + cnf := base + cnf.URL = "postgres://override:secret@otherhost:6543/otherdb?sslmode=verify-ca" + cnf.SSLMode = "disable" // ignored when URL set + dsn, err := postgresqlDbUrl(cnf) + require.NoError(t, err) + assert.Equal(t, cnf.URL, dsn) + assert.False(t, strings.Contains(dsn, "user=user"), "URL must be returned verbatim") + }) + + t.Run("RejectsUnsupportedDriver", func(t *testing.T) { + cnf := base + cnf.Driver = "mysql" + _, err := postgresqlDbUrl(cnf) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported driver") + }) +} diff --git a/nitronode/store/database/db_store.go b/nitronode/store/database/db_store.go index e1f01d73d..17f2cb6e6 100644 --- a/nitronode/store/database/db_store.go +++ b/nitronode/store/database/db_store.go @@ -199,41 +199,103 @@ func (s *DBStore) EnsureNoOngoingStateTransitions(wallet, asset string) error { switch result.TransitionType { case core.TransitionTypeHomeDeposit: // Verify last_state.version == home_channel.state_version - if result.HomeChannelVersion != nil && result.StateVersion != *result.HomeChannelVersion { + if result.HomeChannelVersion == nil || result.StateVersion != *result.HomeChannelVersion { return fmt.Errorf("home deposit is still ongoing") } case core.TransitionTypeHomeWithdrawal: // Verify last_state.version == home_channel.state_version - if result.HomeChannelVersion != nil && result.StateVersion != *result.HomeChannelVersion { + if result.HomeChannelVersion == nil || result.StateVersion != *result.HomeChannelVersion { return fmt.Errorf("home withdrawal is still ongoing") } case core.TransitionTypeMutualLock: // Verify last_state.version == home_channel.state_version == escrow_channel.state_version - if result.HomeChannelVersion != nil && result.StateVersion != *result.HomeChannelVersion || - result.EscrowChannelVersion != nil && result.StateVersion != *result.EscrowChannelVersion { + if result.HomeChannelVersion == nil || result.StateVersion != *result.HomeChannelVersion || + result.EscrowChannelVersion == nil || result.StateVersion != *result.EscrowChannelVersion { return fmt.Errorf("mutual lock is still ongoing") } case core.TransitionTypeEscrowLock: // Verify last_state.version == escrow_channel.state_version - if result.EscrowChannelVersion != nil && result.StateVersion != *result.EscrowChannelVersion { + if result.EscrowChannelVersion == nil || result.StateVersion != *result.EscrowChannelVersion { return fmt.Errorf("escrow lock is still ongoing") } case core.TransitionTypeEscrowWithdraw: // Verify last_state.version == escrow_channel.state_version - if result.EscrowChannelVersion != nil && result.StateVersion != *result.EscrowChannelVersion { + if result.EscrowChannelVersion == nil || result.StateVersion != *result.EscrowChannelVersion { return fmt.Errorf("escrow withdrawal is still ongoing") } case core.TransitionTypeMigrate: // Verify last_state.version == home_channel.state_version - if result.HomeChannelVersion != nil && result.StateVersion != *result.HomeChannelVersion { + if result.HomeChannelVersion == nil || result.StateVersion != *result.HomeChannelVersion { return fmt.Errorf("home chain migration is still ongoing") } } return nil } + +// EnsureNoOngoingEscrowOperation validates that the user has no in-flight escrow +// operation that would prevent the node from issuing receiver-side states (transfer +// receive, app-session release). +// +// Validation logic by latest signed transition type: +// - escrow_lock / mutual_lock: always considered ongoing (no finalization yet) +// - escrow_deposit / escrow_withdraw: only considered ongoing when the on-chain +// escrow channel state version has not caught up with the signed state version +// - any other transition: not an escrow operation, allow +func (s *DBStore) EnsureNoOngoingEscrowOperation(wallet, asset string) error { + wallet = strings.ToLower(wallet) + + type escrowCheck struct { + TransitionType core.TransitionType + StateVersion uint64 + EscrowChannelVersion *uint64 + } + + var result escrowCheck + tx := s.db.Raw(` + SELECT + s.transition_type as transition_type, + s.version as state_version, + ec.state_version as escrow_channel_version + FROM channel_states s + LEFT JOIN channels ec ON ec.channel_id = s.escrow_channel_id + WHERE s.user_wallet = ? + AND s.asset = ? + AND s.user_sig IS NOT NULL + AND s.node_sig IS NOT NULL + ORDER BY s.epoch DESC, s.version DESC + LIMIT 1 + `, wallet, asset).Scan(&result) + + if tx.Error != nil { + return fmt.Errorf("failed to check ongoing escrow operation: %w", tx.Error) + } + if tx.RowsAffected == 0 { + return nil + } + + switch result.TransitionType { + case core.TransitionTypeEscrowLock: + return fmt.Errorf("escrow lock is still ongoing") + + case core.TransitionTypeMutualLock: + return fmt.Errorf("mutual lock is still ongoing") + + case core.TransitionTypeEscrowDeposit: + if result.EscrowChannelVersion == nil || result.StateVersion != *result.EscrowChannelVersion { + return fmt.Errorf("escrow deposit finalization is still ongoing") + } + + case core.TransitionTypeEscrowWithdraw: + if result.EscrowChannelVersion == nil || result.StateVersion != *result.EscrowChannelVersion { + return fmt.Errorf("escrow withdrawal finalization is still ongoing") + } + } + + return nil +} diff --git a/nitronode/store/database/db_store_test.go b/nitronode/store/database/db_store_test.go index 8aeb10c24..74757ce84 100644 --- a/nitronode/store/database/db_store_test.go +++ b/nitronode/store/database/db_store_test.go @@ -645,4 +645,577 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") require.NoError(t, err) }) + + const wallet = "0xuser123" + const asset = "USDC" + const homeChannelID = "0xhomechannel123" + const escrowChannelID = "0xescrowchannel456" + userSig := "0xusersig" + nodeSig := "0xnodesig" + + newHomeChannel := func(version uint64) core.Channel { + return core.Channel{ + ChannelID: homeChannelID, + UserWallet: wallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: version, + } + } + + newEscrowChannel := func(version uint64) core.Channel { + return core.Channel{ + ChannelID: escrowChannelID, + UserWallet: wallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + BlockchainID: 137, + TokenAddress: "0xtoken456", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: version, + } + } + + newSignedState := func(version uint64, transitionType core.TransitionType, withEscrow bool) core.State { + hc := homeChannelID + state := core.State{ + ID: "state1", + Asset: asset, + UserWallet: wallet, + Epoch: 1, + Version: version, + HomeChannelID: &hc, + Transition: core.Transition{Type: transitionType}, + HomeLedger: core.Ledger{ + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + UserSig: &userSig, + NodeSig: &nodeSig, + } + if withEscrow { + ec := escrowChannelID + state.EscrowChannelID = &ec + state.EscrowLedger = &core.Ledger{ + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + } + } + return state + } + + storeState := func(t *testing.T, store DatabaseStore, state core.State) { + t.Helper() + _, err := store.LockUserState(wallet, asset) + require.NoError(t, err) + require.NoError(t, store.StoreUserState(state, "")) + } + + t.Run("HomeDeposit - home channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + storeState(t, store, newSignedState(1, core.TransitionTypeHomeDeposit, false)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "home deposit is still ongoing") + }) + + t.Run("HomeWithdrawal - home channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + storeState(t, store, newSignedState(1, core.TransitionTypeHomeWithdrawal, false)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "home withdrawal is still ongoing") + }) + + t.Run("MutualLock - home channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(newEscrowChannel(2))) + storeState(t, store, newSignedState(2, core.TransitionTypeMutualLock, true)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutual lock is still ongoing") + }) + + t.Run("MutualLock - escrow channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(newHomeChannel(2))) + storeState(t, store, newSignedState(2, core.TransitionTypeMutualLock, true)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutual lock is still ongoing") + }) + + t.Run("EscrowLock - escrow channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(newHomeChannel(1))) + storeState(t, store, newSignedState(1, core.TransitionTypeEscrowLock, true)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow lock is still ongoing") + }) + + t.Run("EscrowWithdraw - escrow channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(newHomeChannel(4))) + storeState(t, store, newSignedState(4, core.TransitionTypeEscrowWithdraw, true)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow withdrawal is still ongoing") + }) + + t.Run("Migrate - home channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + storeState(t, store, newSignedState(1, core.TransitionTypeMigrate, false)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "home chain migration is still ongoing") + }) +} + +func TestDBStore_UpdateStateUserSigIfMissing(t *testing.T) { + t.Run("Backfills user_sig when null and unblocks gate", func(t *testing.T) { + // This is the wedge-recovery path: a node-only state was checkpointed on chain + // (e.g. recipient submitted a transfer_receive state directly). After the event + // reactor backfills user_sig, EnsureNoOngoingStateTransitions must see a fully + // signed row at the on-chain version and pass. + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + homeChannelID := "0xhomechannel123" + nodeSig := "0xnodesig" + + channel := core.Channel{ + ChannelID: homeChannelID, + UserWallet: "0xuser123", + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: 2, + } + require.NoError(t, store.CreateChannel(channel)) + + // Node-only state at version 2; gate would normally skip this row and find + // nothing else, returning nil. To exercise the wedge, also seed an older + // bilateral state at version 1. + _, err := store.LockUserState("0xuser123", "USDC") + require.NoError(t, err) + + bilateralUserSig := "0xprior" + bilateralNodeSig := "0xpriornode" + bilateral := core.State{ + ID: "state1", + Asset: "USDC", + UserWallet: "0xuser123", + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + Transition: core.Transition{ + Type: core.TransitionTypeHomeDeposit, + }, + HomeLedger: core.Ledger{ + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + UserSig: &bilateralUserSig, + NodeSig: &bilateralNodeSig, + } + require.NoError(t, store.StoreUserState(bilateral, "")) + + nodeOnly := core.State{ + ID: "state2", + Asset: "USDC", + UserWallet: "0xuser123", + Epoch: 1, + Version: 2, + HomeChannelID: &homeChannelID, + Transition: core.Transition{ + Type: core.TransitionTypeTransferReceive, + }, + HomeLedger: core.Ledger{ + UserBalance: decimal.NewFromInt(750), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + NodeSig: &nodeSig, + } + require.NoError(t, store.StoreUserState(nodeOnly, "")) + + // Pre-backfill: gate sees bilateral row at version 1, channel.state_version is 2 → mismatch. + err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + require.Error(t, err) + + // Backfill the user signature recovered from the on-chain event. + recoveredSig := "0xrecovered" + require.NoError(t, store.UpdateStateUserSigIfMissing(homeChannelID, 2, recoveredSig)) + + got, err := store.GetStateByID("state2") + require.NoError(t, err) + require.NotNil(t, got) + require.NotNil(t, got.UserSig) + assert.Equal(t, recoveredSig, *got.UserSig) + + // Post-backfill: gate sees the now-bilateral row at version 2, matches channel state_version. + err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + require.NoError(t, err) + }) + + t.Run("Idempotent on replay - existing sig preserved", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + homeChannelID := "0xhomechannel123" + userSig := "0xexisting" + nodeSig := "0xnodesig" + + channel := core.Channel{ + ChannelID: homeChannelID, + UserWallet: "0xuser123", + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + require.NoError(t, store.CreateChannel(channel)) + + _, err := store.LockUserState("0xuser123", "USDC") + require.NoError(t, err) + + state := core.State{ + ID: "state1", + Asset: "USDC", + UserWallet: "0xuser123", + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + Transition: core.Transition{ + Type: core.TransitionTypeHomeDeposit, + }, + HomeLedger: core.Ledger{ + UserBalance: decimal.NewFromInt(1000), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + UserSig: &userSig, + NodeSig: &nodeSig, + } + require.NoError(t, store.StoreUserState(state, "")) + + // Replayed event would carry a different (or any) sig; existing one must not be overwritten. + require.NoError(t, store.UpdateStateUserSigIfMissing(homeChannelID, 1, "0xshould-not-overwrite")) + + got, err := store.GetStateByID("state1") + require.NoError(t, err) + require.NotNil(t, got) + require.NotNil(t, got.UserSig) + assert.Equal(t, userSig, *got.UserSig) + }) + + t.Run("Empty sig is no-op", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + homeChannelID := "0xhomechannel123" + require.NoError(t, store.UpdateStateUserSigIfMissing(homeChannelID, 1, "")) + }) + + t.Run("Unknown version returns no error", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + homeChannelID := "0xhomechannel123" + require.NoError(t, store.UpdateStateUserSigIfMissing(homeChannelID, 99, "0xanything")) + }) +} + +func TestDBStore_EnsureNoOngoingEscrowOperation(t *testing.T) { + const wallet = "0xuser123" + const asset = "USDC" + const homeChannelID = "0xhomechannel123" + const escrowChannelID = "0xescrowchannel456" + const userSig = "0xusersig" + const nodeSig = "0xnodesig" + + homeChannel := core.Channel{ + ChannelID: homeChannelID, + UserWallet: wallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: 1, + } + + newEscrowChannel := func(version uint64) core.Channel { + return core.Channel{ + ChannelID: escrowChannelID, + UserWallet: wallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + BlockchainID: 137, + TokenAddress: "0xtoken456", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: version, + } + } + + newSignedState := func(version uint64, transitionType core.TransitionType, withEscrow bool) core.State { + state := core.State{ + ID: "state1", + Asset: asset, + UserWallet: wallet, + Epoch: 1, + Version: version, + HomeChannelID: ptr(homeChannelID), + Transition: core.Transition{Type: transitionType}, + HomeLedger: core.Ledger{ + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + UserSig: ptr(userSig), + NodeSig: ptr(nodeSig), + } + if withEscrow { + state.EscrowChannelID = ptr(escrowChannelID) + state.EscrowLedger = &core.Ledger{ + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + } + } + return state + } + + storeState := func(t *testing.T, store DatabaseStore, state core.State) { + t.Helper() + _, err := store.LockUserState(wallet, asset) + require.NoError(t, err) + require.NoError(t, store.StoreUserState(state, "")) + } + + t.Run("No previous state - allow", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.NoError(t, err) + }) + + t.Run("Non-escrow transition (TransferSend) - allow", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + + storeState(t, store, newSignedState(1, core.TransitionTypeTransferSend, false)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.NoError(t, err) + }) + + t.Run("EscrowLock - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(1))) + + storeState(t, store, newSignedState(1, core.TransitionTypeEscrowLock, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow lock is still ongoing") + }) + + t.Run("MutualLock - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(1))) + + storeState(t, store, newSignedState(1, core.TransitionTypeMutualLock, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutual lock is still ongoing") + }) + + t.Run("EscrowDeposit - chain caught up - allow", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(2))) + + storeState(t, store, newSignedState(2, core.TransitionTypeEscrowDeposit, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.NoError(t, err) + }) + + t.Run("EscrowDeposit - chain not synced - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(1))) + + storeState(t, store, newSignedState(2, core.TransitionTypeEscrowDeposit, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow deposit finalization is still ongoing") + }) + + t.Run("EscrowWithdraw - chain caught up - allow", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(3))) + + storeState(t, store, newSignedState(3, core.TransitionTypeEscrowWithdraw, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.NoError(t, err) + }) + + t.Run("EscrowWithdraw - chain not synced - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(2))) + + storeState(t, store, newSignedState(3, core.TransitionTypeEscrowWithdraw, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow withdrawal finalization is still ongoing") + }) + + t.Run("EscrowDeposit - escrow channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + + storeState(t, store, newSignedState(2, core.TransitionTypeEscrowDeposit, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow deposit finalization is still ongoing") + }) + + t.Run("EscrowWithdraw - escrow channel missing from DB - block", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + + storeState(t, store, newSignedState(3, core.TransitionTypeEscrowWithdraw, true)) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "escrow withdrawal finalization is still ongoing") + }) + + t.Run("Unsigned state - ignored", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + require.NoError(t, store.CreateChannel(homeChannel)) + require.NoError(t, store.CreateChannel(newEscrowChannel(1))) + + state := newSignedState(2, core.TransitionTypeEscrowLock, true) + state.UserSig = nil + state.NodeSig = nil + storeState(t, store, state) + + err := store.EnsureNoOngoingEscrowOperation(wallet, asset) + require.NoError(t, err) + }) +} + +func ptr[T any](v T) *T { + return &v } diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index ca3511b00..1157fd7f8 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -46,11 +46,25 @@ type DatabaseStore interface { GetChannelByID(channelID string) (*core.Channel, error) // GetActiveHomeChannel retrieves the active home channel for a user's wallet and asset. + // "Active" includes both Void (DB-only) and Open (materialized onchain). GetActiveHomeChannel(wallet, asset string) (*core.Channel, error) - // CheckOpenChannel verifies if a user has an active channel for the given asset - // and returns the approved signature validators if such a channel exists. - CheckOpenChannel(wallet, asset string) (string, bool, error) + // GetNotClosedHomeChannel retrieves the home channel for a user's wallet and asset + // regardless of status, as long as it has not reached ChannelStatusClosed. Intended + // for read paths (e.g. GetHomeChannel RPC) that must remain functional after an + // off-chain Finalize flips the channel to Closing. + GetNotClosedHomeChannel(wallet, asset string) (*core.Channel, error) + + // CheckActiveChannel verifies if a user has an active home channel for the given asset + // and returns its approved signature validators and current status. A nil status means + // no active channel exists. "Active" includes Void (DB-only, awaiting onchain confirmation) + // and Open (materialized onchain); callers needing onchain materialization must additionally + // require Status == core.ChannelStatusOpen. + CheckActiveChannel(wallet, asset string) (string, *core.ChannelStatus, error) + + // HasNonClosedHomeChannel returns true if any home channel for (wallet, asset) exists + // with a status other than Closed, indicating an in-progress channel lifecycle. + HasNonClosedHomeChannel(wallet, asset string) (bool, error) // UpdateChannel persists changes to a channel's metadata (status, version, etc). UpdateChannel(channel core.Channel) error @@ -80,6 +94,14 @@ type DatabaseStore interface { // EnsureNoOngoingStateTransitions validates that no conflicting blockchain operations are pending. EnsureNoOngoingStateTransitions(wallet, asset string) error + // EnsureNoOngoingEscrowOperation validates that the user has no in-flight escrow + // operation (escrow_lock, mutual_lock, or unfinalized escrow_deposit/escrow_withdraw) + // that would prevent issuing a receiver-side state. + EnsureNoOngoingEscrowOperation(wallet, asset string) error + + // UpdateStateUserSigIfMissing backfills the user signature for a stored state when it is currently NULL. + UpdateStateUserSigIfMissing(channelID string, version uint64, userSig string) error + // --- Blockchain Action Operations --- // ScheduleInitiateEscrowWithdrawal queues a blockchain action to initiate withdrawal. @@ -90,6 +112,10 @@ type DatabaseStore interface { // This queues the state to be submitted on-chain to update the channel's on-chain state. ScheduleCheckpoint(stateID string, chainID uint64) error + // ScheduleChallenge schedules a challengeChannel(...) submission on the channel's home + // blockchain using the provided state and a node-produced challenger signature. + ScheduleChallenge(stateID string, chainID uint64) error + // ScheduleFinalizeEscrowDeposit schedules a checkpoint for an escrow deposit operation. // This queues the state to be submitted on-chain to finalize an escrow deposit. ScheduleFinalizeEscrowDeposit(stateID string, chainID uint64) error @@ -160,6 +186,18 @@ type DatabaseStore interface { // RecordLedgerEntry logs a movement of funds within the internal ledger. RecordLedgerEntry(userWallet, accountID, asset string, amount decimal.Decimal) error + // --- Session Key State Pointer Operations --- + + // LockSessionKeyState seeds the pointer row for (user, session_key, kind) if absent and + // locks the (session_key, kind) row for the surrounding transaction. Returns the latest + // stored version for the caller's row, or ErrSessionKeyNotAllowed if the key is bound to + // a different wallet for this kind. + LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (uint64, error) + + // CountSessionKeysForUser returns the number of distinct session keys recorded for the + // wallet across both kinds. Used to enforce the per-user cap at submit time. + CountSessionKeysForUser(userAddress string) (uint32, error) + // --- App Session Key State Operations --- // StoreAppSessionKeyState stores or updates a session key state. @@ -175,8 +213,11 @@ type DatabaseStore interface { // Returns nil if no state exists. GetLastAppSessionKeyState(wallet, sessionKey string) (*app.AppSessionKeyStateV1, error) - // GetLastKeyStates retrieves the latest session key states for a user with optional filtering. - GetLastAppSessionKeyStates(wallet string, sessionKey *string) ([]app.AppSessionKeyStateV1, error) + // GetLastAppSessionKeyStates retrieves the latest session key states for a user with optional + // filtering. When includeInactive is false, only states whose expires_at is in the future are + // returned; when true, all latest states are returned regardless of expiry. Results are + // paginated; totalCount is the unpaginated total matching the filter. + GetLastAppSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) // --- Channel Session Key State Operations --- @@ -188,8 +229,11 @@ type DatabaseStore interface { GetLastChannelSessionKeyVersion(wallet, sessionKey string) (uint64, error) // GetLastChannelSessionKeyStates retrieves the latest channel session key states for a user, - // optionally filtered by session key. - GetLastChannelSessionKeyStates(wallet string, sessionKey *string) ([]core.ChannelSessionKeyStateV1, error) + // optionally filtered by session key. When includeInactive is false, only states whose + // expires_at is in the future are returned; when true, all latest states are returned + // regardless of expiry. Results are paginated; totalCount is the unpaginated total matching + // the filter. + GetLastChannelSessionKeyStates(wallet string, sessionKey *string, includeInactive bool, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) // ValidateChannelSessionKeyForAsset checks that a valid, non-expired session key state // exists at its latest version for the (wallet, sessionKey) pair, includes the given asset, diff --git a/nitronode/store/database/state.go b/nitronode/store/database/state.go index f309b3faf..328f2b8c7 100644 --- a/nitronode/store/database/state.go +++ b/nitronode/store/database/state.go @@ -221,6 +221,24 @@ func (s *DBStore) GetLastStateByChannelID(channelID string, signed bool) (*core. return databaseStateToCore(&state) } +// UpdateStateUserSigIfMissing backfills the user signature for a stored state when it is currently NULL. +// Used by the on-chain event reactor to repair the local record once a state has been bilaterally +// enforced on chain. The IS NULL guard makes the call idempotent on event replay and prevents +// overwriting a signature already populated by the user-facing RPC path. +func (s *DBStore) UpdateStateUserSigIfMissing(channelID string, version uint64, userSig string) error { + if userSig == "" { + return nil + } + cid := strings.ToLower(channelID) + res := s.db.Model(&State{}). + Where("(home_channel_id = ? OR escrow_channel_id = ?) AND version = ? AND user_sig IS NULL", cid, cid, version). + Update("user_sig", userSig) + if res.Error != nil { + return fmt.Errorf("failed to backfill user_sig: %w", res.Error) + } + return nil +} + // GetStateByChannelIDAndVersion retrieves a specific state version for a channel. // Uses UNION ALL of two indexed queries instead of OR for better performance. func (s *DBStore) GetStateByChannelIDAndVersion(channelID string, version uint64) (*core.State, error) { diff --git a/nitronode/store/database/testing.go b/nitronode/store/database/testing.go index 8a3089ea2..24be889b6 100644 --- a/nitronode/store/database/testing.go +++ b/nitronode/store/database/testing.go @@ -54,7 +54,7 @@ func setupTestSqlite(t testing.TB) *gorm.DB { t.Fatalf("Failed to open SQLite database: %v", err) } - err = database.AutoMigrate(&AppV1{}, &AppLedgerEntryV1{}, &AppSessionV1{}, &AppParticipantV1{}, &BlockchainAction{}, &Channel{}, &ContractEvent{}, &State{}, &Transaction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}) + err = database.AutoMigrate(&AppV1{}, &AppLedgerEntryV1{}, &AppSessionV1{}, &AppParticipantV1{}, &BlockchainAction{}, &Channel{}, &ContractEvent{}, &State{}, &Transaction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}) if err != nil { t.Fatalf("Failed to run migrations: %v", err) } @@ -99,7 +99,7 @@ func setupTestPostgres(ctx context.Context, t testing.TB) (*gorm.DB, testcontain t.Fatalf("Failed to open PostgreSQL database: %v", err) } - err = database.AutoMigrate(&AppV1{}, &AppLedgerEntryV1{}, &Channel{}, &AppSessionV1{}, &ContractEvent{}, &State{}, &Transaction{}, &BlockchainAction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}) + err = database.AutoMigrate(&AppV1{}, &AppLedgerEntryV1{}, &Channel{}, &AppSessionV1{}, &ContractEvent{}, &State{}, &Transaction{}, &BlockchainAction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}) if err != nil { t.Fatalf("Failed to run migrations: %v", err) } diff --git a/pkg/app/session_key_v1.go b/pkg/app/session_key_v1.go index 15db89adf..397fce747 100644 --- a/pkg/app/session_key_v1.go +++ b/pkg/app/session_key_v1.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" "github.com/layer-3/nitrolite/pkg/sign" ) @@ -28,6 +29,9 @@ type AppSessionKeyStateV1 struct { ExpiresAt time.Time // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key UserSig string + // SessionKeySig is the session-key holder's signature over the same packed state. + // Required at submit time so that nobody can register a session key they do not control. + SessionKeySig string } // GenerateSessionKeyStateIDV1 generates a deterministic ID from user_address, session_key, and version. @@ -50,6 +54,54 @@ func GenerateSessionKeyStateIDV1(userAddress, sessionKey string, version uint64) return crypto.Keccak256Hash(packed).Hex(), nil } +// ValidateAppSessionKeyStateV1 verifies both signatures over the registration payload: +// UserSig must recover to state.UserAddress (wallet authorizes the delegation) and +// SessionKeySig must recover to state.SessionKey (session-key holder proves possession). +// Both signatures sign the same PackAppSessionKeyStateV1(state) payload, which already binds +// user_address and session_key — so a signature minted for one (wallet, session_key) pair +// cannot be replayed for another. +func ValidateAppSessionKeyStateV1(state AppSessionKeyStateV1) error { + if state.SessionKeySig == "" { + return fmt.Errorf("session_key_sig is required") + } + + packed, err := PackAppSessionKeyStateV1(state) + if err != nil { + return fmt.Errorf("failed to pack session key state: %w", err) + } + + recoverer, err := sign.NewAddressRecoverer(sign.TypeEthereumMsg) + if err != nil { + return fmt.Errorf("failed to create address recoverer: %w", err) + } + + userSigBytes, err := hexutil.Decode(state.UserSig) + if err != nil { + return fmt.Errorf("failed to decode user_sig: %w", err) + } + recoveredUser, err := recoverer.RecoverAddress(packed, userSigBytes) + if err != nil { + return fmt.Errorf("failed to recover user_sig: %w", err) + } + if !strings.EqualFold(recoveredUser.String(), state.UserAddress) { + return fmt.Errorf("user_sig does not match user_address") + } + + sessionKeySigBytes, err := hexutil.Decode(state.SessionKeySig) + if err != nil { + return fmt.Errorf("failed to decode session_key_sig: %w", err) + } + recoveredKey, err := recoverer.RecoverAddress(packed, sessionKeySigBytes) + if err != nil { + return fmt.Errorf("failed to recover session_key_sig: %w", err) + } + if !strings.EqualFold(recoveredKey.String(), state.SessionKey) { + return fmt.Errorf("session_key_sig does not match session_key") + } + + return nil +} + // PackAppSessionKeyStateV1 packs the session key state for signing using ABI encoding. // This is used to generate a deterministic hash that the user signs when registering/updating a session key. // The user_sig field is excluded from packing since it is the signature itself. diff --git a/pkg/app/session_key_v1_test.go b/pkg/app/session_key_v1_test.go index b01748b24..4efe4e1ce 100644 --- a/pkg/app/session_key_v1_test.go +++ b/pkg/app/session_key_v1_test.go @@ -47,6 +47,89 @@ func TestGenerateSessionKeyStateIDV1(t *testing.T) { assert.NotEqual(t, id1, id3) } +func TestValidateAppSessionKeyStateV1(t *testing.T) { + t.Parallel() + userSigner, userAddress := createTestSigner(t) + sessionSigner, sessionKeyAddr := createTestSigner(t) + + version := uint64(1) + appSessionIDs := []string{ + "0x1111111111111111111111111111111111111111111111111111111111111111", + } + applicationIDs := []string{ + "0x2222222222222222222222222222222222222222222222222222222222222222", + } + expiresAt := time.Now().Add(1 * time.Hour) + + baseState := AppSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKeyAddr, + Version: version, + AppSessionIDs: appSessionIDs, + ApplicationIDs: applicationIDs, + ExpiresAt: expiresAt, + } + + packed, err := PackAppSessionKeyStateV1(baseState) + require.NoError(t, err) + + userSig, err := userSigner.Sign(packed) + require.NoError(t, err) + sessionKeySig, err := sessionSigner.Sign(packed) + require.NoError(t, err) + + state := baseState + state.UserSig = hexutil.Encode(userSig) + state.SessionKeySig = hexutil.Encode(sessionKeySig) + + require.NoError(t, ValidateAppSessionKeyStateV1(state)) + + // Empty session_key_sig + stateNoKeySig := state + stateNoKeySig.SessionKeySig = "" + err = ValidateAppSessionKeyStateV1(stateNoKeySig) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig is required") + + // user_sig signed by wrong wallet + wrongSigner, _ := createTestSigner(t) + wrongUserSig, err := wrongSigner.Sign(packed) + require.NoError(t, err) + stateWrongUser := state + stateWrongUser.UserSig = hexutil.Encode(wrongUserSig) + err = ValidateAppSessionKeyStateV1(stateWrongUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "user_sig does not match user_address") + + // session_key_sig signed by wrong key + wrongKeySigner, _ := createTestSigner(t) + wrongKeySig, err := wrongKeySigner.Sign(packed) + require.NoError(t, err) + stateWrongKey := state + stateWrongKey.SessionKeySig = hexutil.Encode(wrongKeySig) + err = ValidateAppSessionKeyStateV1(stateWrongKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig does not match session_key") + + // Tampered version (hash mismatch on recover) + stateTampered := state + stateTampered.Version = 2 + assert.Error(t, ValidateAppSessionKeyStateV1(stateTampered)) + + // Cross-wallet replay: substitute a different user_address. Packed bytes diverge so + // neither recovery yields the matching address. + _, otherUser := createTestSigner(t) + stateCrossUser := state + stateCrossUser.UserAddress = otherUser + assert.Error(t, ValidateAppSessionKeyStateV1(stateCrossUser)) + + // Cross-session-key replay: substitute a different session_key. + _, otherKey := createTestSigner(t) + stateCrossKey := state + stateCrossKey.SessionKey = otherKey + assert.Error(t, ValidateAppSessionKeyStateV1(stateCrossKey)) +} + func TestPackAppSessionKeyStateV1(t *testing.T) { t.Parallel() expiresAt := time.Unix(1739812234, 0) diff --git a/pkg/blockchain/evm/channel_hub_abi.go b/pkg/blockchain/evm/channel_hub_abi.go index 318638c80..b17edec9e 100644 --- a/pkg/blockchain/evm/channel_hub_abi.go +++ b/pkg/blockchain/evm/channel_hub_abi.go @@ -62,8 +62,8 @@ type State struct { // ChannelHubMetaData contains all meta data concerning the ChannelHub contract. var ChannelHubMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"_defaultSigValidator\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"},{\"name\":\"_node\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"DEFAULT_SIG_VALIDATOR\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"ESCROW_DEPOSIT_UNLOCK_DELAY\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MAX_DEPOSIT_ESCROW_STEPS\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MIN_CHALLENGE_DURATION\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"NODE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"TRANSFER_GAS_LIMIT\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"VALIDATOR_ACTIVATION_DELAY\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"VERSION\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"challengeChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengerSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"challengerIdx\",\"type\":\"uint8\",\"internalType\":\"enumParticipantIndex\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"challengeEscrowDeposit\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"challengerSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"challengerIdx\",\"type\":\"uint8\",\"internalType\":\"enumParticipantIndex\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"challengeEscrowWithdrawal\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"challengerSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"challengerIdx\",\"type\":\"uint8\",\"internalType\":\"enumParticipantIndex\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"checkpointChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"claimFunds\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"destination\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"closeChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"createChannel\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"initState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"depositToChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"depositToNode\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"escrowHead\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"finalizeEscrowDeposit\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"finalizeEscrowWithdrawal\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"finalizeMigration\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getChannelData\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumChannelStatus\"},{\"name\":\"definition\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"lastState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpiry\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"lockedFunds\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getChannelIds\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEscrowDepositData\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumEscrowStatus\"},{\"name\":\"unlockAt\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"challengeExpiry\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"lockedAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"initState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEscrowDepositIds\",\"inputs\":[{\"name\":\"page\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pageSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"ids\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEscrowWithdrawalData\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumEscrowStatus\"},{\"name\":\"challengeExpiry\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"lockedAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"initState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNodeBalance\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNodeValidator\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"outputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"},{\"name\":\"registeredAt\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOpenChannels\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"openChannels\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getReclaimBalance\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getUnlockableEscrowDepositStats\",\"inputs\":[],\"outputs\":[{\"name\":\"count\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"totalAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initiateEscrowDeposit\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"initiateEscrowWithdrawal\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"initiateMigration\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"purgeEscrowDeposits\",\"inputs\":[{\"name\":\"maxSteps\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"registerNodeValidator\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawFromChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"withdrawFromNode\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"ChannelChallenged\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpireAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelCheckpointed\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelClosed\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"finalState\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelCreated\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"definition\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"initialState\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelDeposited\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelWithdrawn\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Deposited\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositChallenged\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpireAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositFinalized\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositFinalizedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositInitiated\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositInitiatedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositsPurged\",\"inputs\":[{\"name\":\"purgedCount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalChallenged\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpireAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalFinalized\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalFinalizedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalInitiated\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalInitiatedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"FundsClaimed\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"destination\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationInFinalized\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationInInitiated\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationOutFinalized\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationOutInitiated\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NodeBalanceUpdated\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TransferFailed\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ValidatorRegistered\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"indexed\":true,\"internalType\":\"uint8\"},{\"name\":\"validator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractISignatureValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawn\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AddressCollision\",\"inputs\":[{\"name\":\"collision\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ChallengerVersionTooLow\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureLength\",\"inputs\":[{\"name\":\"length\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureS\",\"inputs\":[{\"name\":\"s\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"EmptySignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectAmount\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectChallengeDuration\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectChannelId\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectChannelStatus\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectNode\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectStateIntent\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectValue\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBalance\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAddress\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidValidatorId\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NativeTransferFailed\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NoChannelIdFoundForEscrow\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeCastOverflowedIntToUint\",\"inputs\":[{\"name\":\"value\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ValidatorAlreadyRegistered\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"}]},{\"type\":\"error\",\"name\":\"ValidatorNotActive\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"activatesAt\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"ValidatorNotApproved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ValidatorNotRegistered\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"}]}]", - Bin: "0x60c0346100fd57601f615a4738819003918201601f19168301916001600160401b038311848410176101015780849260409485528339810103126100fd5780516001600160a01b038116918282036100fd5760200151916001600160a01b038316908184036100fd5760017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055156100ee57156100ee5760805260a052604051615931908161011682396080518181816110400152613bba015260a051818181610c34015281816112ff01528181611e760152818161336401528181613dbb015281816142f401526143d60152f35b63e6c4247b60e01b5f5260045ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe60806040526004361015610011575f80fd5b5f3560e01c806307f241ce1461026f57806316b390b11461026a578063187576d8146102655780633115f6301461026057806338a66be21461025b5780633c684f921461025657806341b660ef1461025157806347de477a1461024c57806351bfcdbd1461024757806353269198146102425780635a0745b41461023d5780635ae2accc146102385780635b9acbf9146102335780635dc46a741461022e5780636840dbd2146102295780636898234b1461022457806371a471411461021f578063735181f01461021a57806382d3e15d146102155780638d0b12a5146102105780638e31c7351461020b57806394191051146102015780639691b46814610206578063a459463114610201578063a5c82680146101fc578063b25a1d38146101f7578063b65b78d1146101f2578063c74a2d10146101ed578063c9408398146101e8578063d888ccae146101e3578063d91a1283146101de578063dc23f29e146101d9578063dd73d494146101d4578063e617208c146101cf578063f4ac51f5146101ca578063f766f8d6146101c5578063ff5bc09e146101c05763ffa1ad74146101bb575f80fd5b612491565b61247a565b61236e565b6122f3565b612255565b6120ce565b611f17565b611e0b565b611d02565b611ac4565b611a49565b611988565b611652565b6114f3565b6113ce565b6113eb565b611233565b6110ec565b6110cf565b611089565b611021565b610f35565b610f1e565b610ed8565b610eb6565b610e9b565b610e7f565b610c87565b610c15565b610aa1565b6107fa565b610734565b6106f9565b61055d565b6104d7565b610341565b610289565b6001600160a01b0381160361028557565b5f80fd5b34610285576020366003190112610285576001600160a01b036004356102ae81610274565b165f526006602052602060405f2054604051908152f35b9181601f84011215610285578235916001600160401b038311610285576020838186019501011161028557565b60643590600282101561028557565b9060606003198301126102855760043591602435906001600160401b03821161028557610330916004016102c5565b909160443560028110156102855790565b34610285576103a36103dd61035536610301565b9294916103b8610370879693965f52600260205260405f2090565b9485549261037f8415156124ac565b600187015460059060081c6001600160a01b031696879260028a01549a8b91613b99565b9192909901986103b28a6126c6565b87613ce6565b60c06103c387613e10565b604051809481926301999b9360e61b835260048301612836565b038173__$682d6198b4eca5bc7e038b912a26498e7e$__5af48015610499577fba075bd445233f7cad862c72f0343b3503aad9c8e704a2295f122b82abf8e80195610451946080945f93610466575b5082610443939461043c896126c6565b908b613e84565b01516001600160401b031690565b90610461604051928392836128a9565b0390a2005b610443935061048c9060c03d60c011610492575b6104848183612542565b810190612774565b9261042c565b503d61047a565b612847565b60206040818301928281528451809452019201905f5b8181106104c15750505090565b82518452602093840193909201916001016104b4565b34610285576020366003190112610285576001600160a01b036004356104fc81610274565b165f52600160205260405f206040519081602082549182815201915f5260205f20905f5b818110610547576105438561053781870382612542565b6040519182918261049e565b0390f35b8254845260209093019260019283019201610520565b34610285576020366003190112610285576004355f905f60035491600454925b808410806106f0575b156106e5576105bb6105b56105a761059d87612ea7565b90549060031b1c90565b5f52600260205260405f2090565b936136de565b946105c58461518c565b6106d3576105d2846151bc565b15610690575f5160206158bc5f395f51905f526001600160a01b0361067961067361065e945f610610600c8b01546001600160a01b039060401c1690565b9961066d60016106318d6001600160a01b03165f52600660205260405f2090565b54928d6106446004830195865490613066565b9b8c916001600160a01b03165f52600660205260405f2090565b5501805460ff19166003179055565b556136de565b976136de565b604051938452951691602090a25b9391929361057d565b945050505061069e90600455565b806106a557005b6040519081527f61815f4b11c6ea4e14a2e448a010bed8efdc3e53a15efbf183d16a31085cd14590602090a1005b936106df9193506136de565b91610687565b50505060045561069e565b50818310610586565b34610285575f366003190112610285576020604051620186a08152f35b6004359060ff8216820361028557565b359060ff8216820361028557565b346102855760203660031901126102855760ff61074f610716565b165f52600760205260405f2060405160408101918183106001600160401b038411176107ad576040928352546001600160a01b03811680835260a09190911c6001600160401b03166020928301819052835191825291810191909152f35b6124c1565b90816102609103126102855790565b90600319820160e081126102855760c0136102855760049160c435906001600160401b038211610285576107f7916004016107b2565b90565b610803366107c1565b6020810160026108128261294f565b61081b81611bac565b148015610a86575b8015610a68575b61083390612959565b6108bd6108486108433686612988565b614282565b92610855602086016129fc565b9061085f866142b7565b61086f6080870135838388614394565b60c0816108a261089b610884608084016129fc565b6001600160a01b03165f52600660205260405f2090565b54886143fb565b604051632a2d120f60e21b8152958692839260048401612c22565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af4908115610499577fb0d099feaab5034d04a1c610e86b8832343f2127b3c667b705834dafdf96e9e4946109326109b3936001600160a01b03965f91610a39575b50610921368b612988565b61092b3686612d26565b908a61452f565b61095687610951866001600160a01b03165f52600160205260405f2090565b6154d0565b5060026109628261294f565b61096b81611bac565b036109b85750857f6085f5128b19e0d3cc37524413de47259383f0f75265d5d66f41778696206696604051806109a18582612dd2565b0390a25b604051938493169683612de3565b0390a3005b6109c360039161294f565b6109cc81611bac565b03610a0957857f188e0ade7d115cc397426774adb960ae3e8c83e72f0a6cad4b7085e1d60bf98660405180610a018582612dd2565b0390a26109a5565b857f567044ba1cdd4671ac3979c114241e1e3b56c9e9051f63f2f234f7a2795019cc60405180610a018582612dd2565b610a5b915060c03d60c011610a61575b610a538183612542565b810190612a06565b5f610916565b503d610a49565b50610833610a758261294f565b610a7e81611bac565b15905061082a565b506003610a928261294f565b610a9b81611bac565b14610823565b610aaa366107c1565b90610acb6004610abc6020850161294f565b610ac581611bac565b14612959565b610ad4816142b7565b610ae16108433683612988565b916080610af0602084016129fc565b92013591610b0083828487614394565b610b12610b0c83612e72565b85614642565b92610b1c85614671565b15610b5e5750506109b381610b527f471c4ebe4e57d25ef7117e141caac31c6b98f067b8098a7a7bbd38f637c2f98093866146cd565b60405191829182612dd2565b9091610b8a60c082610b6f87613e10565b604051632ef10bcd60e21b8152938492839260048401612e7c565b038173__$682d6198b4eca5bc7e038b912a26498e7e$__5af4928315610499577fede7867afa7cdb9c443667efd8244d98bf9df1dce68e60dc94dca6605125ca76946109b394610bed935f91610bf6575b50610be63686612d26565b8989613e84565b610b5284612ef6565b610c0f915060c03d60c011610492576104848183612542565b5f610bdb565b34610285575f3660031901126102855760206040516001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000168152f35b9060406003198301126102855760043591602435906001600160401b038211610285576107f7916004016107b2565b3461028557610c9536610c58565b610ca66009610abc6020840161294f565b610cc26001610cbc845f525f60205260405f2090565b01612f3f565b610d5d610cd960208301516001600160a01b031690565b91610cea6080820151848688614394565b610cf43685612d26565b61014085019386610d0486612e72565b6001600160401b031646149586610e17575b50505060c081610d42610d3b61088460206060850151016001600160a01b0390511690565b54896143fb565b604051632a2d120f60e21b8152958692839260048401612fc9565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af491821561049957610d8f935f93610df6575b508661452f565b15610dc5576104617f9a6f675cc94b83b55f1ecc0876affd4332a30c92e6faa2aca0199b1b6df922c39160405191829182612dd2565b6104617f7b20773c41402791c5f18914dbbeacad38b1ebcc4c55d8eb3bfe0a4cde26c8269160405191829182612dd2565b610e1091935060c03d60c011610a6157610a538183612542565b915f610d88565b610e7692610e29610e71923690612c47565b6060860152610e3b3660608b01612c47565b6080860152610e48612fb5565b60a0860152610e55612fb5565b60c08601526001600160a01b03165f52600160205260405f2090565b615575565b505f8681610d16565b34610285575f366003190112610285576020604051612a308152f35b34610285575f36600319011261028557602060405160408152f35b3461028557604036600319011261028557610543610537602435600435613094565b610eea610ee436610c58565b9061314d565b005b6060600319820112610285576004359160243591604435906001600160401b038211610285576107f7916004016107b2565b3461028557610eea610f2f36610eec565b9161349f565b34610285576020366003190112610285576001600160a01b03600435610f5a81610274565b165f526001602052610f6e60405f20615444565b5f905f5b815181101561100e57610fa0610f99610f8b8385613080565b515f525f60205260405f2090565b5460ff1690565b610fa9816121a4565b60038114159081610ff9575b50610fc3575b600101610f72565b91610fd6818460019310610fde576136de565b929050610fbb565b610fe88585613080565b51610ff38286613080565b526136de565b60059150611006816121a4565b14155f610fb5565b506105439181526040519182918261049e565b34610285575f3660031901126102855760206040516001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000168152f35b60409060031901126102855760043561107c81610274565b906024356107f781610274565b346102855760206110c66001600160a01b036110a436611064565b91165f526008835260405f20906001600160a01b03165f5260205260405f2090565b54604051908152f35b34610285575f366003190112610285576020600454604051908152f35b34610285576110fa36610301565b611146611112859493945f52600560205260405f2090565b918254946111218615156124ac565b60a061112c88614925565b604051809581926312031f5d60e11b8352600483016136ec565b038173__$b69fb814c294bfc16f92e50d7aeced4bde$__5af4908115610499577fb8568a1f475f3c76759a620e08a653d28348c5c09e2e0bc91d533339801fefd8966103b296610451966060965f956111f0575b50916111e0859661044396938560056111c460016111d49901546001600160a01b039060081c1690565b97889360028401549a8b91613b99565b92909193019e8f6126c6565b6111e9896126c6565b908b6149df565b61044395506111d4939192966112206111e09260a03d60a01161122c575b6112188183612542565b8101906133db565b9650969291935061119a565b503d61120e565b346102855760603660031901126102855761124c610716565b60243561125881610274565b6044356001600160401b0381116102855761136b9161127e6113a89236906004016102c5565b93909461133161132c60ff8316966112978815156136fd565b6001600160a01b038616986112ad8a1515613713565b6112ee856112e86112dc6112dc6112cf8460ff165f52600760205260405f2090565b546001600160a01b031690565b6001600160a01b031690565b15613729565b6113266112fc8b8730614aa7565b917f0000000000000000000000000000000000000000000000000000000000000000933691612cd5565b90614adf565b613747565b61134b61133c612563565b6001600160a01b039094168452565b426001600160401b0316602084015260ff165f52600760205260405f2090565b8151815460209093015167ffffffffffffffff60a01b60a09190911b166001600160e01b03199093166001600160a01b0390911617919091179055565b7f9ee792368f12db92ad66335fa19df35feaec025c86445fea202ab5412a180e055f80a3005b34610285575f366003190112610285576020604051620151808152f35b61146f6113f736610c58565b6114186114096020839594950161294f565b61141281611bac565b15612959565b61142e6001610cbc855f525f60205260405f2090565b9061145361144660208401516001600160a01b031690565b6080840151908387614394565b60c0816108a2611468610884608084016129fc565b54876143fb565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af4928315610499577f567044ba1cdd4671ac3979c114241e1e3b56c9e9051f63f2f234f7a2795019cc9361046193610b52925f926114d2575b506114cb3685612d26565b908761452f565b6114ec91925060c03d60c011610a6157610a538183612542565b905f6114c0565b3461028557611501366107c1565b906115136006610abc6020850161294f565b61151c816142b7565b6115296108433683612988565b916080611538602084016129fc565b9201359161154883828487614394565b611554610b0c83612e72565b9261155e85614671565b156115945750506109b381610b527f587faad1bcd589ce902468251883e1976a645af8563c773eed7356d78433210c93866146cd565b90916115d060a0826115b66115af61088461016084016129fc565b5488614982565b60405162ea54e760e01b8152938492839260048401613488565b038173__$b69fb814c294bfc16f92e50d7aeced4bde$__5af4928315610499577f17eb0a6bd5a0de45d1029ce3444941070e149df35b22176fc439f930f73c09f7946109b394610b52935f91611633575b5061162c3686612d26565b89896149df565b61164c915060a03d60a01161122c576112188183612542565b5f611621565b6080366003190112610285576004356024356001600160401b038111610285576116809036906004016107b2565b6044356001600160401b0381116102855761169f9036906004016102c5565b91906116a96102f2565b926116bb855f525f60205260405f2090565b9185846116ca60018601612f3f565b936116d6865460ff1690565b936116e0856121a4565b60018514808015611975575b6116f59061375d565b611701600589016126c6565b9561173f61170e86612e72565b6001600160401b0361173661172a8b516001600160401b031690565b6001600160401b031690565b91161015613773565b60208801516001600160a01b0316966001600160401b0361177861172a61176a60808d015199612e72565b93516001600160401b031690565b911611611835575b5050946117db7f07b9206d5a6026d3bd2a8f9a9b79f6fa4bfbd6a016975829fbaf07488019f28a998998966117d56118089760149c6117c96117f9996117f0996118269e613b99565b93919490923690612d26565b90613ce6565b845460ff191660021785555163ffffffff1690565b63ffffffff1690565b6001600160401b0342166137a9565b9301805467ffffffffffffffff19166001600160401b038516179055565b610461604051928392836137c9565b6118a69495509061187e9161187160208c9b999c9a959a019161186c600161185c8561294f565b61186581611bac565b1415612959565b6121a4565b81611958575b5015612959565b61188a8489898d614394565b60c0876108a261189f610884608084016129fc565b548d6143fb565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af4918215610499577f07b9206d5a6026d3bd2a8f9a9b79f6fa4bfbd6a016975829fbaf07488019f28a996014996117d58d8b6117c96118089a6117db976118269e6119236117f09c6117f99e5f91611939575b5061191c3688612d26565b8d89614ed0565b999e509950995050509750509698995099611780565b611952915060c03d60c011610a6157610a538183612542565b5f611911565b600991506119659061294f565b61196e81611bac565b145f611877565b5061197f866121a4565b600486146116ec565b6040366003190112610285576004356119a081610274565b602435906119af8215156137f0565b6001600160a01b03811691825f52600660205260405f2054818101809111611a4457837f2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4611a3184611a21610461965f5160206158bc5f395f51905f5298865f5260066020528760405f2055336150a2565b6040519081529081906020820190565b0390a26040519081529081906020820190565b612fee565b611a69611a5536610c58565b6114186003610abc6020849695960161294f565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af4928315610499577f188e0ade7d115cc397426774adb960ae3e8c83e72f0a6cad4b7085e1d60bf9869361046193610b52925f926114d257506114cb3685612d26565b34610285575f36600319011261028557600354600454905f805b82841015611b80577fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b8401545f90815260026020526040902091611b218361518c565b611b6e57611b2e836151bc565b15611b5757611b4e916004611b456105b5936136de565b94015490613066565b915b9192611ade565b92509250505b604080519182526020820192909252f35b915092611b7a906136de565b91611b50565b92509050611b5d565b634e487b7160e01b5f52602160045260245ffd5b60041115611ba757565b611b89565b600a1115611ba757565b90600a821015611ba75752565b60c080916001600160401b0381511684526001600160a01b03602082015116602085015260ff6040820151166040850152606081015160608501526080810151608085015260a081015160a08501520151910152565b805180835260209291819084018484015e5f828201840152601f01601f1916010190565b6107f7916001600160401b038251168152611c6060208301516020830190611bb6565b60408201516040820152611c7c60608301516060830190611bc3565b611c8f6080830151610140830190611bc3565b60c0611cad60a0840151610260610220850152610260840190611c19565b92015190610240818403910152611c19565b92936001600160401b0360c0956107f798979482948752611cdf81611b9d565b602087015216604085015216606083015260808201528160a08201520190611c3d565b3461028557602036600319011261028557600435611d1e61383c565b505f52600260205260405f2060405190611d37826124d5565b80548252610543600182015491611d82611d72611d548560ff1690565b94611d63602088019687613880565b60081c6001600160a01b031690565b6001600160a01b03166040860152565b6002810154606085015260038101546001600160401b0380821660808701908152959160401c166001600160401b031660a0820190815291611dfa61176a611dd8600560048501549460c08701958652016126c6565b9360e0810194855251965197611ded89611b9d565b516001600160401b031690565b905191519260405196879687611cbf565b3461028557606036600319011261028557600435611e2881610274565b5f5160206158bc5f395f51905f5261046160243592611e4684610274565b60443593611e5e6001600160a01b0383161515613713565b611e698515156137f0565b611e9d6001600160a01b037f000000000000000000000000000000000000000000000000000000000000000016331461388c565b7f7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d5611a3186611a216001600160a01b038516988995865f526006602052611ef48260405f2054611eef828210156138a2565b613073565b9788611f11836001600160a01b03165f52600660205260405f2090565b556151eb565b3461028557611f25366107c1565b611f366008610abc6020840161294f565b611f436108433684612988565b91611fa4611f53602083016129fc565b91611f646080820135848688614394565b611f6e3685612d26565b611f7786614671565b9386851561206d575b505060c081610d42610d3b61088460206060850151016001600160a01b0390511690565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af491821561049957611fe1935f93612048575b50611fdb903690612988565b8661452f565b15612017576104617f3142fb397e715d80415dff7b527bf1c451def4675da6e1199ee1b4588e3f630a9160405191829182612dd2565b6104617f26afbcb9eb52c21f42eb9cfe8f263718ffb65afbf84abe8ad8cce2acfb2242b89160405191829182612dd2565b611fdb9193506120669060c03d60c011610a6157610a538183612542565b9290611fcf565b61095161208b9261207d866142b7565b610e29366101408b01612c47565b505f86611f80565b9160a0936001600160401b03916107f797969385526120b181611b9d565b602085015216604083015260608201528160808201520190611c3d565b34610285576020366003190112610285576004356120ea61383c565b505f52600560205260405f2060405190612103826124f1565b8054825261054360018201549161213a611d7260ff851694602087019561212981611b9d565b865260081c6001600160a01b031690565b6002810154606085015260038101546001600160401b03166001600160401b0316608085019081529361219361217e600560048501549460a08501958652016126c6565b9160c0810192835251945195611ded87611b9d565b915190519160405195869586612093565b60061115611ba757565b906006821015611ba75752565b60a0809163ffffffff81511684526001600160a01b0360208201511660208501526001600160a01b0360408201511660408501526001600160401b036060820151166060850152608081015160808501520151910152565b91926122376101209461222d8561224a959a99989a6121ae565b60208501906121bb565b61014060e0840152610140830190611c3d565b946101008201520152565b34610285576020366003190112610285576004355f60a06040516122788161250c565b828152826020820152826040820152826060820152826080820152015261229d61383c565b505f525f6020526122b060405f206138c4565b80516122bb816121a4565b61054360208301519260408101519060606122e361172a60808401516001600160401b031690565b9101519160405195869586612213565b6123136122ff36610c58565b6114186002610abc6020849695960161294f565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af4928315610499577f6085f5128b19e0d3cc37524413de47259383f0f75265d5d66f417786962066969361046193610b52925f926114d257506114cb3685612d26565b346102855761237c36611064565b612384615237565b6001600160a01b0381169161239a831515613713565b6001600160a01b036123d7826123c1336001600160a01b03165f52600860205260405f2090565b906001600160a01b03165f5260205260405f2090565b54916123e48315156137f0565b5f612404826123c1336001600160a01b03165f52600860205260405f2090565b551691818361246b57612427915f808080858a5af1612421613921565b50613950565b60405190815233907f7b8d70738154be94a9a068a6d2f5dd8cfc65c52855859dc8f47de1ff185f8b5590602090a4610eea60015f5160206158dc5f395f51905f5255565b612475918461526f565b612427565b3461028557610eea61248b36610eec565b91613978565b34610285575f36600319011261028557602060405160018152f35b156124b357565b6287a33760e41b5f5260045ffd5b634e487b7160e01b5f52604160045260245ffd5b61010081019081106001600160401b038211176107ad57604052565b60e081019081106001600160401b038211176107ad57604052565b60c081019081106001600160401b038211176107ad57604052565b60a081019081106001600160401b038211176107ad57604052565b90601f801991011681019081106001600160401b038211176107ad57604052565b60405190612572604083612542565b565b6040519061257260e083612542565b90604051612590816124f1565b60c0600482946125cd60ff82546001600160401b03811687526001600160a01b03808260401c1616602088015260e01c16604086019060ff169052565b6001810154606085015260028101546080850152600381015460a08501520154910152565b90600182811c92168015612620575b602083101461260c57565b634e487b7160e01b5f52602260045260245ffd5b91607f1691612601565b5f9291815491612639836125f2565b808352926001811690811561268e575060011461265557505050565b5f9081526020812093945091925b838310612674575060209250010190565b600181602092949394548385870101520191019190612663565b915050602093945060ff929192191683830152151560051b010190565b906125726126bf926040519384809261262a565b0383612542565b906040516126d3816124f1565b809260ff81546001600160401b038116845260401c1690600a821015611ba757600d6127449160c09360208601526001810154604086015261271760028201612583565b606086015261272860078201612583565b6080860152612739600c82016126ab565b60a0860152016126ab565b910152565b5190600482101561028557565b6001600160401b0381160361028557565b5190811515820361028557565b908160c0910312610285576127dc60a0604051926127918461250c565b80518452602081015160208501526127ab60408201612749565b604085015260608101516127be81612756565b606085015260808101516127d181612756565b608085015201612767565b60a082015290565b9081516127f081611b9d565b815260806001600160401b0381612816602086015160a0602087015260a0860190611c3d565b946040810151604086015282606082015116606086015201511691015290565b9060206107f79281815201906127e4565b6040513d5f823e3d90fd5b600460c09160ff8082546001600160401b03811687526001600160a01b038160401c16602088015260e01c161660408501526001810154606085015260028101546080850152600381015460a08501520154910152565b9291602061293561257293604087526128dc81546001600160401b03811660408a015260ff60608a019160401c16611bb6565b600181015460808801526128f660a0880160028301612852565b612907610180880160078301612852565b61026080880152600d6129216102a08901600c840161262a565b888103603f19016102808a0152910161262a565b9401906001600160401b03169052565b600a111561028557565b356107f781612945565b1561296057565b633226144f60e21b5f5260045ffd5b63ffffffff81160361028557565b359061257282612756565b91908260c0910312610285576040516129a08161250c565b60a080829480356129b08161296f565b845260208101356129c081610274565b602085015260408101356129d381610274565b604085015260608101356129e681612756565b6060850152608081013560808501520135910152565b356107f781610274565b908160c09103126102855760405190612a1e8261250c565b805182526020810151602083015260408101516006811015610285576127dc9160a09160408501526060810151612a5481612756565b60608501526127d160808201612767565b90612a718183516121ae565b60806001600160401b0381612a95602086015160a0602087015260a0860190611c3d565b94604081015160408601526060810151606086015201511691015290565b359061257282612945565b60c080916001600160401b038135612ad581612756565b1684526001600160a01b036020820135612aee81610274565b16602085015260ff612b0260408301610726565b166040850152606081013560608501526080810135608085015260a081013560a08501520135910152565b9035601e19823603018112156102855701602081359101916001600160401b03821161028557813603831361028557565b908060209392818452848401375f828201840152601f01601f1916010190565b6107f7916001600160401b038235612b9581612756565b168152612bb36020830135612ba981612945565b6020830190611bb6565b60408201356040820152612bcd6060820160608401612abe565b612bdf61014082016101408401612abe565b612c13612c07612bf3610220850185612b2d565b610260610220860152610260850191612b5e565b92610240810190612b2d565b91610240818503910152612b5e565b9091612c396107f793604084526040840190612a65565b916020818403910152612b7e565b91908260e091031261028557604051612c5f816124f1565b60c08082948035612c6f81612756565b84526020810135612c7f81610274565b6020850152612c9060408201610726565b6040850152606081013560608501526080810135608085015260a081013560a08501520135910152565b6001600160401b0381116107ad57601f01601f191660200190565b929192612ce182612cba565b91612cef6040519384612542565b829481845281830111610285578281602093845f960137010152565b9080601f83011215610285578160206107f793359101612cd5565b9190916102608184031261028557612d3c612574565b92612d468261297d565b8452612d5460208301612ab3565b602085015260408201356040850152612d708160608401612c47565b6060850152612d83816101408401612c47565b60808501526102208201356001600160401b0381116102855781612da8918401612d0b565b60a08501526102408201356001600160401b03811161028557612dcb9201612d0b565b60c0830152565b9060206107f7928181520190612b7e565b60e09060a06107f7949363ffffffff8135612dfd8161296f565b1683526001600160a01b036020820135612e1681610274565b1660208401526001600160a01b036040820135612e3281610274565b1660408401526001600160401b036060820135612e4e81612756565b16606084015260808101356080840152013560a08201528160c08201520190612b7e565b356107f781612756565b9091612c396107f7936040845260408401906127e4565b634e487b7160e01b5f52603260045260245ffd5b600354811015612ebf5760035f5260205f2001905f90565b612e93565b8054821015612ebf575f5260205f2001905f90565b91612ef29183549060031b91821b915f19901b19161790565b9055565b600354600160401b8110156107ad5760018101600355600354811015612ebf5760035f527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b0155565b90604051612f4c8161250c565b60a0600382946001600160a01b03815463ffffffff8116865260201c166020850152612fa46001600160401b0360018301546001600160a01b03808216166040880152851c1660608601906001600160401b03169052565b600281015460808501520154910152565b60405190612fc4602083612542565b5f8252565b9091612fe06107f793604084526040840190612a65565b916020818403910152611c3d565b634e487b7160e01b5f52601160045260245ffd5b6001600160401b0381116107ad5760051b60200190565b60405190613028602083612542565b5f808352366020840137565b9061303e82613002565b61304b6040519182612542565b828152809261305c601f1991613002565b0190602036910137565b91908201809211611a4457565b91908203918211611a4457565b8051821015612ebf5760209160051b010190565b91906003549080840293808504821490151715611a44578184101561311857830190818411611a4457808211613110575b506130d86130d38483613073565b613034565b92805b8281106130e757505050565b806130f661059d600193612ea7565b6131096131038584613073565b88613080565b52016130db565b90505f6130c5565b505090506107f7613019565b906006811015611ba75760ff80198354169116179055565b9060206107f7928181520190611c3d565b9061315f825f525f60205260405f2090565b61316b60018201612f3f565b91613177825460ff1690565b9184613185600583016126c6565b91600261319c60208801516001600160a01b031690565b956131a6816121a4565b1480613395575b6132bc575050506131c56001610abc6020840161294f565b6131d56080840151838387614394565b61320860c0826131ed61089b610884608084016129fc565b604051632a2d120f60e21b8152938492839260048401612c22565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af4801561049957610e716132969461327288937f04cd8c68bf83e7bc531ca5a5d75c34e36513c2acf81e07e6470ba79e29da13a898613289965f9261329b575b5061326b3689612d26565b908661452f565b6001600160a01b03165f52600160205260405f2090565b5060405191829182612dd2565b0390a2565b6132b591925060c03d60c011610a6157610a538183612542565b905f613260565b7f04cd8c68bf83e7bc531ca5a5d75c34e36513c2acf81e07e6470ba79e29da13a895506133889293506132969461331b601483613303610e7195600360ff19825416179055565b5f601382015501805467ffffffffffffffff19169055565b613272606086016133478151606061333d60208301516001600160a01b031690565b9101519085614781565b5160a061335e60208301516001600160a01b031690565b910151907f0000000000000000000000000000000000000000000000000000000000000000614781565b506040519182918261313c565b506014810154426001600160401b03909116106131ad565b156133b457565b6336c7a86b60e21b5f5260045ffd5b906133cd81611b9d565b60ff80198354169116179055565b908160a0910312610285576134306080604051926133f884612527565b805184526020810151602085015261341260408201612749565b6040850152606081015161342581612756565b606085015201612767565b608082015290565b90815161344481611b9d565b8152608080613462602085015160a0602086015260a0850190611c3d565b93604081015160408501526001600160401b036060820151166060850152015191015290565b9091612c396107f793604084526040840190613438565b916134b2825f52600560205260405f2090565b906134bd83856148d8565b613686576134cd848354146133ad565b600182018054929060026134f0600886901c6001600160a01b03165b9560ff1690565b6134f981611b9d565b148061366e575b61359757506002906135196007610abc6020860161294f565b01549061352882848388614394565b61353760a0826115b687614925565b038173__$b69fb814c294bfc16f92e50d7aeced4bde$__5af4928315610499577f2fdac1380dbe23ae259b6871582b7f33e34461547f400bdd20d74991250317d19461359294610b52935f91611633575061162c3686612d26565b0390a3565b805460ff191660031790557f2fdac1380dbe23ae259b6871582b7f33e34461547f400bdd20d74991250317d1925061359291905f5160206158bc5f395f51905f526001600160a01b0361363c61361a600c60048601955f87549755613609600382016001600160401b03198154169055565b015460401c6001600160a01b031690565b93613636856001600160a01b03165f52600660205260405f2090565b54613066565b9283613659826001600160a01b03165f52600660205260405f2090565b556040519384521691602090a2610b5261417c565b506003820154426001600160401b0390911610613500565b7f6d0cf3d243d63f08f50db493a8af34b27d4e3bc9ec4098e82700abfeffe2d498915061359290610b526136d760016136c6885f525f60205260405f2090565b015460201c6001600160a01b031690565b82876148fa565b5f198114611a445760010190565b9060206107f7928181520190613438565b1561370457565b6306ee4dcd60e01b5f5260045ffd5b1561371a57565b63e6c4247b60e01b5f5260045ffd5b156137315750565b60ff906357470ffd60e01b5f521660045260245ffd5b1561374e57565b63c1606c2f60e01b5f5260045ffd5b1561376457565b631e40ad6360e31b5f5260045ffd5b1561377a57565b637d95736160e01b5f5260045ffd5b6001600160401b0362015180911601906001600160401b038211611a4457565b906001600160401b03809116911601906001600160401b038211611a4457565b906001600160401b036137e9602092959495604085526040850190612b7e565b9416910152565b156137f757565b6334b2073960e11b5f5260045ffd5b60405190613813826124f1565b5f60c0838281528260208201528260408201528260608201528260808201528260a08201520152565b60405190613849826124f1565b606060c0835f81525f60208201525f6040820152613865613806565b83820152613871613806565b60808201528260a08201520152565b61388982611b9d565b52565b1561389357565b6308ad910960e21b5f5260045ffd5b156138a957565b631e9acf1760e31b5f5260045ffd5b6006821015611ba75752565b906040516138d181612527565b60806001600160401b03601483956138ed60ff825416866138b8565b6138f960018201612f3f565b602086015261390a600582016126c6565b604086015260138101546060860152015416910152565b3d1561394b573d9061393282612cba565b916139406040519384612542565b82523d5f602084013e565b606090565b15613959575050565b6001600160a01b039063296c17bb60e21b5f521660045260245260445ffd5b9161398b825f52600260205260405f2090565b9061399683856152c8565b613af9576139a6848354146133ad565b600182018054929060026139c6600886901c6001600160a01b03166134e9565b6139cf81611b9d565b1480613ad6575b613a6857506002906139ef6005610abc6020860161294f565b0154906139fe82848388614394565b613a0d60c082610b6f87613e10565b038173__$682d6198b4eca5bc7e038b912a26498e7e$__5af4928315610499577f1b92e8ef67d8a7c0d29c99efcd180a5e0d98d60ac41d52abbbb5950882c78e4e9461359294610b52935f91610bf65750610be63686612d26565b805460ff191660031790557f1b92e8ef67d8a7c0d29c99efcd180a5e0d98d60ac41d52abbbb5950882c78e4e926135929291613ace91613ac8600c60048401935f855495556136096003820167ffffffffffffffff60401b198154169055565b90614781565b610b5261417c565b50600382015460401c6001600160401b03166001600160401b03429116106139d6565b7f32e24720f56fd5a7f4cb219d7ff3278ae95196e79c85b5801395894a6f53466c915061359290610b526136d760016136c6885f525f60205260405f2090565b15613b4057565b6306a41ced60e21b5f5260045ffd5b15613b575750565b60ff9063399eb60560e01b5f521660045260245ffd5b15613b76575050565b9060ff6001600160401b039263975133f360e01b5f52166004521660245260445ffd5b9291908015613c73578015612ebf57613be891843560f81c9081613bec57507f000000000000000000000000000000000000000000000000000000000000000094600101925f19909201919050565b9091565b600180613bff84613c06949060ff161c90565b1614613b39565b613c66613c1e8260ff165f52600760205260405f2090565b546001600160a01b0381169290613c5390613c4e90613c3f84871515613b4f565b60a01c6001600160401b031690565b613789565b906001600160401b038216421015613b6d565b93600101915f1990910190565b63ac241e1160e01b5f5260045ffd5b805191908290602001825e015f815290565b60021115611ba757565b90816020910312610285575190565b9392606093613cd86001600160a01b03946137e9949998998852608060208901526080880190611c19565b918683036040880152612b5e565b602095613d1693959497613d39613d046001600160a01b03956152e0565b613d2b6040519788928c840190613c82565b686368616c6c656e676560b81b815260090190565b03601f198101875286612542565b613d4281613c94565b613db557613d68905b60405163600109bb60e01b81529889978896879560048701613cad565b0392165afa801561049957612572915f91613d86575b501515613747565b613da8915060203d602011613dae575b613da08183612542565b810190613c9e565b5f613d7e565b503d613d96565b50613d687f0000000000000000000000000000000000000000000000000000000000000000613d4b565b60405190613dec82612527565b5f608083828152613dfb61383c565b60208201528260408201528260608201520152565b613e18613ddf565b905f5260026020526001600160401b0380600360405f2060ff600182015416613e4081611b9d565b8552613e4e600582016126c6565b6020860152600481015460408601520154818116606085015260401c1616608082015290565b600160ff1b8114611a44575f0390565b6020939291613f1891613e9f815f52600260205260405f2090565b97604086018051613eaf81611b9d565b613eb881611b9d565b61415f575b50878560a0880194613ecf8651151590565b61414c575b5050505050613eed60608501516001600160401b031690565b6001600160401b038116614123575b5060808401516001600160401b0316806140ed575b5051151590565b156140d457608001518201516001600160a01b031680935b8251905f82131561409457613f529150613f4a8451615428565b9283916150a2565b613f6160048601918254613066565b90555b0180515f811315613ff957505f5160206158bc5f395f51905f5291613f916001600160a01b039251615428565b613fe26004613fbb83613fb5866001600160a01b03165f52600660205260405f2090565b54613073565b9687613fd8866001600160a01b03165f52600660205260405f2090565b5501918254613066565b90556040519384521691602090a25b61257261417c565b90505f811261400b575b505050613ff1565b5f5160206158bc5f395f51905f529161403361402e6001600160a01b0393613e74565b615428565b61407e600461405783613636866001600160a01b03165f52600660205260405f2090565b9687614074866001600160a01b03165f52600660205260405f2090565b5501918254613073565b90556040519384521691602090a25f8080614003565b5f82126140a4575b505050613f64565b6140b361402e6140bb93613e74565b928391614781565b6140ca60048601918254613073565b9055825f8061409c565b50600c84015460401c6001600160a01b03168093613f30565b61411d90600389019067ffffffffffffffff60401b82549160401b169067ffffffffffffffff60401b1916179055565b5f613f11565b6141469060038901906001600160401b03166001600160401b0319825416179055565b5f613efc565b61415594615352565b5f80878582613ed4565b614176905161416d81611b9d565b60018b016133c3565b5f613ebd565b5f905f60035491600454925b80841080614278575b1561426b576141a86105b56105a761059d87612ea7565b946141b28461518c565b614259576141bf846151bc565b15614214575f5160206158bc5f395f51905f526001600160a01b036141fd61067361065e945f610610600c8b01546001600160a01b039060401c1690565b604051938452951691602090a25b93919293614188565b93919450506142239150600455565b8061422b5750565b6040519081527f61815f4b11c6ea4e14a2e448a010bed8efdc3e53a15efbf183d16a31085cd14590602090a1565b936142659193506136de565b9161420b565b5092916142239150600455565b5060408310614191565b6040516142936020820180936121bb565b60c081526142a260e082612542565b5190206001600160f81b0316600160f81b1790565b6001600160a01b0360208201356142cd81610274565b166142d9811515613713565b6001600160a01b0361431e60408401356142f281610274565b7f000000000000000000000000000000000000000000000000000000000000000083169216821461388c565b8114614350575063ffffffff6201518091356143398161296f565b161061434157565b630596b15b60e01b5f5260045ffd5b63abfa558d60e01b5f5260045260245ffd5b903590601e198136030182121561028557018035906001600160401b0382116102855760200191813603831361028557565b9091612572936143c46143d2926143b9836143b3610220890189614362565b90613b99565b90888894939461548c565b6143b3610240850185614362565b91937f00000000000000000000000000000000000000000000000000000000000000009361548c565b9060146001600160401b039161440f613ddf565b935f525f60205260405f209061442960ff835416866138b8565b614435600583016126c6565b6020860152601382015460408601526060850152015416608082015290565b9060a060039163ffffffff81511663ffffffff198554161784556001600160a01b03602082015116640100000000600160c01b0385549160201b1690640100000000600160c01b03191617845561451e600185016144e86144bf60408501516001600160a01b031690565b825473ffffffffffffffffffffffffffffffffffffffff19166001600160a01b03909116178255565b60608301516001600160401b0316815467ffffffffffffffff60a01b191660a09190911b67ffffffffffffffff60a01b16179055565b608081015160028501550151910155565b9261456b816145ba9460a09461454c885f525f60205260405f2090565b97614558895460ff1690565b614561816121a4565b1561463057614ed0565b60408101805161457a816121a4565b614583816121a4565b151580614605575b6145eb575b5060148401805460608301516001600160401b0390811691168190036145c9575b50500151151590565b6145c15750565b60135f910155565b815467ffffffffffffffff19166001600160401b039091161790555f806145b1565b6145ff90516145f9816121a4565b85613124565b5f614590565b50845460ff16815190614617826121a4565b614620826121a4565b614629816121a4565b141561458b565b61463d8260018b01614454565b614ed0565b906001600160401b0360405191602083019384521660408201526040815261466b606082612542565b51902090565b805f525f60205260ff60405f2054166006811015611ba75780159081156146b9575b506146b4575f525f6020526001600160401b03600760405f20015416461490565b505f90565b600591506146c6816121a4565b145f614693565b9061471f91805f525f6020526146e8600160405f2001612f3f565b60c0836147046146fd610884608084016129fc565b54856143fb565b604051632a2d120f60e21b8152968792839260048401612c22565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af492831561049957612572945f9461475c575b50614756903690612d26565b9161452f565b61475691945061477a9060c03d60c011610a6157610a538183612542565b939061474a565b90614794929161478f615237565b6147a7565b60015f5160206158dc5f395f51905f5255565b91909181156148d3576001600160a01b038316928361484b576001600160a01b038216925f8080808488620186a0f16147de613921565b50156147eb575050505050565b61482e613592926123c17fbf182be802245e8ed88e4b8d3e4344c0863dd2a70334f089fd07265389306fcf956001600160a01b03165f52600860205260405f2090565b614839828254613066565b90556040519081529081906020820190565b61485d61485984848461561b565b1590565b614868575b50505050565b816148b16001600160a01b03926123c17fbf182be802245e8ed88e4b8d3e4344c0863dd2a70334f089fd07265389306fcf956001600160a01b03165f52600860205260405f2090565b6148bc858254613066565b90556040519384521691602090a35f808080614862565b505050565b905f52600560205260405f20541590816148f0575090565b6107f79150614671565b61471f926146e86149176001610cbc855f525f60205260405f2090565b916080830151908585614394565b61492d613ddf565b905f5260056020526001600160401b03600360405f2060ff60018201541661495481611b9d565b8452614962600582016126c6565b60208501526004810154604085015201541660608201525f608082015290565b9061498b613ddf565b915f5260056020526001600160401b03600360405f2060ff6001820154166149b281611b9d565b85526149c0600582016126c6565b6020860152600481015460408601520154166060830152608082015290565b6020939291613f18916149fa815f52600560205260405f2090565b97604086018051614a0a81611b9d565b614a1381611b9d565b614a93575b5087856080880194614a2a8651151590565b614a80575b5050505050614a4860608501516001600160401b031690565b6001600160401b038116614a5d575051151590565b61411d9060038901906001600160401b03166001600160401b0319825416179055565b614a89946156af565b5f80878582614a2f565b614aa1905161416d81611b9d565b5f614a18565b9160ff6001600160a01b03928360405195466020880152166040860152166060840152166080820152608081526107f760a082612542565b805192835f9472184f03e93ff9f4daa797ed6e38ed64bf6a1f0160401b821015614c69575b806d04ee2d6d415b85acef8100000000600a921015614c4d575b662386f26fc10000811015614c38575b6305f5e100811015614c26575b612710811015614c16575b6064811015614c07575b1015614bfc575b614b936021614b686001880161575b565b968701015b5f1901916f181899199a1a9b1b9c1cb0b131b232b360811b600a82061a8353600a900490565b908115614ba357614b9390614b6d565b50506001600160a01b03614bc884614bbc8584986156ef565b60208151910120615745565b911693168314614bf457614be6918160206112dc9351910120615745565b14614bef575f90565b600190565b505050600190565b600190940193614b57565b60029060649004960195614b50565b6004906127109004960195614b46565b6008906305f5e1009004960195614b3b565b601090662386f26fc100009004960195614b2e565b6020906d04ee2d6d415b85acef81000000009004960195614b1e565b506040945072184f03e93ff9f4daa797ed6e38ed64bf6a1f0160401b8104614b04565b90600a811015611ba75760ff60401b82549160401b169060ff60401b1916179055565b8151815460208401516040808601516001600160401b039094166001600160e81b031990931692909217911b7bffffffffffffffffffffffffffffffffffffffff0000000000000000161760e09190911b60ff60e01b16178155606082015160018201556080820151600282015560a0820151600382015560c090910151600490910155565b601f8211614d4257505050565b5f5260205f20906020601f840160051c83019310614d7a575b601f0160051c01905b818110614d6f575050565b5f8155600101614d64565b9091508190614d5b565b91909182516001600160401b0381116107ad57614dab81614da584546125f2565b84614d35565b6020601f8211600114614de6578190612ef29394955f92614ddb575b50508160011b915f199060031b1c19161790565b015190505f80614dc7565b601f19821690614df9845f5260205f2090565b915f5b818110614e3357509583600195969710614e1b575b505050811b019055565b01515f1960f88460031b161c191690555f8080614e11565b9192602060018192868b015181550194019201614dfc565b8151815467ffffffffffffffff19166001600160401b0391909116178155602082015191600a831015611ba75760c0600d91614e8a6125729585614c8c565b60408101516001850155614ea5606082015160028601614caf565b614eb6608082015160078601614caf565b614ec760a0820151600c8601614d84565b01519101614d84565b60206060614f1082614eef614f21959896985f525f60205260405f2090565b97614efd6080880151151590565b615090575b01516001600160a01b031690565b94015101516001600160a01b031690565b809282515f8113615065575b50602083019283515f8113614fe4575b5051905f8212614fbc575b505050515f8112614f5f575b50505061257261417c565b5f5160206158bc5f395f51905f5291614f8261402e6001600160a01b0393613e74565b614fa6601361405783613636866001600160a01b03165f52600660205260405f2090565b90556040519384521691602090a25f8080614f54565b6140b361402e614fcb93613e74565b614fda60138501918254613073565b9055815f80614f48565b614fed90615428565b61500c81613fb5866001600160a01b03165f52600660205260405f2090565b9081615029866001600160a01b03165f52600660205260405f2090565b5561503960138901918254613066565b90556040519081526001600160a01b038416905f5160206158bc5f395f51905f5290602090a25f614f3d565b61506e90615428565b6150798184846150a2565b61508860138701918254613066565b90555f614f2d565b61509d8860058b01614e4b565b614f02565b9061479492916150b0615237565b6150cb565b156150bc57565b636956f2ab60e11b5f5260045ffd5b9082156148d3576001600160a01b0316918215801561517d576150ef8234146150b5565b156150f957505050565b6001600160a01b03604051926323b872dd60e01b5f52166004523060245260445260205f60648180865af160015f511481161561515e575b6040919091525f606052156151435750565b635274afe760e01b5f526001600160a01b031660045260245ffd5b6001811516615174573d15833b15151616615131565b503d5f823e3d90fd5b61518734156150b5565b6150ef565b6001015460ff1661519c81611b9d565b600381149081156151ab575090565b600291506151b881611b9d565b1490565b6001600160401b0360038201541642101590816151d7575090565b600180925060ff910154166151b881611b9d565b9061479492916151f9615237565b919081156148d3576001600160a01b0316918261522e5761257292505f808080856001600160a01b0386165af1612421613921565b6125729261526f565b60025f5160206158dc5f395f51905f5254146152605760025f5160206158dc5f395f51905f5255565b633ee5aeb560e01b5f5260045ffd5b916001600160a01b036040519263a9059cbb60e01b5f521660045260245260205f60448180865af160015f51148116156152b2575b604091909152156151435750565b6001811516615174573d15833b151516166152a4565b905f52600260205260405f20541590816148f0575090565b6001600160401b03815116906020810151600a811015611ba75761533682604061534194015161532760806060840151930151946040519760208901526040880190611bb6565b60608601526080850190611bc3565b610160830190611bc3565b61022081526107f761024082612542565b9190915f52600260205260405f2091825560058201926153926001600160401b0383511685906001600160401b03166001600160401b0319825416179055565b602082015193600a851015611ba75760c0615424936153b66002976153fe94614c8c565b604081015160068701556153d1606082015160078801614caf565b6153e26080820151600c8801614caf565b6153f360a082015160118801614d84565b015160128501614d84565b6001830190610100600160a81b0382549160081b1690610100600160a81b031916179055565b0155565b5f81126154325790565b635467221960e11b5f5260045260245ffd5b90604051918281549182825260208201905f5260205f20925f5b81811061547357505061257292500383612542565b845483526001948501948794506020909301920161545e565b6001600160a01b0390613d686154b26154ad60209895999697993690612d26565b6152e0565b936040519889978896879563600109bb60e01b875260048701613cad565b6001810190825f528160205260405f2054155f14615533578054600160401b8110156107ad5761552061550a826001879401855584612ec4565b819391549060031b91821b915f19901b19161790565b905554915f5260205260405f2055600190565b5050505f90565b80548015615561575f1901906155508282612ec4565b8154905f199060031b1b1916905555565b634e487b7160e01b5f52603160045260245ffd5b6001810191805f528260205260405f2054928315155f14615613575f198401848111611a445783545f19810194908511611a44575f9585836155d0976155c395036155d6575b50505061553a565b905f5260205260405f2090565b55600190565b6155fc6155f6916155ed61059d61560a9588612ec4565b92839187612ec4565b90612ed9565b85905f5260205260405f2090565b555f80806155bb565b505050505f90565b60405163a9059cbb60e01b602082019081526001600160a01b03939093166024820152604480820194909452928352915f91829161565a606482612542565b51908285620186a0f19061566c613921565b91156156a95781519081156156a05750602081101561568b5750505f90565b81602091810103126102855760200151151590565b9150503b151590565b50505f90565b9190915f52600560205260405f2091825560058201926153926001600160401b0383511685906001600160401b03166001600160401b0319825416179055565b6125729061573761573194936040519586937f19457468657265756d205369676e6564204d6573736167653a0a0000000000006020860152603a850190613c82565b90613c82565b03601f198101845283612542565b6107f79161575291615783565b909291926157bd565b9061576582612cba565b6157726040519182612542565b828152809261305c601f1991612cba565b81519190604183036157b3576157ac9250602082015190606060408401519301515f1a90615839565b9192909190565b50505f9160029190565b6157c681611b9d565b806157cf575050565b6157d881611b9d565b600181036157ef5763f645eedf60e01b5f5260045ffd5b6157f881611b9d565b60028103615813575063fce698f760e01b5f5260045260245ffd5b8061581f600392611b9d565b146158275750565b6335e2f38360e21b5f5260045260245ffd5b91907f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a084116158b0579160209360809260ff5f9560405194855216868401526040830152606082015282805260015afa15610499575f516001600160a01b038116156158a657905f905f90565b505f906001905f90565b5050505f916003919056fe05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00a2646970667358221220a570aef58c98bd9abf8f1bffbdf7d77776f6b91090e2ebdca3a8bced81d9fd7564736f6c634300081e0033", + ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"_defaultSigValidator\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"},{\"name\":\"_node\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"DEFAULT_SIG_VALIDATOR\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"ESCROW_DEPOSIT_UNLOCK_DELAY\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MAX_CHALLENGE_DURATION\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MAX_DEPOSIT_ESCROW_STEPS\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"MIN_CHALLENGE_DURATION\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint32\",\"internalType\":\"uint32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"NODE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"TRANSFER_GAS_LIMIT\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"VALIDATOR_ACTIVATION_DELAY\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"VERSION\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"challengeChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengerSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"challengerIdx\",\"type\":\"uint8\",\"internalType\":\"enumParticipantIndex\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"challengeEscrowDeposit\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"challengerSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"challengerIdx\",\"type\":\"uint8\",\"internalType\":\"enumParticipantIndex\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"challengeEscrowWithdrawal\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"challengerSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"challengerIdx\",\"type\":\"uint8\",\"internalType\":\"enumParticipantIndex\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"checkpointChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"claimFunds\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"destination\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"closeChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"createChannel\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"initState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"depositToChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"depositToNode\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"escrowHead\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"finalizeEscrowDeposit\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"finalizeEscrowWithdrawal\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"finalizeMigration\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"getChannelData\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumChannelStatus\"},{\"name\":\"definition\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"lastState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpiry\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"lockedFunds\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getChannelIds\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEscrowDepositData\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumEscrowStatus\"},{\"name\":\"unlockAt\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"challengeExpiry\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"lockedAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"initState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEscrowDepositIds\",\"inputs\":[{\"name\":\"page\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"pageSize\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[{\"name\":\"ids\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getEscrowWithdrawalData\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"status\",\"type\":\"uint8\",\"internalType\":\"enumEscrowStatus\"},{\"name\":\"challengeExpiry\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"lockedAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"initState\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNodeBalance\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getNodeValidator\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"}],\"outputs\":[{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"},{\"name\":\"registeredAt\",\"type\":\"uint64\",\"internalType\":\"uint64\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getOpenChannels\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"openChannels\",\"type\":\"bytes32[]\",\"internalType\":\"bytes32[]\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getReclaimBalance\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getUnlockableEscrowDepositStats\",\"inputs\":[],\"outputs\":[{\"name\":\"count\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"totalAmount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"initiateEscrowDeposit\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"payable\"},{\"type\":\"function\",\"name\":\"initiateEscrowWithdrawal\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"initiateMigration\",\"inputs\":[{\"name\":\"def\",\"type\":\"tuple\",\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"purgeEscrowDeposits\",\"inputs\":[{\"name\":\"maxSteps\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"registerNodeValidator\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"validator\",\"type\":\"address\",\"internalType\":\"contractISignatureValidator\"},{\"name\":\"signature\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawFromChannel\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"withdrawFromNode\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"ChannelChallenged\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpireAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelCheckpointed\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelClosed\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"finalState\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelCreated\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"definition\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structChannelDefinition\",\"components\":[{\"name\":\"challengeDuration\",\"type\":\"uint32\",\"internalType\":\"uint32\"},{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"node\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"nonce\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"approvedSignatureValidators\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"name\":\"initialState\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelDeposited\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ChannelWithdrawn\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"candidate\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Deposited\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositChallenged\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpireAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositFinalized\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositFinalizedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositInitiated\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositInitiatedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowDepositsPurged\",\"inputs\":[{\"name\":\"escrowIds\",\"type\":\"bytes32[]\",\"indexed\":false,\"internalType\":\"bytes32[]\"},{\"name\":\"purgedCount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalChallenged\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]},{\"name\":\"challengeExpireAt\",\"type\":\"uint64\",\"indexed\":false,\"internalType\":\"uint64\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalFinalized\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalFinalizedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalInitiated\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"EscrowWithdrawalInitiatedOnHome\",\"inputs\":[{\"name\":\"escrowId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"FundsClaimed\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"destination\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationInFinalized\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationInInitiated\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationOutFinalized\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"MigrationOutInitiated\",\"inputs\":[{\"name\":\"channelId\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"state\",\"type\":\"tuple\",\"indexed\":false,\"internalType\":\"structState\",\"components\":[{\"name\":\"version\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"intent\",\"type\":\"uint8\",\"internalType\":\"enumStateIntent\"},{\"name\":\"metadata\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"homeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"nonHomeLedger\",\"type\":\"tuple\",\"internalType\":\"structLedger\",\"components\":[{\"name\":\"chainId\",\"type\":\"uint64\",\"internalType\":\"uint64\"},{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decimals\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"userAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"userNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"},{\"name\":\"nodeAllocation\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"nodeNetFlow\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"name\":\"userSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"},{\"name\":\"nodeSig\",\"type\":\"bytes\",\"internalType\":\"bytes\"}]}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"NodeBalanceUpdated\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"TransferFailed\",\"inputs\":[{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"ValidatorRegistered\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"indexed\":true,\"internalType\":\"uint8\"},{\"name\":\"validator\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"contractISignatureValidator\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawn\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AddressCollision\",\"inputs\":[{\"name\":\"collision\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ChallengerVersionTooLow\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureLength\",\"inputs\":[{\"name\":\"length\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"ECDSAInvalidSignatureS\",\"inputs\":[{\"name\":\"s\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"EmptySignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectAmount\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectChallengeDuration\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectChannelId\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectChannelStatus\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectMsgSender\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectNode\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectSignature\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectStateIntent\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"IncorrectValue\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBalance\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAddress\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidValidatorId\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NativeTransferFailed\",\"inputs\":[{\"name\":\"to\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"NoChannelIdFoundForEscrow\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeCastOverflowedIntToUint\",\"inputs\":[{\"name\":\"value\",\"type\":\"int256\",\"internalType\":\"int256\"}]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"ValidatorAlreadyRegistered\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"}]},{\"type\":\"error\",\"name\":\"ValidatorNotActive\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"},{\"name\":\"activatesAt\",\"type\":\"uint64\",\"internalType\":\"uint64\"}]},{\"type\":\"error\",\"name\":\"ValidatorNotApproved\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ValidatorNotRegistered\",\"inputs\":[{\"name\":\"validatorId\",\"type\":\"uint8\",\"internalType\":\"uint8\"}]}]", + Bin: "0x60c03461010b57601f615ee238819003918201601f19168301916001600160401b0383118484101761010f57808492604094855283398101031261010b5780516001600160a01b0381169182820361010b5760200151916001600160a01b0383169081840361010b5760017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055156100fc57156100fc5760805260a052604051615dbe908161012482396080518181816111910152613ed3015260a051818181610c5c01528181610d790152818161145001528181611a3e0152818161207d0152818161361d015281816140800152818161464901526147510152f35b63e6c4247b60e01b5f5260045ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffdfe60806040526004361015610011575f80fd5b5f3560e01c806307f241ce1461027f57806316b390b11461027a578063187576d8146102755780633115f6301461027057806338a66be21461026b5780633c684f921461026657806341b660ef1461026157806347de477a1461025c57806351bfcdbd1461025757806353269198146102525780635a0745b41461024d5780635ae2accc146102485780635b9acbf9146102435780635dc46a741461023e5780636840dbd2146102395780636898234b1461023457806371a471411461022f578063735181f01461022a57806382d3e15d146102255780638d0b12a5146102205780638e31c7351461021b57806394191051146102115780639691b46814610216578063a459463114610211578063a5c826801461020c578063b25a1d3814610207578063b65b78d114610202578063b9f4420d146101fd578063c74a2d10146101f8578063c9408398146101f3578063d888ccae146101ee578063d91a1283146101e9578063dc23f29e146101e4578063dd73d494146101df578063e617208c146101da578063f4ac51f5146101d5578063f766f8d6146101d0578063ff5bc09e146101cb5763ffa1ad74146101c6575f80fd5b6126ae565b612697565b612578565b6124fd565b61245f565b6122e5565b61212e565b612012565b611f09565b611c7a565b611bfa565b611bdd565b611aee565b611770565b611611565b6114e7565b611504565b611384565b61123d565b611220565b6111da565b611172565b611093565b61107c565b611031565b610ffb565b610fe0565b610fc4565b610dcc565b610d5a565b610b96565b610870565b6107ad565b610772565b61057b565b6104f5565b610351565b610299565b6001600160a01b0381160361029557565b5f80fd5b34610295576020366003190112610295576001600160a01b036004356102be81610284565b165f526006602052602060405f2054604051908152f35b9181601f84011215610295578235916001600160401b038311610295576020838186019501011161029557565b60643590600282101561029557565b9060606003198301126102955760043591602435906001600160401b03821161029557610340916004016102d5565b909160443560028110156102955790565b34610295576103b36103ed61036536610311565b9294916103c8610380879693965f52600260205260405f2090565b9485549261038f8415156126c9565b600187015460059060081c6001600160a01b031696879260028a01549a8b91613eb2565b9192909901986103c28a6128e3565b87613fe3565b60c06103d3876140d5565b604051809481926301999b9360e61b835260048301612a53565b038173__$682d6198b4eca5bc7e038b912a26498e7e$__5af480156104a9577fba075bd445233f7cad862c72f0343b3503aad9c8e704a2295f122b82abf8e80195610461946080945f93610476575b5082610453939461044c896128e3565b908b614149565b01516001600160401b031690565b9061047160405192839283612b8e565b0390a2005b610453935061049c9060c03d60c0116104a2575b610494818361275f565b810190612991565b9261043c565b503d61048a565b612a64565b90602080835192838152019201905f5b8181106104cb5750505090565b82518452602093840193909201916001016104be565b9060206104f29281815201906104ae565b90565b34610295576020366003190112610295576001600160a01b0360043561051a81610284565b165f52600160205260405f206040519081602082549182815201915f5260205f20905f5b81811061056557610561856105558187038261275f565b604051918291826104e1565b0390f35b825484526020909301926001928301920161053e565b3461029557602036600319011261029557600354600480545f92918390358284111561076c576105ab838561332c565b8082101561075e57506105c28195949392956132ed565b925b80831080610755575b15610748576105e86105de84613145565b90549060031b1c90565b6106036105fd825f52600260205260405f2090565b966139b6565b9561060d81615559565b6107335761061a81615589565b156106e3576001600160a01b036106cb6105fd600198999a6106ab955f866106ba610661600c5f516020615d695f395f51905f529a01546001600160a01b039060401c1690565b9d8e9261067f846001600160a01b03165f52600660205260405f2090565b5493610691600483019586549061331f565b9c8d916001600160a01b03165f52600660205260405f2090565b5501805460ff19166003179055565b556106c5828d613339565b526139b6565b604051938452961691602090a25b94939291946105c4565b505050506106f391939250600455565b806106fa57005b81817f8fac6141d748dc9c9bc16cc25f636385597618190a44c03d33be5656e01b3642935261072e60405192839283614462565b0390a1005b505092939491610742906139b6565b926106d9565b50506004559190506106f3565b508185106105cd565b6105c29095949392956132ed565b5f6105ab565b34610295575f366003190112610295576020604051620186a08152f35b6004359060ff8216820361029557565b359060ff8216820361029557565b346102955760203660031901126102955760ff6107c861078f565b165f52600760205260405f2060405160408101918183106001600160401b03841117610826576040928352546001600160a01b03811680835260a09190911c6001600160401b03166020928301819052835191825291810191909152f35b6126de565b90816102609103126102955790565b90600319820160e081126102955760c0136102955760049160c435906001600160401b038211610295576104f29160040161082b565b6108793661083a565b60208101600261088882612bbf565b61089181611d68565b148015610b7b575b8015610b5d575b6108a990612bc9565b60026108b482612bbf565b6108bd81611d68565b03610b4e575b6109a36109016108d33686612c0e565b60c090207effffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff16600160f81b1790565b9261092f610920610919865f525f60205260405f2090565b5460ff1690565b610929816123bb565b15612c82565b61093b60208601612c98565b906109458661460e565b610955608087013583838861470f565b60a08161098861098161096a60808401612c98565b6001600160a01b03165f52600660205260405f2090565b5488614776565b604051632a2d120f60e21b8152958692839260048401612ec0565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49081156104a9577fb0d099feaab5034d04a1c610e86b8832343f2127b3c667b705834dafdf96e9e494610a18610a99936001600160a01b03965f91610b1f575b50610a07368b612c0e565b610a113686612fc4565b908a6148c2565b610a3c87610a37866001600160a01b03165f52600160205260405f2090565b61598d565b506002610a4882612bbf565b610a5181611d68565b03610a9e5750857f6085f5128b19e0d3cc37524413de47259383f0f75265d5d66f4177869620669660405180610a878582613070565b0390a25b604051938493169683613081565b0390a3005b610aa9600391612bbf565b610ab281611d68565b03610aef57857f188e0ade7d115cc397426774adb960ae3e8c83e72f0a6cad4b7085e1d60bf98660405180610ae78582613070565b0390a2610a8b565b857f567044ba1cdd4671ac3979c114241e1e3b56c9e9051f63f2f234f7a2795019cc60405180610ae78582613070565b610b41915060a03d60a011610b47575b610b39818361275f565b810190612ca2565b5f6109fc565b503d610b2f565b610b583415612bdf565b6108c3565b506108a9610b6a82612bbf565b610b7381611d68565b1590506108a0565b506003610b8782612bbf565b610b9081611d68565b14610899565b610b9f3661083a565b90610bc06004610bb160208501612bbf565b610bba81611d68565b14612bc9565b610bc98161460e565b610bd66108d33683612c0e565b916080610be560208401612c98565b92013591610bf58382848761470f565b610c19610c0183613110565b85906001600160401b03915f521660205260405f2090565b92610c23856149d5565b15610ca3575050610a997f471c4ebe4e57d25ef7117e141caac31c6b98f067b8098a7a7bbd38f637c2f98091610c836001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001633146131e2565b610c8d3415612bdf565b610c978186614a31565b60405191829182613070565b9091610ccf60c082610cb4876140d5565b604051632ef10bcd60e21b815293849283926004840161311a565b038173__$682d6198b4eca5bc7e038b912a26498e7e$__5af49283156104a9577fede7867afa7cdb9c443667efd8244d98bf9df1dce68e60dc94dca6605125ca7694610a9994610d32935f91610d3b575b50610d2b3686612fc4565b8989614149565b610c9784613194565b610d54915060c03d60c0116104a257610494818361275f565b5f610d20565b34610295575f3660031901126102955760206040516001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000168152f35b9060406003198301126102955760043591602435906001600160401b038211610295576104f29160040161082b565b3461029557610dda36610d9d565b610deb6009610bb160208401612bbf565b610e076001610e01845f525f60205260405f2090565b016131f8565b610ea2610e1e60208301516001600160a01b031690565b91610e2f608082015184868861470f565b610e393685612fc4565b61014085019386610e4986613110565b6001600160401b031646149586610f5c575b50505060a081610e87610e8061096a60206060850151016001600160a01b0390511690565b5489614776565b604051632a2d120f60e21b8152958692839260048401613282565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49182156104a957610ed4935f93610f3b575b50866148c2565b15610f0a576104717f9a6f675cc94b83b55f1ecc0876affd4332a30c92e6faa2aca0199b1b6df922c39160405191829182613070565b6104717f7b20773c41402791c5f18914dbbeacad38b1ebcc4c55d8eb3bfe0a4cde26c8269160405191829182613070565b610f5591935060a03d60a011610b4757610b39818361275f565b915f610ecd565b610fbb92610f6e610fb6923690612ee5565b6060860152610f803660608b01612ee5565b6080860152610f8d61326e565b60a0860152610f9a61326e565b60c08601526001600160a01b03165f52600160205260405f2090565b615a37565b505f8681610e5b565b34610295575f366003190112610295576020604051612a308152f35b34610295575f36600319011261029557602060405160408152f35b346102955760403660031901126102955761056161101d60243560043561334d565b6040519182916020835260208301906104ae565b346102955761104861104236610d9d565b90613406565b005b6060600319820112610295576004359160243591604435906001600160401b038211610295576104f29160040161082b565b346102955761104861108d3661104a565b91613756565b34610295576020366003190112610295576001600160a01b036004356110b881610284565b165f5260016020526110cc60405f20615901565b5f905f5b815181101561115f576110f76109196110e98385613339565b515f525f60205260405f2090565b611100816123bb565b6003811415908161114a575b5061111a575b6001016110d0565b9161112d818460019310611135576139b6565b929050611112565b61113f8585613339565b516106c58286613339565b60059150611157816123bb565b14155f61110c565b50610561918152604051918291826104e1565b34610295575f3660031901126102955760206040516001600160a01b037f0000000000000000000000000000000000000000000000000000000000000000168152f35b6040906003190112610295576004356111cd81610284565b906024356104f281610284565b346102955760206112176001600160a01b036111f5366111b5565b91165f526008835260405f20906001600160a01b03165f5260205260405f2090565b54604051908152f35b34610295575f366003190112610295576020600454604051908152f35b346102955761124b36610311565b611297611263859493945f52600560205260405f2090565b918254946112728615156126c9565b60a061127d88614c71565b604051809581926312031f5d60e11b8352600483016139c4565b038173__$b69fb814c294bfc16f92e50d7aeced4bde$__5af49081156104a9577fb8568a1f475f3c76759a620e08a653d28348c5c09e2e0bc91d533339801fefd8966103c296610461966060965f95611341575b50916113318596610453969385600561131560016113259901546001600160a01b039060081c1690565b97889360028401549a8b91613eb2565b92909193019e8f6128e3565b61133a896128e3565b908b614d2b565b6104539550611325939192966113716113319260a03d60a01161137d575b611369818361275f565b8101906136a5565b965096929193506112eb565b503d61135f565b346102955760603660031901126102955761139d61078f565b6024356113a981610284565b6044356001600160401b038111610295576114bc916113cf6114c19236906004016102d5565b93909461148261147d60ff8316966113e88815156139d5565b6001600160a01b038616986113fe8a15156139eb565b61143f8561143961142d61142d6114208460ff165f52600760205260405f2090565b546001600160a01b031690565b6001600160a01b031690565b15613a01565b61147761144d8b8730614e62565b917f0000000000000000000000000000000000000000000000000000000000000000933691612f73565b90614e9a565b613a1f565b61149c61148d612780565b6001600160a01b039094168452565b426001600160401b0316602084015260ff165f52600760205260405f2090565b613a35565b7f9ee792368f12db92ad66335fa19df35feaec025c86445fea202ab5412a180e055f80a3005b34610295575f366003190112610295576020604051620151808152f35b346102955761158d61151536610d9d565b61153661152760208395949501612bbf565b61153081611d68565b15612bc9565b61154c6001610e01855f525f60205260405f2090565b9061157161156460208401516001600160a01b031690565b608084015190838761470f565b60a08161098861158661096a60808401612c98565b5487614776565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49283156104a9577f567044ba1cdd4671ac3979c114241e1e3b56c9e9051f63f2f234f7a2795019cc9361047193610c97925f926115f0575b506115e93685612fc4565b90876148c2565b61160a91925060a03d60a011610b4757610b39818361275f565b905f6115de565b346102955761161f3661083a565b906116316006610bb160208501612bbf565b61163a8161460e565b6116476108d33683612c0e565b91608061165660208401612c98565b920135916116668382848761470f565b611672610c0183613110565b9261167c856149d5565b156116b2575050610a9981610c977f587faad1bcd589ce902468251883e1976a645af8563c773eed7356d78433210c9386614a31565b90916116ee60a0826116d46116cd61096a6101608401612c98565b5488614cce565b60405162ea54e760e01b815293849283926004840161373f565b038173__$b69fb814c294bfc16f92e50d7aeced4bde$__5af49283156104a9577f17eb0a6bd5a0de45d1029ce3444941070e149df35b22176fc439f930f73c09f794610a9994610c97935f91611751575b5061174a3686612fc4565b8989614d2b565b61176a915060a03d60a01161137d57611369818361275f565b5f61173f565b6080366003190112610295576004356024356001600160401b0381116102955761179e90369060040161082b565b6044356001600160401b038111610295576117bd9036906004016102d5565b90916117c7610302565b926117d9855f525f60205260405f2090565b6117e5600182016131f8565b936117f1825460ff1690565b906117fb826123bb565b6001821495868015611adb575b61181190612c82565b61181d600585016128e3565b9261185b61182a88613110565b6001600160401b0361185261184688516001600160401b031690565b6001600160401b031690565b91161015613aa3565b60208201516001600160a01b0316978a6080840151956001600160401b036118966118466118888d613110565b93516001600160401b031690565b91161115611a8d57506118eb61192d9493926004926118d660208c01926118d160016118c186612bbf565b6118ca81611d68565b1415612bc9565b6123bb565b80611a6d575b6118e69015612bc9565b612bbf565b6118f481611d68565b1480611a3a575b61190590156131e2565b6119118489898d61470f565b60a08761098861192661096a60808401612c98565b548d614776565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49182156104a9577f07b9206d5a6026d3bd2a8f9a9b79f6fa4bfbd6a016975829fbaf07488019f28a996014996119bb8d8b6119af6119ee9a6119c197611a0c9e6119aa6119d69c6119df9e5f91611a1b575b506119a33688612fc4565b8d896152c4565b613eb2565b93919490923690612fc4565b90613fe3565b845460ff191660021785555163ffffffff1690565b63ffffffff1690565b6001600160401b034216613ad9565b9301805467ffffffffffffffff19166001600160401b038516179055565b61047160405192839283613af9565b611a34915060a03d60a011610b4757610b39818361275f565b5f611998565b50337f00000000000000000000000000000000000000000000000000000000000000006001600160a01b031614156118fb565b506118e66009611a7c83612bbf565b611a8581611d68565b1490506118dc565b6119d69392506119c19150996014996119bb7f07b9206d5a6026d3bd2a8f9a9b79f6fa4bfbd6a016975829fbaf07488019f28a9c8b6119af6119ee9a6119df9a611a0c9e6119aa3415612bdf565b50611ae5836123bb565b60048314611808565b604036600319011261029557600435611b0681610284565b6001600160a01b0360243591611b1d831515613b19565b611b25615604565b611b30838233615498565b60017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f00551690815f52600660205260405f205490808201809211611bd8575f516020615d695f395f51905f5291837f2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4611bc561047194835f5260066020528460405f2055604051918291829190602083019252565b0390a26040519081529081906020820190565b6132a7565b34610295575f36600319011261029557602060405162093a808152f35b3461029557611c1f611c0b36610d9d565b6115366003610bb160208496959601612bbf565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49283156104a9577f188e0ade7d115cc397426774adb960ae3e8c83e72f0a6cad4b7085e1d60bf9869361047193610c97925f926115f057506115e93685612fc4565b34610295575f36600319011261029557600354600454905f805b82841015611d3c577fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b8401545f90815260026020526040902091611cd783615559565b611d2a57611ce483615589565b15611d1357611d0a916004611cfb611d04936139b6565b9401549061331f565b936139b6565b915b9192611c94565b92509250505b604080519182526020820192909252f35b915092611d36906139b6565b91611d0c565b92509050611d19565b634e487b7160e01b5f52602160045260245ffd5b60041115611d6357565b611d45565b600a1115611d6357565b90600a821015611d635752565b805180835260209291819084018484015e5f828201840152601f01601f1916010190565b6104f2916001600160401b038251168152611dc660208301516020830190611d72565b60408201516040820152611e336060830151606083019060c080916001600160401b0381511684526001600160a01b03602082015116602085015260ff6040820151166040850152606081015160608501526080810151608085015260a081015160a08501520151910152565b60808281015180516001600160401b031661014084015260208101516001600160a01b0316610160840152604081015160ff1661018084015260608101516101a0840152908101516101c083015260a08101516101e083015260c0015161020082015260c0611eb460a0840151610260610220850152610260840190611d7f565b92015190610240818403910152611d7f565b92936001600160401b0360c0956104f298979482948752611ee681611d59565b602087015216604085015216606083015260808201528160a08201520190611da3565b3461029557602036600319011261029557600435611f25613b65565b505f52600260205260405f2060405190611f3e826126f2565b80548252610561600182015491611f89611f79611f5b8560ff1690565b94611f6a602088019687613ba9565b60081c6001600160a01b031690565b6001600160a01b03166040860152565b6002810154606085015260038101546001600160401b0380821660808701908152959160401c166001600160401b031660a0820190815291612001611888611fdf600560048501549460c08701958652016128e3565b9360e0810194855251965197611ff489611d59565b516001600160401b031690565b905191519260405196879687611ec6565b346102955760603660031901126102955760043561202f81610284565b5f516020615d695f395f51905f526104716024359261204d84610284565b604435936120656001600160a01b03831615156139eb565b612070851515613b19565b6120a46001600160a01b037f00000000000000000000000000000000000000000000000000000000000000001633146131e2565b7f7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d5611bc58661211e6001600160a01b038516988995865f5260066020526120fb8260405f20546120f682821015613bb5565b61332c565b9788612118836001600160a01b03165f52600660205260405f2090565b556155b8565b6040519081529081906020820190565b346102955761213c3661083a565b61214d6008610bb160208401612bbf565b61215a6108d33684612c0e565b916121bb61216a60208301612c98565b9161217b608082013584868861470f565b6121853685612fc4565b61218e866149d5565b93868515612284575b505060a081610e87610e8061096a60206060850151016001600160a01b0390511690565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49182156104a9576121f8935f9361225f575b506121f2903690612c0e565b866148c2565b1561222e576104717f3142fb397e715d80415dff7b527bf1c451def4675da6e1199ee1b4588e3f630a9160405191829182613070565b6104717f26afbcb9eb52c21f42eb9cfe8f263718ffb65afbf84abe8ad8cce2acfb2242b89160405191829182613070565b6121f291935061227d9060a03d60a011610b4757610b39818361275f565b92906121e6565b610a376122a2926122948661460e565b610f6e366101408b01612ee5565b505f86612197565b9160a0936001600160401b03916104f297969385526122c881611d59565b602085015216604083015260608201528160808201520190611da3565b3461029557602036600319011261029557600435612301613b65565b505f52600560205260405f206040519061231a8261270e565b80548252610561600182015491612351611f7960ff851694602087019561234081611d59565b865260081c6001600160a01b031690565b6002810154606085015260038101546001600160401b03166001600160401b031660808501908152936123aa612395600560048501549460a08501958652016128e3565b9160c0810192835251945195611ff487611d59565b9151905191604051958695866122aa565b60061115611d6357565b906006821015611d635752565b919260a0610120946123eb85612454959a99989a6123c5565b63ffffffff81511660208601526001600160a01b0360208201511660408601526001600160a01b0360408201511660608601526001600160401b036060820151166080860152608081015182860152015160c084015261014060e0840152610140830190611da3565b946101008201520152565b34610295576020366003190112610295576004355f60a060405161248281612729565b82815282602082015282604082015282606082015282608082015201526124a7613b65565b505f525f6020526124ba60405f20613bd7565b80516124c5816123bb565b61056160208301519260408101519060606124ed61184660808401516001600160401b031690565b91015191604051958695866123d2565b61251d61250936610d9d565b6115366002610bb160208496959601612bbf565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49283156104a9577f6085f5128b19e0d3cc37524413de47259383f0f75265d5d66f417786962066969361047193610c97925f926115f057506115e93685612fc4565b3461029557612586366111b5565b61258e615604565b6001600160a01b038116916125a48315156139eb565b6001600160a01b036125e1826125cb336001600160a01b03165f52600860205260405f2090565b906001600160a01b03165f5260205260405f2090565b54916125ee831515613b19565b5f61260e826125cb336001600160a01b03165f52600860205260405f2090565b551691818361268857612631915f808080858a5af161262b613c34565b50613c63565b60405190815233907f7b8d70738154be94a9a068a6d2f5dd8cfc65c52855859dc8f47de1ff185f8b5590602090a461104860017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b6126929184615662565b612631565b34610295576110486126a83661104a565b91613c8b565b34610295575f36600319011261029557602060405160018152f35b156126d057565b6287a33760e41b5f5260045ffd5b634e487b7160e01b5f52604160045260245ffd5b61010081019081106001600160401b0382111761082657604052565b60e081019081106001600160401b0382111761082657604052565b60c081019081106001600160401b0382111761082657604052565b60a081019081106001600160401b0382111761082657604052565b90601f801991011681019081106001600160401b0382111761082657604052565b6040519061278f60408361275f565b565b6040519061278f60e08361275f565b906040516127ad8161270e565b60c0600482946127ea60ff82546001600160401b03811687526001600160a01b03808260401c1616602088015260e01c16604086019060ff169052565b6001810154606085015260028101546080850152600381015460a08501520154910152565b90600182811c9216801561283d575b602083101461282957565b634e487b7160e01b5f52602260045260245ffd5b91607f169161281e565b5f92918154916128568361280f565b80835292600181169081156128ab575060011461287257505050565b5f9081526020812093945091925b838310612891575060209250010190565b600181602092949394548385870101520191019190612880565b915050602093945060ff929192191683830152151560051b010190565b9061278f6128dc9260405193848092612847565b038361275f565b906040516128f08161270e565b809260ff81546001600160401b038116845260401c1690600a821015611d6357600d6129619160c093602086015260018101546040860152612934600282016127a0565b6060860152612945600782016127a0565b6080860152612956600c82016128c8565b60a0860152016128c8565b910152565b5190600482101561029557565b6001600160401b0381160361029557565b5190811515820361029557565b908160c0910312610295576129f960a0604051926129ae84612729565b80518452602081015160208501526129c860408201612966565b604085015260608101516129db81612973565b606085015260808101516129ee81612973565b608085015201612984565b60a082015290565b908151612a0d81611d59565b815260806001600160401b0381612a33602086015160a0602087015260a0860190611da3565b946040810151604086015282606082015116606086015201511691015290565b9060206104f2928181520190612a01565b6040513d5f823e3d90fd5b90600d6104f292612a9781546001600160401b038116855260ff602086019160401c16611d72565b60018101546040840152612b036060840160028301600460c09160ff8082546001600160401b03811687526001600160a01b038160401c16602088015260e01c161660408501526001810154606085015260028101546080850152600381015460a08501520154910152565b60078101546001600160401b038116610140850152604081901c6001600160a01b031661016085015260e01c60ff1661018084015260088101546101a084015260098101546101c0840152600a8101546101e0840152600b810154610200840152610260610220840152612b7e6102608401600c8301612847565b9261024081850391015201612847565b906001600160401b03612bae602092959495604085526040850190612a6f565b9416910152565b600a111561029557565b356104f281612bb5565b15612bd057565b633226144f60e21b5f5260045ffd5b15612be657565b636956f2ab60e11b5f5260045ffd5b63ffffffff81160361029557565b359061278f82612973565b91908260c091031261029557604051612c2681612729565b60a08082948035612c3681612bf5565b84526020810135612c4681610284565b60208501526040810135612c5981610284565b60408501526060810135612c6c81612973565b6060850152608081013560808501520135910152565b15612c8957565b631e40ad6360e31b5f5260045ffd5b356104f281610284565b908160a09103126102955760405190612cba82612744565b80518252602081015160208301526040810151600681101561029557612cfb9160809160408501526060810151612cf081612973565b606085015201612984565b608082015290565b90612d0f8183516123c5565b60806001600160401b0381612d33602086015160a0602087015260a0860190611da3565b94604081015160408601526060810151606086015201511691015290565b359061278f82612bb5565b60c080916001600160401b038135612d7381612973565b1684526001600160a01b036020820135612d8c81610284565b16602085015260ff612da06040830161079f565b166040850152606081013560608501526080810135608085015260a081013560a08501520135910152565b9035601e19823603018112156102955701602081359101916001600160401b03821161029557813603831361029557565b908060209392818452848401375f828201840152601f01601f1916010190565b6104f2916001600160401b038235612e3381612973565b168152612e516020830135612e4781612bb5565b6020830190611d72565b60408201356040820152612e6b6060820160608401612d5c565b612e7d61014082016101408401612d5c565b612eb1612ea5612e91610220850185612dcb565b610260610220860152610260850191612dfc565b92610240810190612dcb565b91610240818503910152612dfc565b9091612ed76104f293604084526040840190612d03565b916020818403910152612e1c565b91908260e091031261029557604051612efd8161270e565b60c08082948035612f0d81612973565b84526020810135612f1d81610284565b6020850152612f2e6040820161079f565b6040850152606081013560608501526080810135608085015260a081013560a08501520135910152565b6001600160401b03811161082657601f01601f191660200190565b929192612f7f82612f58565b91612f8d604051938461275f565b829481845281830111610295578281602093845f960137010152565b9080601f83011215610295578160206104f293359101612f73565b9190916102608184031261029557612fda612791565b92612fe482612c03565b8452612ff260208301612d51565b60208501526040820135604085015261300e8160608401612ee5565b6060850152613021816101408401612ee5565b60808501526102208201356001600160401b0381116102955781613046918401612fa9565b60a08501526102408201356001600160401b038111610295576130699201612fa9565b60c0830152565b9060206104f2928181520190612e1c565b60e09060a06104f2949363ffffffff813561309b81612bf5565b1683526001600160a01b0360208201356130b481610284565b1660208401526001600160a01b0360408201356130d081610284565b1660408401526001600160401b0360608201356130ec81612973565b16606084015260808101356080840152013560a08201528160c08201520190612e1c565b356104f281612973565b9091612ed76104f293604084526040840190612a01565b634e487b7160e01b5f52603260045260245ffd5b60035481101561315d5760035f5260205f2001905f90565b613131565b805482101561315d575f5260205f2001905f90565b916131909183549060031b91821b915f19901b19161790565b9055565b60035468010000000000000000811015610826576001810160035560035481101561315d5760035f527fc2575a0e9e593c00f959f8c92f12db2869c3395a3b0502d05e2516446f71f85b0155565b156131e957565b6370a8bfcd60e11b5f5260045ffd5b9060405161320581612729565b60a0600382946001600160a01b03815463ffffffff8116865260201c16602085015261325d6001600160401b0360018301546001600160a01b03808216166040880152851c1660608601906001600160401b03169052565b600281015460808501520154910152565b6040519061327d60208361275f565b5f8252565b90916132996104f293604084526040840190612d03565b916020818403910152611da3565b634e487b7160e01b5f52601160045260245ffd5b6001600160401b0381116108265760051b60200190565b604051906132e160208361275f565b5f808352366020840137565b906132f7826132bb565b613304604051918261275f565b8281528092613315601f19916132bb565b0190602036910137565b91908201809211611bd857565b91908203918211611bd857565b805182101561315d5760209160051b010190565b91906003549080840293808504821490151715611bd857818410156133d157830190818411611bd8578082116133c9575b5061339161338c848361332c565b6132ed565b92805b8281106133a057505050565b806133af6105de600193613145565b6133c26133bc858461332c565b88613339565b5201613394565b90505f61337e565b505090506104f26132d2565b906006811015611d635760ff80198354169116179055565b9060206104f2928181520190611da3565b90613418825f525f60205260405f2090565b613424600182016131f8565b91613430825460ff1690565b918461343e600583016128e3565b91600261345560208801516001600160a01b031690565b9561345f816123bb565b148061364e575b6135755750505061347e6001610bb160208401612bbf565b61348e608084015183838761470f565b6134c160a0826134a661098161096a60808401612c98565b604051632a2d120f60e21b8152938492839260048401612ec0565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af480156104a957610fb661354f9461352b88937f04cd8c68bf83e7bc531ca5a5d75c34e36513c2acf81e07e6470ba79e29da13a898613542965f92613554575b506135243689612fc4565b90866148c2565b6001600160a01b03165f52600160205260405f2090565b5060405191829182613070565b0390a2565b61356e91925060a03d60a011610b4757610b39818361275f565b905f613519565b7f04cd8c68bf83e7bc531ca5a5d75c34e36513c2acf81e07e6470ba79e29da13a8955061364192935061354f946135d46014836135bc610fb695600360ff19825416179055565b5f601382015501805467ffffffffffffffff19169055565b61352b60608601613600815160606135f660208301516001600160a01b031690565b9101519085614ae5565b5160a061361760208301516001600160a01b031690565b910151907f0000000000000000000000000000000000000000000000000000000000000000614ae5565b50604051918291826133f5565b506014810154426001600160401b0390911610613466565b1561366d57565b6336c7a86b60e21b5f5260045ffd5b9061368681611d59565b60ff80198354169116179055565b9060206104f2928181520190612a6f565b908160a091031261029557612cfb6080604051926136c284612744565b80518452602081015160208501526136dc60408201612966565b60408501526060810151612cf081612973565b9081516136fb81611d59565b8152608080613719602085015160a0602086015260a0850190611da3565b93604081015160408501526001600160401b036060820151166060850152015191015290565b9091612ed76104f2936040845260408401906136ef565b916137618284614c4f565b61394d57613777825f52600560205260405f2090565b9061378484835414613666565b600182018054929060026137a7600886901c6001600160a01b03165b9560ff1690565b6137b081611d59565b1480613935575b61384e57506002906137d06007610bb160208601612bbf565b0154906137df8284838861470f565b6137ee60a0826116d487614c71565b038173__$b69fb814c294bfc16f92e50d7aeced4bde$__5af49283156104a9577f2fdac1380dbe23ae259b6871582b7f33e34461547f400bdd20d74991250317d19461384994610c97935f91611751575061174a3686612fc4565b0390a3565b805460ff191660031790557f2fdac1380dbe23ae259b6871582b7f33e34461547f400bdd20d74991250317d1925060059150600481015f815491556138a0600383016001600160401b03198154169055565b5f516020615d695f395f51905f526001600160a01b036138f36138d1600c8601546001600160a01b039060401c1690565b936138ed856001600160a01b03165f52600660205260405f2090565b5461331f565b9283613910826001600160a01b03165f52600660205260405f2090565b556040519384521691602090a261392561447e565b6138496040519283920182613694565b506003820154426001600160401b03909116106137b7565b613849816139836007610bb160207f6d0cf3d243d63f08f50db493a8af34b27d4e3bc9ec4098e82700abfeffe2d4989601612bbf565b610c8d613997865f525f60205260405f2090565b600181015460039060201c6001600160a01b031691015490838861470f565b5f198114611bd85760010190565b9060206104f29281815201906136ef565b156139dc57565b6306ee4dcd60e01b5f5260045ffd5b156139f257565b63e6c4247b60e01b5f5260045ffd5b15613a095750565b60ff906357470ffd60e01b5f521660045260245ffd5b15613a2657565b63c1606c2f60e01b5f5260045ffd5b6001600160401b03602061278f93613a7a6001600160a01b0382511685906001600160a01b031673ffffffffffffffffffffffffffffffffffffffff19825416179055565b0151825467ffffffffffffffff60a01b1916911660a01b67ffffffffffffffff60a01b16179055565b15613aaa57565b637d95736160e01b5f5260045ffd5b6001600160401b0362015180911601906001600160401b038211611bd857565b906001600160401b03809116911601906001600160401b038211611bd857565b906001600160401b03612bae602092959495604085526040850190612e1c565b15613b2057565b6334b2073960e11b5f5260045ffd5b60405190613b3c8261270e565b5f60c0838281528260208201528260408201528260608201528260808201528260a08201520152565b60405190613b728261270e565b606060c0835f81525f60208201525f6040820152613b8e613b2f565b83820152613b9a613b2f565b60808201528260a08201520152565b613bb282611d59565b52565b15613bbc57565b631e9acf1760e31b5f5260045ffd5b6006821015611d635752565b90604051613be481612744565b60806001600160401b0360148395613c0060ff82541686613bcb565b613c0c600182016131f8565b6020860152613c1d600582016128e3565b604086015260138101546060860152015416910152565b3d15613c5e573d90613c4582612f58565b91613c53604051938461275f565b82523d5f602084013e565b606090565b15613c6c575050565b6001600160a01b039063296c17bb60e21b5f521660045260245260445ffd5b91613c9682846156bb565b613e1c57613cac825f52600260205260405f2090565b90613cb984835414613666565b60018201805492906002613cd9600886901c6001600160a01b03166137a0565b613ce281611d59565b1480613df9575b613d7b5750600290613d026005610bb160208601612bbf565b015490613d118284838861470f565b613d2060c082610cb4876140d5565b038173__$682d6198b4eca5bc7e038b912a26498e7e$__5af49283156104a9577f1b92e8ef67d8a7c0d29c99efcd180a5e0d98d60ac41d52abbbb5950882c78e4e9461384994610c97935f91610d3b5750610d2b3686612fc4565b805460ff191660031790557f1b92e8ef67d8a7c0d29c99efcd180a5e0d98d60ac41d52abbbb5950882c78e4e9260059250613df19060048301905f82549255613dda600385016fffffffffffffffff0000000000000000198154169055565b600c84015460401c6001600160a01b031690614ae5565b61392561447e565b50600382015460401c6001600160401b03166001600160401b0342911610613ce9565b613849816139836005610bb160207f32e24720f56fd5a7f4cb219d7ff3278ae95196e79c85b5801395894a6f53466c9601612bbf565b15613e5957565b6306a41ced60e21b5f5260045ffd5b15613e705750565b60ff9063399eb60560e01b5f521660045260245ffd5b15613e8f575050565b9060ff6001600160401b039263975133f360e01b5f52166004521660245260445ffd5b9291908015613f8c57801561315d57613f0191843560f81c9081613f0557507f000000000000000000000000000000000000000000000000000000000000000094600101925f19909201919050565b9091565b600180613f1884613f1f949060ff161c90565b1614613e52565b613f7f613f378260ff165f52600760205260405f2090565b546001600160a01b0381169290613f6c90613f6790613f5884871515613e68565b60a01c6001600160401b031690565b613ab9565b906001600160401b038216421015613e86565b93600101915f1990910190565b63ac241e1160e01b5f5260045ffd5b90816020910312610295575190565b9392606093613fd56001600160a01b0394612bae949998998852608060208901526080880190611d7f565b918683036040880152612dfc565b9193929590613ff1906156d3565b916002821015611d63576020956001600160a01b039261407a5761402d905b604051635850a09b60e11b81529889978896879560048701613faa565b0392165afa80156104a95761278f915f9161404b575b501515613a1f565b61406d915060203d602011614073575b614065818361275f565b810190613f9b565b5f614043565b503d61405b565b5061402d7f0000000000000000000000000000000000000000000000000000000000000000614010565b604051906140b182612744565b5f6080838281526140c0613b65565b60208201528260408201528260608201520152565b6140dd6140a4565b905f5260026020526001600160401b0380600360405f2060ff60018201541661410581611d59565b8552614113600582016128e3565b6020860152600481015460408601520154818116606085015260401c1616608082015290565b600160ff1b8114611bd8575f0390565b936141b694602094939682614166835f52600260205260405f2090565b9860a08701956141768751151590565b156144495760808201518901516001600160a01b0316998a975b60408a018d81516141a081611d59565b6141a981611d59565b61442b575b505051151590565b614418575b50505050506141d460608401516001600160401b031690565b6001600160401b0381166143ef575b5060038601805460808501516001600160401b039081169160401c168190036143b8575b50505f8351135f1461436b576142299061422184516158e5565b92839161548a565b6142386004860191825461331f565b90555b0180515f8113156142d057505f516020615d695f395f51905f52916142686001600160a01b0392516158e5565b6142b960046142928361428c866001600160a01b03165f52600660205260405f2090565b5461332c565b96876142af866001600160a01b03165f52600660205260405f2090565b550191825461331f565b90556040519384521691602090a25b61278f61447e565b90505f81126142e2575b5050506142c8565b5f516020615d695f395f51905f529161430a6143056001600160a01b0393614139565b6158e5565b614355600461432e836138ed866001600160a01b03165f52600660205260405f2090565b968761434b866001600160a01b03165f52600660205260405f2090565b550191825461332c565b90556040519384521691602090a25f80806142da565b6143753415612bdf565b8251905f8212614388575b50505061423b565b61439761430561439f93614139565b928391614ae5565b6143ae6004860191825461332c565b9055825f80614380565b81546fffffffffffffffff0000000000000000191660409190911b6fffffffffffffffff0000000000000000161790555f80614207565b6144129060038801906001600160401b03166001600160401b0319825416179055565b5f6141e3565b614421946157eb565b5f808281806141bb565b600161444292519161443c83611d59565b0161367c565b5f8d6141ae565b600c8b015460401c6001600160a01b0316998a97614190565b9291906144796020916040865260408601906104ae565b930152565b6003546004545f928390828411156145e85761449a838561332c565b806040105f146145da57506144b4604095949392956132ed565b925b808310806145d0575b156145c2576144d06105de84613145565b6144e56105fd825f52600260205260405f2090565b956144ef81615559565b6145ad576144fc81615589565b1561455b576001600160a01b036145436105fd600198999a6106ab955f866106ba610661600c5f516020615d695f395f51905f529a01546001600160a01b039060401c1690565b604051938452961691602090a25b94939291946144b6565b5050509391925061456b90600455565b80614574575050565b81817f8fac6141d748dc9c9bc16cc25f636385597618190a44c03d33be5656e01b364293526145a860405192839283614462565b0390a1565b5050929394916145bc906139b6565b92614551565b509391925061456b90600455565b50604085106144bf565b6144b49095949392956132ed565b5f61449a565b356104f281612bf5565b156145ff57565b630596b15b60e01b5f5260045ffd5b6001600160a01b03602082013561462481610284565b166146308115156139eb565b6001600160a01b03604083013561464681610284565b817f00000000000000000000000000000000000000000000000000000000000000001691829116036146ce5781146146bc5750806201518063ffffffff61468f61278f946145ee565b161015908161469f575b506145f8565b62093a8091506146b363ffffffff916145ee565b1611155f614699565b63abfa558d60e01b5f5260045260245ffd5b6308ad910960e21b5f5260045ffd5b903590601e198136030182121561029557018035906001600160401b0382116102955760200191813603831361029557565b909161278f9361473f61474d926147348361472e6102208901896146dd565b90613eb2565b908888949394615949565b61472e6102408501856146dd565b91937f000000000000000000000000000000000000000000000000000000000000000093615949565b9060146001600160401b039161478a6140a4565b935f525f60205260405f20906147a460ff83541686613bcb565b6147b0600583016128e3565b6020860152601382015460408601526060850152015416608082015290565b9060a060039163ffffffff81511663ffffffff198554161784556001600160a01b036020820151167fffffffffffffffff0000000000000000000000000000000000000000ffffffff77ffffffffffffffffffffffffffffffffffffffff0000000086549260201b1691161784556148b16001850161488461485b60408501516001600160a01b031690565b82906001600160a01b031673ffffffffffffffffffffffffffffffffffffffff19825416179055565b6060830151815467ffffffffffffffff60a01b191660a09190911b67ffffffffffffffff60a01b16179055565b608081015160028501550151910155565b926148fe8161494d946080946148df885f525f60205260405f2090565b976148eb895460ff1690565b6148f4816123bb565b156149c3576152c4565b60408101805161490d816123bb565b614916816123bb565b151580614998575b61497e575b5060148401805460608301516001600160401b03908116911681900361495c575b50500151151590565b6149545750565b60135f910155565b815467ffffffffffffffff19166001600160401b039091161790555f80614944565b614992905161498c816123bb565b856133dd565b5f614923565b50845460ff168151906149aa826123bb565b6149b3826123bb565b6149bc816123bb565b141561491e565b6149d08260018b016147cf565b6152c4565b805f525f60205260ff60405f2054166006811015611d63578015908115614a1d575b50614a18575f525f6020526001600160401b03600760405f20015416461490565b505f90565b60059150614a2a816123bb565b145f6149f7565b90614a8391805f525f602052614a4c600160405f20016131f8565b60a083614a68614a6161096a60808401612c98565b5485614776565b604051632a2d120f60e21b8152968792839260048401612ec0565b038173__$c00a153e45d4e7ce60e0acf48b0547b51a$__5af49283156104a95761278f945f94614ac0575b50614aba903690612fc4565b916148c2565b614aba919450614ade9060a03d60a011610b4757610b39818361275f565b9390614aae565b90614af89291614af3615604565b614b1e565b60017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b9190918115614c4a576001600160a01b0383169283614bc2576001600160a01b038216925f8080808488620186a0f1614b55613c34565b5015614b62575050505050565b614ba5613849926125cb7fbf182be802245e8ed88e4b8d3e4344c0863dd2a70334f089fd07265389306fcf956001600160a01b03165f52600860205260405f2090565b614bb082825461331f565b90556040519081529081906020820190565b614bd4614bd0848484615add565b1590565b614bdf575b50505050565b81614c286001600160a01b03926125cb7fbf182be802245e8ed88e4b8d3e4344c0863dd2a70334f089fd07265389306fcf956001600160a01b03165f52600860205260405f2090565b614c3385825461331f565b90556040519384521691602090a35f808080614bd9565b505050565b905f52600560205260405f2054159081614c67575090565b6104f291506149d5565b614c796140a4565b905f5260056020526001600160401b03600360405f2060ff600182015416614ca081611d59565b8452614cae600582016128e3565b60208501526004810154604085015201541660608201525f608082015290565b90614cd76140a4565b915f5260056020526001600160401b03600360405f2060ff600182015416614cfe81611d59565b8552614d0c600582016128e3565b6020860152600481015460408601520154166060830152608082015290565b6020939291614db691614d46815f52600560205260405f2090565b97604086018051614d5681611d59565b614d5f81611d59565b614e45575b5087856080880194614d768651151590565b614e32575b505050505060038701614d9581546001600160401b031690565b60608601516001600160401b039081169116819003614e1057505051151590565b15614df757608001518201516001600160a01b031680935b8251905f821315614de857614229915061422184516158e5565b5f82126143885750505061423b565b50600c84015460401c6001600160a01b03168093614dce565b815467ffffffffffffffff19166001600160401b039091161790555f806141ae565b614e3b94615b4a565b5f80878582614d7b565b614e5c9051614e5381611d59565b60018b0161367c565b5f614d64565b9160ff6001600160a01b03928360405195466020880152166040860152166060840152166080820152608081526104f260a08261275f565b805192835f947a184f03e93ff9f4daa797ed6e38ed64bf6a1f010000000000000000821015615036575b806d04ee2d6d415b85acef8100000000600a92101561501a575b662386f26fc10000811015615005575b6305f5e100811015614ff3575b612710811015614fe3575b6064811015614fd4575b1015614fc9575b614f606021614f2860018801615c08565b968701015b5f1901917f3031323334353637383961626364656600000000000000000000000000000000600a82061a8353600a900490565b908115614f7057614f6090614f2d565b50506001600160a01b03614f9584614f89858498615b9c565b60208151910120615bf2565b911693168314614fc157614fb39181602061142d9351910120615bf2565b14614fbc575f90565b600190565b505050600190565b600190940193614f17565b60029060649004960195614f10565b6004906127109004960195614f06565b6008906305f5e1009004960195614efb565b601090662386f26fc100009004960195614eee565b6020906d04ee2d6d415b85acef81000000009004960195614ede565b50604094507a184f03e93ff9f4daa797ed6e38ed64bf6a1f0100000000000000008104614ec4565b90600a811015611d635768ff000000000000000082549160401b169068ff00000000000000001916179055565b8151815460208401516040808601516001600160401b039094167fffffff000000000000000000000000000000000000000000000000000000000090931692909217911b7bffffffffffffffffffffffffffffffffffffffff0000000000000000161760e09190911b60ff60e01b16178155606082015160018201556080820151600282015560a0820151600382015560c090910151600490910155565b601f821161513657505050565b5f5260205f20906020601f840160051c8301931061516e575b601f0160051c01905b818110615163575050565b5f8155600101615158565b909150819061514f565b91909182516001600160401b0381116108265761519f81615199845461280f565b84615129565b6020601f82116001146151da5781906131909394955f926151cf575b50508160011b915f199060031b1c19161790565b015190505f806151bb565b601f198216906151ed845f5260205f2090565b915f5b8181106152275750958360019596971061520f575b505050811b019055565b01515f1960f88460031b161c191690555f8080615205565b9192602060018192868b0151815501940192016151f0565b8151815467ffffffffffffffff19166001600160401b0391909116178155602082015191600a831015611d635760c0600d9161527e61278f958561505e565b6040810151600185015561529960608201516002860161508b565b6152aa60808201516007860161508b565b6152bb60a0820151600c8601615178565b01519101615178565b9161531360206152e1615305959694965f525f60205260405f2090565b956152f982606086015101516001600160a01b031690565b9586946005890161523f565b01516001600160a01b031690565b5f8351135f1461547b5761532783516158e5565b61533281848461548a565b6153416013870191825461331f565b90555b602083019283515f81136153fa575b5051905f82126153d2575b505050515f8112615375575b50505061278f61447e565b5f516020615d695f395f51905f52916153986143056001600160a01b0393614139565b6153bc601361432e836138ed866001600160a01b03165f52600660205260405f2090565b90556040519384521691602090a25f808061536a565b6143976143056153e193614139565b6153f06013850191825461332c565b9055815f8061535e565b615403906158e5565b6154228161428c866001600160a01b03165f52600660205260405f2090565b908161543f866001600160a01b03165f52600660205260405f2090565b5561544f6013890191825461331f565b90556040519081526001600160a01b038416905f516020615d695f395f51905f5290602090a25f615353565b6154853415612bdf565b615344565b90614af89291615498615604565b908215614c4a576001600160a01b0316918215801561554a576154bc823414612bdf565b156154c657505050565b6001600160a01b03604051926323b872dd60e01b5f52166004523060245260445260205f60648180865af160015f511481161561552b575b6040919091525f606052156155105750565b635274afe760e01b5f526001600160a01b031660045260245ffd5b6001811516615541573d15833b151516166154fe565b503d5f823e3d90fd5b6155543415612bdf565b6154bc565b6001015460ff1661556981611d59565b60038114908115615578575090565b6002915061558581611d59565b1490565b6001600160401b0360038201541642101590816155a4575090565b600180925060ff9101541661558581611d59565b90614af892916155c6615604565b91908115614c4a576001600160a01b031691826155fb5761278f92505f808080856001600160a01b0386165af161262b613c34565b61278f92615662565b60027f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0054146156535760027f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b633ee5aeb560e01b5f5260045ffd5b916001600160a01b036040519263a9059cbb60e01b5f521660045260245260205f60448180865af160015f51148116156156a5575b604091909152156155105750565b6001811516615541573d15833b15151616615697565b905f52600260205260405f2054159081614c67575090565b6001600160401b03815116906020810151600a811015611d635761577a8260406157da94015161571a60806060840151930151946040519760208901526040880190611d72565b6060860152608085019060c080916001600160401b0381511684526001600160a01b03602082015116602085015260ff6040820151166040850152606081015160608501526080810151608085015260a081015160a08501520151910152565b80516001600160401b031661016084015260208101516001600160a01b0316610180840152604081015160ff166101a084015260608101516101c084015260808101516101e084015260a081015161020084015260c00151610220830152565b61022081526104f26102408261275f565b9190915f52600260205260405f20918255600582019261582b6001600160401b0383511685906001600160401b03166001600160401b0319825416179055565b602082015193600a851015611d635760c06158e19361584f6002976158979461505e565b6040810151600687015561586a60608201516007880161508b565b61587b6080820151600c880161508b565b61588c60a082015160118801615178565b015160128501615178565b60018301907fffffffffffffffffffffff0000000000000000000000000000000000000000ff74ffffffffffffffffffffffffffffffffffffffff0083549260081b169116179055565b0155565b5f81126158ef5790565b635467221960e11b5f5260045260245ffd5b90604051918281549182825260208201905f5260205f20925f5b81811061593057505061278f9250038361275f565b845483526001948501948794506020909301920161591b565b6001600160a01b039061402d61596f61596a60209895999697993690612fc4565b6156d3565b936040519889978896879563600109bb60e01b875260048701613faa565b6001810190825f528160205260405f2054155f146159f557805468010000000000000000811015610826576159e26159cc826001879401855584613162565b819391549060031b91821b915f19901b19161790565b905554915f5260205260405f2055600190565b5050505f90565b80548015615a23575f190190615a128282613162565b8154905f199060031b1b1916905555565b634e487b7160e01b5f52603160045260245ffd5b6001810191805f528260205260405f2054928315155f14615ad5575f198401848111611bd85783545f19810194908511611bd8575f958583615a9297615a859503615a98575b5050506159fc565b905f5260205260405f2090565b55600190565b615abe615ab891615aaf6105de615acc9588613162565b92839187613162565b90613177565b85905f5260205260405f2090565b555f8080615a7d565b505050505f90565b60405163a9059cbb60e01b60208281019182526001600160a01b03909416602483015260448083019590955293815290925f91615b1b60648261275f565b51908285620186a0f15f51913d91156159f5578115615b415750602011614a1857151590565b9150503b151590565b9190915f52600560205260405f20918255600582019261582b6001600160401b0383511685906001600160401b03166001600160401b0319825416179055565b805191908290602001825e015f815290565b61278f90615be4615bde94936040519586937f19457468657265756d205369676e6564204d6573736167653a0a0000000000006020860152603a850190615b8a565b90615b8a565b03601f19810184528361275f565b6104f291615bff91615c30565b90929192615c6a565b90615c1282612f58565b615c1f604051918261275f565b8281528092613315601f1991612f58565b8151919060418303615c6057615c599250602082015190606060408401519301515f1a90615ce6565b9192909190565b50505f9160029190565b615c7381611d59565b80615c7c575050565b615c8581611d59565b60018103615c9c5763f645eedf60e01b5f5260045ffd5b615ca581611d59565b60028103615cc0575063fce698f760e01b5f5260045260245ffd5b80615ccc600392611d59565b14615cd45750565b6335e2f38360e21b5f5260045260245ffd5b91907f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08411615d5d579160209360809260ff5f9560405194855216868401526040830152606082015282805260015afa156104a9575f516001600160a01b03811615615d5357905f905f90565b505f906001905f90565b5050505f916003919056fe05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7cea264697066735822122038c58d6835b628f29d1179ead91c3ea8c111403cc9a295f112ffd3c5270cf61864736f6c634300081e0033", } // ChannelHubABI is the input ABI used to generate the binding from. @@ -295,6 +295,37 @@ func (_ChannelHub *ChannelHubCallerSession) ESCROWDEPOSITUNLOCKDELAY() (uint32, return _ChannelHub.Contract.ESCROWDEPOSITUNLOCKDELAY(&_ChannelHub.CallOpts) } +// MAXCHALLENGEDURATION is a free data retrieval call binding the contract method 0xb9f4420d. +// +// Solidity: function MAX_CHALLENGE_DURATION() view returns(uint32) +func (_ChannelHub *ChannelHubCaller) MAXCHALLENGEDURATION(opts *bind.CallOpts) (uint32, error) { + var out []interface{} + err := _ChannelHub.contract.Call(opts, &out, "MAX_CHALLENGE_DURATION") + + if err != nil { + return *new(uint32), err + } + + out0 := *abi.ConvertType(out[0], new(uint32)).(*uint32) + + return out0, err + +} + +// MAXCHALLENGEDURATION is a free data retrieval call binding the contract method 0xb9f4420d. +// +// Solidity: function MAX_CHALLENGE_DURATION() view returns(uint32) +func (_ChannelHub *ChannelHubSession) MAXCHALLENGEDURATION() (uint32, error) { + return _ChannelHub.Contract.MAXCHALLENGEDURATION(&_ChannelHub.CallOpts) +} + +// MAXCHALLENGEDURATION is a free data retrieval call binding the contract method 0xb9f4420d. +// +// Solidity: function MAX_CHALLENGE_DURATION() view returns(uint32) +func (_ChannelHub *ChannelHubCallerSession) MAXCHALLENGEDURATION() (uint32, error) { + return _ChannelHub.Contract.MAXCHALLENGEDURATION(&_ChannelHub.CallOpts) +} + // MAXDEPOSITESCROWSTEPS is a free data retrieval call binding the contract method 0x5ae2accc. // // Solidity: function MAX_DEPOSIT_ESCROW_STEPS() view returns(uint32) @@ -1007,21 +1038,21 @@ func (_ChannelHub *ChannelHubTransactorSession) ChallengeEscrowWithdrawal(escrow // CheckpointChannel is a paid mutator transaction binding the contract method 0x9691b468. // -// Solidity: function checkpointChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function checkpointChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubTransactor) CheckpointChannel(opts *bind.TransactOpts, channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.contract.Transact(opts, "checkpointChannel", channelId, candidate) } // CheckpointChannel is a paid mutator transaction binding the contract method 0x9691b468. // -// Solidity: function checkpointChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function checkpointChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubSession) CheckpointChannel(channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.Contract.CheckpointChannel(&_ChannelHub.TransactOpts, channelId, candidate) } // CheckpointChannel is a paid mutator transaction binding the contract method 0x9691b468. // -// Solidity: function checkpointChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function checkpointChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubTransactorSession) CheckpointChannel(channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.Contract.CheckpointChannel(&_ChannelHub.TransactOpts, channelId, candidate) } @@ -1049,21 +1080,21 @@ func (_ChannelHub *ChannelHubTransactorSession) ClaimFunds(token common.Address, // CloseChannel is a paid mutator transaction binding the contract method 0x5dc46a74. // -// Solidity: function closeChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function closeChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubTransactor) CloseChannel(opts *bind.TransactOpts, channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.contract.Transact(opts, "closeChannel", channelId, candidate) } // CloseChannel is a paid mutator transaction binding the contract method 0x5dc46a74. // -// Solidity: function closeChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function closeChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubSession) CloseChannel(channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.Contract.CloseChannel(&_ChannelHub.TransactOpts, channelId, candidate) } // CloseChannel is a paid mutator transaction binding the contract method 0x5dc46a74. // -// Solidity: function closeChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function closeChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubTransactorSession) CloseChannel(channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.Contract.CloseChannel(&_ChannelHub.TransactOpts, channelId, candidate) } @@ -1301,21 +1332,21 @@ func (_ChannelHub *ChannelHubTransactorSession) RegisterNodeValidator(validatorI // WithdrawFromChannel is a paid mutator transaction binding the contract method 0xc74a2d10. // -// Solidity: function withdrawFromChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function withdrawFromChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubTransactor) WithdrawFromChannel(opts *bind.TransactOpts, channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.contract.Transact(opts, "withdrawFromChannel", channelId, candidate) } // WithdrawFromChannel is a paid mutator transaction binding the contract method 0xc74a2d10. // -// Solidity: function withdrawFromChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function withdrawFromChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubSession) WithdrawFromChannel(channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.Contract.WithdrawFromChannel(&_ChannelHub.TransactOpts, channelId, candidate) } // WithdrawFromChannel is a paid mutator transaction binding the contract method 0xc74a2d10. // -// Solidity: function withdrawFromChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) payable returns() +// Solidity: function withdrawFromChannel(bytes32 channelId, (uint64,uint8,bytes32,(uint64,address,uint8,uint256,int256,uint256,int256),(uint64,address,uint8,uint256,int256,uint256,int256),bytes,bytes) candidate) returns() func (_ChannelHub *ChannelHubTransactorSession) WithdrawFromChannel(channelId [32]byte, candidate State) (*types.Transaction, error) { return _ChannelHub.Contract.WithdrawFromChannel(&_ChannelHub.TransactOpts, channelId, candidate) } @@ -3198,13 +3229,14 @@ func (it *ChannelHubEscrowDepositsPurgedIterator) Close() error { // ChannelHubEscrowDepositsPurged represents a EscrowDepositsPurged event raised by the ChannelHub contract. type ChannelHubEscrowDepositsPurged struct { + EscrowIds [][32]byte PurgedCount *big.Int Raw types.Log // Blockchain specific contextual infos } -// FilterEscrowDepositsPurged is a free log retrieval operation binding the contract event 0x61815f4b11c6ea4e14a2e448a010bed8efdc3e53a15efbf183d16a31085cd145. +// FilterEscrowDepositsPurged is a free log retrieval operation binding the contract event 0x8fac6141d748dc9c9bc16cc25f636385597618190a44c03d33be5656e01b3642. // -// Solidity: event EscrowDepositsPurged(uint256 purgedCount) +// Solidity: event EscrowDepositsPurged(bytes32[] escrowIds, uint256 purgedCount) func (_ChannelHub *ChannelHubFilterer) FilterEscrowDepositsPurged(opts *bind.FilterOpts) (*ChannelHubEscrowDepositsPurgedIterator, error) { logs, sub, err := _ChannelHub.contract.FilterLogs(opts, "EscrowDepositsPurged") @@ -3214,9 +3246,9 @@ func (_ChannelHub *ChannelHubFilterer) FilterEscrowDepositsPurged(opts *bind.Fil return &ChannelHubEscrowDepositsPurgedIterator{contract: _ChannelHub.contract, event: "EscrowDepositsPurged", logs: logs, sub: sub}, nil } -// WatchEscrowDepositsPurged is a free log subscription operation binding the contract event 0x61815f4b11c6ea4e14a2e448a010bed8efdc3e53a15efbf183d16a31085cd145. +// WatchEscrowDepositsPurged is a free log subscription operation binding the contract event 0x8fac6141d748dc9c9bc16cc25f636385597618190a44c03d33be5656e01b3642. // -// Solidity: event EscrowDepositsPurged(uint256 purgedCount) +// Solidity: event EscrowDepositsPurged(bytes32[] escrowIds, uint256 purgedCount) func (_ChannelHub *ChannelHubFilterer) WatchEscrowDepositsPurged(opts *bind.WatchOpts, sink chan<- *ChannelHubEscrowDepositsPurged) (event.Subscription, error) { logs, sub, err := _ChannelHub.contract.WatchLogs(opts, "EscrowDepositsPurged") @@ -3251,9 +3283,9 @@ func (_ChannelHub *ChannelHubFilterer) WatchEscrowDepositsPurged(opts *bind.Watc }), nil } -// ParseEscrowDepositsPurged is a log parse operation binding the contract event 0x61815f4b11c6ea4e14a2e448a010bed8efdc3e53a15efbf183d16a31085cd145. +// ParseEscrowDepositsPurged is a log parse operation binding the contract event 0x8fac6141d748dc9c9bc16cc25f636385597618190a44c03d33be5656e01b3642. // -// Solidity: event EscrowDepositsPurged(uint256 purgedCount) +// Solidity: event EscrowDepositsPurged(bytes32[] escrowIds, uint256 purgedCount) func (_ChannelHub *ChannelHubFilterer) ParseEscrowDepositsPurged(log types.Log) (*ChannelHubEscrowDepositsPurged, error) { event := new(ChannelHubEscrowDepositsPurged) if err := _ChannelHub.contract.UnpackLog(event, "EscrowDepositsPurged", log); err != nil { diff --git a/pkg/blockchain/evm/channel_hub_reactor.go b/pkg/blockchain/evm/channel_hub_reactor.go index e8711e458..1d91749af 100644 --- a/pkg/blockchain/evm/channel_hub_reactor.go +++ b/pkg/blockchain/evm/channel_hub_reactor.go @@ -49,6 +49,10 @@ type ChannelHubReactorStore interface { // This queues the state to be submitted on-chain to update the channel's on-chain state. ScheduleCheckpoint(stateID string, chainID uint64) error + // ScheduleChallenge schedules a challengeChannel(...) submission on the channel's home + // blockchain using the provided state and a node-produced challenger signature. + ScheduleChallenge(stateID string, chainID uint64) error + // ScheduleInitiateEscrowDeposit schedules an initiate for an escrow deposit operation. // This queues the state to be submitted on-chain to finalize an escrow deposit. ScheduleInitiateEscrowDeposit(stateID string, chainID uint64) error @@ -67,6 +71,9 @@ type ChannelHubReactorStore interface { // RefreshUserEnforcedBalance recomputes the locked balance from the user's open home channel on-chain state. RefreshUserEnforcedBalance(wallet, asset string) error + // UpdateStateUserSigIfMissing backfills the user signature for a stored state when it is currently NULL. + UpdateStateUserSigIfMissing(channelID string, version uint64, userSig string) error + // StoreContractEvent persists a blockchain event to the database. StoreContractEvent(ev core.BlockchainEvent) error } @@ -75,6 +82,14 @@ var channelHubAbi *abi.ABI var channelHubFilterer *ChannelHubFilterer var channelHubEventMapping map[common.Hash]string +// encodeSig hex-encodes a signature byte slice or returns an empty string when absent. +func encodeSig(b []byte) string { + if len(b) == 0 { + return "" + } + return hexutil.Encode(b) +} + func initChannelHub() { var err error channelHubAbi, err = ChannelHubMetaData.GetAbi() @@ -250,6 +265,7 @@ func (r *ChannelHubReactor) handleHomeChannelCreated(ctx context.Context, store ev := core.HomeChannelCreatedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.InitialState.Version, + UserSig: encodeSig(event.InitialState.UserSig), } return r.eventHandler.HandleHomeChannelCreated(ctx, store, &ev) } @@ -263,6 +279,7 @@ func (r *ChannelHubReactor) handleHomeChannelMigrated(ctx context.Context, store ev := core.HomeChannelMigratedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleHomeChannelMigrated(ctx, store, &ev) } @@ -276,6 +293,7 @@ func (r *ChannelHubReactor) handleHomeChannelCheckpointed(ctx context.Context, s ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.Candidate.Version, + UserSig: encodeSig(event.Candidate.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -289,6 +307,7 @@ func (r *ChannelHubReactor) handleChannelDeposited(ctx context.Context, store Ch ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.Candidate.Version, + UserSig: encodeSig(event.Candidate.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -302,6 +321,7 @@ func (r *ChannelHubReactor) handleChannelWithdrawn(ctx context.Context, store Ch ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.Candidate.Version, + UserSig: encodeSig(event.Candidate.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -316,6 +336,7 @@ func (r *ChannelHubReactor) handleHomeChannelChallenged(ctx context.Context, sto ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.Candidate.Version, ChallengeExpiry: event.ChallengeExpireAt, + UserSig: encodeSig(event.Candidate.UserSig), } return r.eventHandler.HandleHomeChannelChallenged(ctx, store, &ev) } @@ -329,6 +350,7 @@ func (r *ChannelHubReactor) handleHomeChannelClosed(ctx context.Context, store C ev := core.HomeChannelClosedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.FinalState.Version, + UserSig: encodeSig(event.FinalState.UserSig), } return r.eventHandler.HandleHomeChannelClosed(ctx, store, &ev) } @@ -342,6 +364,7 @@ func (r *ChannelHubReactor) handleEscrowDepositInitiated(ctx context.Context, st ev := core.EscrowDepositInitiatedEvent{ ChannelID: hexutil.Encode(event.EscrowId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleEscrowDepositInitiated(ctx, store, &ev) } @@ -356,6 +379,7 @@ func (r *ChannelHubReactor) handleEscrowDepositChallenged(ctx context.Context, s ChannelID: hexutil.Encode(event.EscrowId[:]), StateVersion: event.State.Version, ChallengeExpiry: event.ChallengeExpireAt, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleEscrowDepositChallenged(ctx, store, &ev) } @@ -369,6 +393,7 @@ func (r *ChannelHubReactor) handleEscrowDepositFinalized(ctx context.Context, st ev := core.EscrowDepositFinalizedEvent{ ChannelID: hexutil.Encode(event.EscrowId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleEscrowDepositFinalized(ctx, store, &ev) } @@ -382,6 +407,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalInitiated(ctx context.Context, ev := core.EscrowWithdrawalInitiatedEvent{ ChannelID: hexutil.Encode(event.EscrowId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleEscrowWithdrawalInitiated(ctx, store, &ev) } @@ -396,6 +422,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalChallenged(ctx context.Context ChannelID: hexutil.Encode(event.EscrowId[:]), StateVersion: event.State.Version, ChallengeExpiry: event.ChallengeExpireAt, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleEscrowWithdrawalChallenged(ctx, store, &ev) } @@ -409,6 +436,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalFinalized(ctx context.Context, ev := core.EscrowWithdrawalFinalizedEvent{ ChannelID: hexutil.Encode(event.EscrowId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleEscrowWithdrawalFinalized(ctx, store, &ev) } @@ -422,6 +450,7 @@ func (r *ChannelHubReactor) handleEscrowDepositInitiatedOnHome(ctx context.Conte ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -435,6 +464,7 @@ func (r *ChannelHubReactor) handleEscrowDepositFinalizedOnHome(ctx context.Conte ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -448,6 +478,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalInitiatedOnHome(ctx context.Co ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -461,6 +492,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalFinalizedOnHome(ctx context.Co ev := core.HomeChannelCheckpointedEvent{ ChannelID: hexutil.Encode(event.ChannelId[:]), StateVersion: event.State.Version, + UserSig: encodeSig(event.State.UserSig), } return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) } @@ -537,8 +569,12 @@ func (r *ChannelHubReactor) handleEscrowDepositsPurged(ctx context.Context, stor if err != nil { return errors.Wrap(err, "failed to parse EscrowDepositsPurged event") } - logger := log.FromContext(ctx) - logger.Info("EscrowDepositsPurged event", "purgedCount", event.PurgedCount.String()) - return nil + escrowIDs := make([]string, len(event.EscrowIds)) + for i, id := range event.EscrowIds { + escrowIDs[i] = hexutil.Encode(id[:]) + } + + ev := core.EscrowDepositsPurgedEvent{EscrowIDs: escrowIDs} + return r.eventHandler.HandleEscrowDepositsPurged(ctx, store, &ev) } diff --git a/pkg/blockchain/evm/channel_hub_reactor_test.go b/pkg/blockchain/evm/channel_hub_reactor_test.go index 18eb31379..7786e151c 100644 --- a/pkg/blockchain/evm/channel_hub_reactor_test.go +++ b/pkg/blockchain/evm/channel_hub_reactor_test.go @@ -55,6 +55,11 @@ func (m *mockChannelHubStore) ScheduleCheckpoint(stateID string, chainID uint64) return args.Error(0) } +func (m *mockChannelHubStore) ScheduleChallenge(stateID string, chainID uint64) error { + args := m.Called(stateID, chainID) + return args.Error(0) +} + func (m *mockChannelHubStore) ScheduleInitiateEscrowDeposit(stateID string, chainID uint64) error { args := m.Called(stateID, chainID) return args.Error(0) @@ -85,6 +90,11 @@ func (m *mockChannelHubStore) StoreContractEvent(ev core.BlockchainEvent) error return args.Error(0) } +func (m *mockChannelHubStore) UpdateStateUserSigIfMissing(channelID string, version uint64, userSig string) error { + args := m.Called(channelID, version, userSig) + return args.Error(0) +} + // mockChannelHubEventHandler captures events dispatched by the reactor. type mockChannelHubEventHandler struct { mock.Mock @@ -135,6 +145,11 @@ func (m *mockChannelHubEventHandler) HandleEscrowDepositFinalized(ctx context.Co return args.Error(0) } +func (m *mockChannelHubEventHandler) HandleEscrowDepositsPurged(ctx context.Context, tx core.ChannelHubEventHandlerStore, ev *core.EscrowDepositsPurgedEvent) error { + args := m.Called(ctx, tx, ev) + return args.Error(0) +} + func (m *mockChannelHubEventHandler) HandleEscrowWithdrawalInitiated(ctx context.Context, tx core.ChannelHubEventHandlerStore, ev *core.EscrowWithdrawalInitiatedEvent) error { args := m.Called(ctx, tx, ev) return args.Error(0) @@ -371,6 +386,46 @@ func TestChannelHubReactor_HandleHomeChannelCheckpointed(t *testing.T) { store.AssertExpectations(t) } +// TestChannelHubReactor_HandleHomeChannelCheckpointed_ForwardsUserSig confirms the reactor +// hex-encodes Candidate.UserSig from the parsed event payload and surfaces it to the handler. +// Without this the wedge-recovery backfill in HandleHomeChannelCheckpointed has nothing to write. +func TestChannelHubReactor_HandleHomeChannelCheckpointed_ForwardsUserSig(t *testing.T) { + blockchainID := uint64(1) + nodeAddr := "0x1111111111111111111111111111111111111111" + channelID := common.HexToHash("0xcc02ff") + + state := makeState(7) + state.UserSig = []byte{0xde, 0xad, 0xbe, 0xef} + data := packNonIndexed(t, "ChannelCheckpointed", state) + + logEntry := types.Log{ + Topics: []common.Hash{ + channelHubAbi.Events["ChannelCheckpointed"].ID, + channelID, + }, + Data: data, + BlockNumber: 401, + TxHash: common.HexToHash("0x112"), + Index: 0, + } + + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + return ev.UserSig == hexutil.Encode([]byte{0xde, 0xad, 0xbe, 0xef}) + })).Return(nil) + + expectStoreContractEvent(store, "ChannelCheckpointed", 401, blockchainID) + + reactor := newReactor(blockchainID, nodeAddr, handler, assetStore, store) + err := reactor.HandleEvent(context.Background(), logEntry) + require.NoError(t, err) + handler.AssertExpectations(t) + store.AssertExpectations(t) +} + func TestChannelHubReactor_HandleHomeChannelChallenged(t *testing.T) { blockchainID := uint64(1) nodeAddr := "0x1111111111111111111111111111111111111111" @@ -903,6 +958,46 @@ func TestChannelHubReactor_HandleEscrowWithdrawalFinalizedOnHome(t *testing.T) { store.AssertExpectations(t) } +func TestChannelHubReactor_HandleEscrowDepositsPurged(t *testing.T) { + blockchainID := uint64(1) + nodeAddr := "0x1111111111111111111111111111111111111111" + + escrowID1 := common.HexToHash("0xee10") + escrowID2 := common.HexToHash("0xee11") + escrowIds := [][32]byte{escrowID1, escrowID2} + purgedCount := big.NewInt(2) + + data := packNonIndexed(t, "EscrowDepositsPurged", escrowIds, purgedCount) + + logEntry := types.Log{ + Topics: []common.Hash{ + channelHubAbi.Events["EscrowDepositsPurged"].ID, + }, + Data: data, + BlockNumber: 1200, + TxHash: common.HexToHash("0x999"), + Index: 0, + } + + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + handler.On("HandleEscrowDepositsPurged", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.EscrowDepositsPurgedEvent) bool { + return len(ev.EscrowIDs) == 2 && + ev.EscrowIDs[0] == hexutil.Encode(escrowID1[:]) && + ev.EscrowIDs[1] == hexutil.Encode(escrowID2[:]) + })).Return(nil) + + expectStoreContractEvent(store, "EscrowDepositsPurged", 1200, blockchainID) + + reactor := newReactor(blockchainID, nodeAddr, handler, assetStore, store) + err := reactor.HandleEvent(context.Background(), logEntry) + require.NoError(t, err) + handler.AssertExpectations(t) + store.AssertExpectations(t) +} + func TestChannelHubReactor_UnknownEvent(t *testing.T) { blockchainID := uint64(1) nodeAddr := "0x1111111111111111111111111111111111111111" diff --git a/pkg/blockchain/evm/validator_watcher.go b/pkg/blockchain/evm/validator_watcher.go new file mode 100644 index 000000000..c73ff1784 --- /dev/null +++ b/pkg/blockchain/evm/validator_watcher.go @@ -0,0 +1,151 @@ +package evm + +import ( + "context" + "math/big" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/pkg/errors" + + "github.com/layer-3/nitrolite/pkg/core" + "github.com/layer-3/nitrolite/pkg/log" +) + +// WatchValidatorRegistered subscribes to ValidatorRegistered events emitted by the +// ChannelHub contract at contractAddress and delivers them on the returned channel. +// +// Historical replay: if fromBlock > 0 the function first fetches all matching logs +// from fromBlock to the current chain head before switching to live events. This +// fills the gap between the last processed block and "now", ensuring no event is +// missed during a reconnect. Pass fromBlock = 0 to skip historical fetch (e.g. on +// the very first call when no prior state exists). +// +// The channel is closed when ctx is cancelled or the underlying subscription is +// lost. Callers should treat a closed channel as a signal to resubscribe, passing +// the BlockNumber of the last received event + 1 as fromBlock to avoid gaps. +// +// Reorg safety: logs marked Removed (chain reorganisation) are silently skipped. +// +// client must support event subscriptions (WebSocket or IPC transport). +func WatchValidatorRegistered(ctx context.Context, contractAddress common.Address, client EVMClient, blockchainID uint64, fromBlock uint64) (<-chan *core.ValidatorRegisteredEvent, error) { + logger := log.FromContext(ctx).WithName("evm") + + topic := channelHubAbi.Events["ValidatorRegistered"].ID + + query := ethereum.FilterQuery{ + Addresses: []common.Address{contractAddress}, + Topics: [][]common.Hash{{topic}}, + } + + logCh := make(chan types.Log, 16) + sub, err := client.SubscribeFilterLogs(ctx, query, logCh) + if err != nil { + return nil, errors.Wrap(err, "failed to subscribe to ValidatorRegistered events (ensure a WebSocket RPC endpoint is configured)") + } + + eventCh := make(chan *core.ValidatorRegisteredEvent, 16) + + go func() { + defer close(eventCh) + defer sub.Unsubscribe() + + // headBlock is the upper bound of the historical getLogs query. Any live + // event with BlockNumber ≤ headBlock was already fetched historically and + // must be skipped to prevent duplicate delivery from the subscription-to- + // getLogs transition window (the subscription starts before HeaderByNumber + // returns, so logs in that window land in both streams). + var headBlock uint64 + + // Historical phase: fetch logs from fromBlock to the current head before + // processing live events, so reconnects don't miss events emitted during + // any outage window. + if fromBlock > 0 { + header, err := client.HeaderByNumber(ctx, nil) + if err != nil { + if ctx.Err() == nil { + logger.Warn("failed to get chain head for historical ValidatorRegistered fetch — skipping gap fill", "error", err) + } + } else { + headBlock = header.Number.Uint64() + histQuery := ethereum.FilterQuery{ + FromBlock: new(big.Int).SetUint64(fromBlock), + ToBlock: header.Number, + Addresses: []common.Address{contractAddress}, + Topics: [][]common.Hash{{topic}}, + } + histLogs, err := client.FilterLogs(ctx, histQuery) + if err != nil { + if ctx.Err() == nil { + logger.Warn("failed to fetch historical ValidatorRegistered logs — gap fill incomplete", "error", err, "fromBlock", fromBlock) + } + } else { + logger.Info("replaying historical ValidatorRegistered logs", "count", len(histLogs), "fromBlock", fromBlock, "toBlock", header.Number) + for _, l := range histLogs { + if ev := parseAndSend(ctx, eventCh, l, blockchainID, logger); ev == nil { + return // ctx cancelled during delivery + } + } + } + } + } + + // Live phase. + for { + select { + case <-ctx.Done(): + return + case err := <-sub.Err(): + // Suppress the error log on clean ctx cancellation: go-ethereum delivers + // a cancellation error on sub.Err() before ctx.Done() is scheduled, + // which would otherwise log a spurious "subscription error" on shutdown. + if err != nil && ctx.Err() == nil { + logger.Error("ValidatorRegistered subscription error", "error", err, "contract", contractAddress.Hex(), "blockchainID", blockchainID) + } + return + case l, ok := <-logCh: + if !ok { + return + } + if l.Removed { + logger.Warn("skipping removed ValidatorRegistered log (reorg)", "blockchainID", blockchainID, "txHash", l.TxHash.Hex()) + continue + } + // Skip events already covered by the historical getLogs query to + // prevent duplicate delivery from the subscription overlap window. + if l.BlockNumber <= headBlock { + continue + } + if parseAndSend(ctx, eventCh, l, blockchainID, logger) == nil { + return + } + } + } + }() + + return eventCh, nil +} + +// parseAndSend parses a ValidatorRegistered log and forwards it to eventCh. +// Returns the event on success, nil if ctx was cancelled before delivery. +func parseAndSend(ctx context.Context, eventCh chan<- *core.ValidatorRegisteredEvent, l types.Log, blockchainID uint64, logger log.Logger) *core.ValidatorRegisteredEvent { + parsed, err := channelHubFilterer.ParseValidatorRegistered(l) + if err != nil { + logger.Error("failed to parse ValidatorRegistered log", "error", err, "txHash", l.TxHash.Hex()) + return &core.ValidatorRegisteredEvent{} // non-nil signals caller to continue + } + ev := &core.ValidatorRegisteredEvent{ + BlockchainID: blockchainID, + ValidatorID: parsed.ValidatorId, + Validator: parsed.Validator.Hex(), + BlockNumber: l.BlockNumber, + } + logger.Info("ValidatorRegistered event", "blockchainID", blockchainID, "validatorID", ev.ValidatorID, "validator", ev.Validator, "block", ev.BlockNumber) + select { + case eventCh <- ev: + return ev + case <-ctx.Done(): + return nil + } +} diff --git a/pkg/core/event.go b/pkg/core/event.go index 4a407eed3..2ffbc6312 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -35,6 +35,13 @@ type EscrowDepositChallengedEvent channelChallengedEvent // EscrowDepositFinalizedEvent represents the EscrowDepositFinalized event type EscrowDepositFinalizedEvent channelEvent +// EscrowDepositsPurgedEvent represents the EscrowDepositsPurged event emitted when expired +// escrow deposits are finalized by the purge queue without a signed FINALIZE_ESCROW_DEPOSIT state. +type EscrowDepositsPurgedEvent struct { + // EscrowIDs holds the hex-encoded escrow IDs (== channel_id in the channels table) that were purged. + EscrowIDs []string `json:"escrow_ids"` +} + // EscrowWithdrawalInitiatedEvent represents the EscrowWithdrawalInitiated event type EscrowWithdrawalInitiatedEvent channelEvent @@ -47,12 +54,17 @@ type EscrowWithdrawalFinalizedEvent channelEvent type channelEvent struct { ChannelID string `json:"channel_id"` StateVersion uint64 `json:"state_version"` + // UserSig is the hex-encoded user signature recovered from the on-chain state payload. + // Empty when the parsed event carries no user signature (e.g. unilateral node-only state). + UserSig string `json:"user_sig,omitempty"` } type channelChallengedEvent struct { ChannelID string `json:"channel_id"` StateVersion uint64 `json:"state_version"` ChallengeExpiry uint64 `json:"challenge_expiry"` + // UserSig is the hex-encoded user signature recovered from the on-chain state payload. + UserSig string `json:"user_sig,omitempty"` } type UserLockedBalanceUpdatedEvent struct { @@ -61,6 +73,19 @@ type UserLockedBalanceUpdatedEvent struct { Balance decimal.Decimal `json:"balance"` } +// ValidatorRegisteredEvent is emitted by ChannelHub when the node registers a new +// signature validator. Users should react to unexpected registrations by revoking +// ERC20 approvals granted to ChannelHub — see contracts/SECURITY.md for details. +type ValidatorRegisteredEvent struct { + BlockchainID uint64 `json:"blockchain_id"` + ValidatorID uint8 `json:"validator_id"` + // Validator is the EIP-55 checksummed hex address of the registered validator contract. + // Always compare using strings.EqualFold or common.HexToAddress(ev.Validator).Hex() + // to avoid silent mismatches against lowercase or non-checksummed config values. + Validator string `json:"validator"` + BlockNumber uint64 `json:"block_number"` // block where the event was emitted; use as fromBlock on reconnect +} + type BlockchainEvent struct { ContractAddress string `json:"contract_address"` BlockchainID uint64 `json:"blockchain_id"` diff --git a/pkg/core/interface.go b/pkg/core/interface.go index eb647f616..56c9125a4 100644 --- a/pkg/core/interface.go +++ b/pkg/core/interface.go @@ -95,6 +95,7 @@ type ChannelHubEventHandler interface { HandleEscrowDepositInitiated(context.Context, ChannelHubEventHandlerStore, *EscrowDepositInitiatedEvent) error HandleEscrowDepositChallenged(context.Context, ChannelHubEventHandlerStore, *EscrowDepositChallengedEvent) error HandleEscrowDepositFinalized(context.Context, ChannelHubEventHandlerStore, *EscrowDepositFinalizedEvent) error + HandleEscrowDepositsPurged(context.Context, ChannelHubEventHandlerStore, *EscrowDepositsPurgedEvent) error HandleEscrowWithdrawalInitiated(context.Context, ChannelHubEventHandlerStore, *EscrowWithdrawalInitiatedEvent) error HandleEscrowWithdrawalChallenged(context.Context, ChannelHubEventHandlerStore, *EscrowWithdrawalChallengedEvent) error HandleEscrowWithdrawalFinalized(context.Context, ChannelHubEventHandlerStore, *EscrowWithdrawalFinalizedEvent) error @@ -122,6 +123,10 @@ type ChannelHubEventHandlerStore interface { // This queues the state to be submitted on-chain to update the channel's on-chain state. ScheduleCheckpoint(stateID string, chainID uint64) error + // ScheduleChallenge schedules a challengeChannel(...) submission on the channel's home + // blockchain using the provided state and a node-produced challenger signature. + ScheduleChallenge(stateID string, chainID uint64) error + // ScheduleInitiateEscrowDeposit schedules an initiate for an escrow deposit operation. // This queues the state to be submitted on-chain to finalize an escrow deposit. ScheduleInitiateEscrowDeposit(stateID string, chainID uint64) error @@ -139,6 +144,11 @@ type ChannelHubEventHandlerStore interface { // RefreshUserEnforcedBalance recomputes the locked balance from the user's open home channel on-chain state. RefreshUserEnforcedBalance(wallet, asset string) error + + // UpdateStateUserSigIfMissing backfills the user signature for a stored state when it is currently NULL. + // Used to repair the local record after an on-chain event proves the state was bilaterally enforced. + // No-op when userSig is empty or no row matches; existing user_sig is never overwritten. + UpdateStateUserSigIfMissing(channelID string, version uint64, userSig string) error } type LockingContractEventHandler interface { diff --git a/pkg/core/session_key.go b/pkg/core/session_key.go index 0f0b91468..38d8a24fd 100644 --- a/pkg/core/session_key.go +++ b/pkg/core/session_key.go @@ -16,12 +16,13 @@ import ( // ChannelSessionKeyStateV1 represents the state of a session key. type ChannelSessionKeyStateV1 struct { // ID Hash(user_address + session_key + version) - UserAddress string `json:"user_address"` // UserAddress is the user wallet address - SessionKey string `json:"session_key"` // SessionKey is the session key address for delegation - Version uint64 `json:"version"` // Version is the version of the session key format - Assets []string `json:"assets"` // Assets associated with this session key - ExpiresAt time.Time `json:"expires_at"` // Expiration time as unix timestamp of this session key - UserSig string `json:"user_sig"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key + UserAddress string `json:"user_address"` // UserAddress is the user wallet address + SessionKey string `json:"session_key"` // SessionKey is the session key address for delegation + Version uint64 `json:"version"` // Version is the version of the session key format + Assets []string `json:"assets"` // Assets associated with this session key + ExpiresAt time.Time `json:"expires_at"` // Expiration time as unix timestamp of this session key + UserSig string `json:"user_sig"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key + SessionKeySig string `json:"session_key_sig"` // SessionKeySig is the session-key holder's signature proving possession of the key being registered. } type VerifyChannelSessionKePermissionsV1 func(walletAddr, sessionKeyAddr, metadataHash string) (bool, error) @@ -91,19 +92,25 @@ func PackChannelKeyStateV1(sessionKey string, metadataHash common.Hash) ([]byte, return packed, nil } -func GetChannelSessionKeyAuthMetadataHashV1(version uint64, assets []string, expiresAt int64) (common.Hash, error) { +// GetChannelSessionKeyAuthMetadataHashV1 hashes the session-key authorization metadata. +// user_address is bound into the hash; together with the session_key already in +// PackChannelKeyStateV1, this binds the signed payload to a single (wallet, session_key) +// pair so signatures cannot be replayed across wallets or session keys. +func GetChannelSessionKeyAuthMetadataHashV1(userAddress string, version uint64, assets []string, expiresAt int64) (common.Hash, error) { stringArrayType, err := abi.NewType("string[]", "", nil) if err != nil { return common.Hash{}, fmt.Errorf("failed to create string array type: %w", err) } metadtataArgs := abi.Arguments{ + {Type: abi.Type{T: abi.AddressTy}}, // user_address {Type: abi.Type{T: abi.UintTy, Size: 64}}, // version {Type: stringArrayType}, // assets {Type: abi.Type{T: abi.UintTy, Size: 64}}, // expires_at (unix timestamp) } packedMetadataArgs, err := metadtataArgs.Pack( + common.HexToAddress(userAddress), version, assets, uint64(expiresAt), @@ -116,8 +123,18 @@ func GetChannelSessionKeyAuthMetadataHashV1(version uint64, assets []string, exp return hashedMetadata, nil } -func ValidateChannelSessionKeyAuthSigV1(state ChannelSessionKeyStateV1) error { - metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(state.Version, state.Assets, state.ExpiresAt.Unix()) +// ValidateChannelSessionKeyStateV1 verifies both signatures over the registration payload: +// user_sig must recover to state.UserAddress (wallet authorizes the delegation) and +// session_key_sig must recover to state.SessionKey (session-key holder proves possession). +// Both signatures sign the same PackChannelKeyStateV1(session_key, metadataHash) payload; +// session_key binds the packed bytes and user_address binds the metadata hash, so a +// signature minted for one (wallet, session_key) pair cannot be replayed for another. +func ValidateChannelSessionKeyStateV1(state ChannelSessionKeyStateV1) error { + if state.SessionKeySig == "" { + return fmt.Errorf("session_key_sig is required") + } + + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(state.UserAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) if err != nil { return fmt.Errorf("failed to get metadata hash: %w", err) } @@ -127,23 +144,33 @@ func ValidateChannelSessionKeyAuthSigV1(state ChannelSessionKeyStateV1) error { return fmt.Errorf("failed to pack session key state: %w", err) } - authSigBytes, err := hexutil.Decode(state.UserSig) - if err != nil { - return fmt.Errorf("failed to decode user signature: %w", err) - } - recoverer, err := sign.NewAddressRecoverer(sign.TypeEthereumMsg) if err != nil { return fmt.Errorf("failed to create address recoverer: %w", err) } - recoveredAddr, err := recoverer.RecoverAddress(packed, authSigBytes) + userSigBytes, err := hexutil.Decode(state.UserSig) if err != nil { - return fmt.Errorf("failed to recover address from signature: %w", err) + return fmt.Errorf("failed to decode user signature: %w", err) + } + recoveredUser, err := recoverer.RecoverAddress(packed, userSigBytes) + if err != nil { + return fmt.Errorf("failed to recover user_sig: %w", err) + } + if !strings.EqualFold(recoveredUser.String(), state.UserAddress) { + return fmt.Errorf("invalid signature: recovered address %s does not match wallet %s", recoveredUser.String(), state.UserAddress) } - if !strings.EqualFold(recoveredAddr.String(), state.UserAddress) { - return fmt.Errorf("invalid signature: recovered address %s does not match wallet %s", recoveredAddr.String(), state.UserAddress) + sessionKeySigBytes, err := hexutil.Decode(state.SessionKeySig) + if err != nil { + return fmt.Errorf("failed to decode session_key_sig: %w", err) + } + recoveredKey, err := recoverer.RecoverAddress(packed, sessionKeySigBytes) + if err != nil { + return fmt.Errorf("failed to recover session_key_sig: %w", err) + } + if !strings.EqualFold(recoveredKey.String(), state.SessionKey) { + return fmt.Errorf("session_key_sig does not match session_key") } return nil diff --git a/pkg/core/session_key_test.go b/pkg/core/session_key_test.go index c21d999d1..23e4bd473 100644 --- a/pkg/core/session_key_test.go +++ b/pkg/core/session_key_test.go @@ -27,7 +27,7 @@ func TestChannelSessionKeySignerV1(t *testing.T) { expiresAt := time.Now().Add(1 * time.Hour).Unix() // 4. Compute Metadata Hash - metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt) + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(userAddress, version, assets, expiresAt) require.NoError(t, err) // 5. Pack Data for Authorization (User signs this) @@ -69,58 +69,122 @@ func TestChannelSessionKeySignerV1(t *testing.T) { assert.Equal(t, strings.ToLower(userAddress), strings.ToLower(recoveredWallet)) } -func TestValidateChannelSessionKeyAuthSigV1(t *testing.T) { +func TestValidateChannelSessionKeyStateV1(t *testing.T) { t.Parallel() - // 1. Setup User Wallet userSigner, userAddress := createSigner(t) + sessionSigner, sessionKeyAddr := createSigner(t) - // 2. Setup Session Key - // We just need address for validation logic, not the signer itself unless we sign with it (which we don't for auth sig) - // But let's use createSigner for consistency - _, sessionKeyAddr := createSigner(t) - - // 3. Define State version := uint64(1) assets := []string{"USDC"} expiresAt := time.Now().Add(1 * time.Hour) - // 4. Create valid signature - metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(version, assets, expiresAt.Unix()) + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(userAddress, version, assets, expiresAt.Unix()) require.NoError(t, err) packed, err := PackChannelKeyStateV1(sessionKeyAddr, metadataHash) require.NoError(t, err) - authSig, err := userSigner.Sign(packed) + userSig, err := userSigner.Sign(packed) + require.NoError(t, err) + + sessionKeySig, err := sessionSigner.Sign(packed) require.NoError(t, err) state := ChannelSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKeyAddr, - Version: version, - Assets: assets, - ExpiresAt: expiresAt, - UserSig: hexutil.Encode(authSig), + UserAddress: userAddress, + SessionKey: sessionKeyAddr, + Version: version, + Assets: assets, + ExpiresAt: expiresAt, + UserSig: hexutil.Encode(userSig), + SessionKeySig: hexutil.Encode(sessionKeySig), } - // 5. Validate - err = ValidateChannelSessionKeyAuthSigV1(state) - require.NoError(t, err) + require.NoError(t, ValidateChannelSessionKeyStateV1(state)) + + // Empty session_key_sig + stateNoKeySig := state + stateNoKeySig.SessionKeySig = "" + err = ValidateChannelSessionKeyStateV1(stateNoKeySig) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig is required") - // 6. Test Invalid Signature (wrong signer) + // user_sig signed by wrong wallet wrongSigner, _ := createSigner(t) - wrongSig, err := wrongSigner.Sign(packed) + wrongUserSig, err := wrongSigner.Sign(packed) + require.NoError(t, err) + stateWrongUser := state + stateWrongUser.UserSig = hexutil.Encode(wrongUserSig) + err = ValidateChannelSessionKeyStateV1(stateWrongUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match wallet") + + // session_key_sig signed by wrong key + wrongKeySigner, _ := createSigner(t) + wrongKeySig, err := wrongKeySigner.Sign(packed) + require.NoError(t, err) + stateWrongKey := state + stateWrongKey.SessionKeySig = hexutil.Encode(wrongKeySig) + err = ValidateChannelSessionKeyStateV1(stateWrongKey) + require.Error(t, err) + assert.Contains(t, err.Error(), "session_key_sig does not match session_key") + + // Tampered version (hash mismatch on recover) + stateTampered := state + stateTampered.Version = 2 + assert.Error(t, ValidateChannelSessionKeyStateV1(stateTampered)) +} + +// TestValidateChannelSessionKeyStateV1_NoReplay verifies that signatures cannot be replayed +// across (wallet, session_key) pairs. session_key binds the packed payload and user_address +// binds the metadata hash, so substituting either dimension causes signature recovery to +// yield an unrelated address. +func TestValidateChannelSessionKeyStateV1_NoReplay(t *testing.T) { + t.Parallel() + userSignerA, userAddressA := createSigner(t) + _, userAddressB := createSigner(t) + + sessionSignerA, sessionKeyAddrA := createSigner(t) + _, sessionKeyAddrB := createSigner(t) + + version := uint64(1) + assets := []string{"USDC"} + expiresAt := time.Now().Add(1 * time.Hour) + + metadataHashA, err := GetChannelSessionKeyAuthMetadataHashV1(userAddressA, version, assets, expiresAt.Unix()) + require.NoError(t, err) + packedA, err := PackChannelKeyStateV1(sessionKeyAddrA, metadataHashA) require.NoError(t, err) - state.UserSig = hexutil.Encode(wrongSig) - err1 := ValidateChannelSessionKeyAuthSigV1(state) - require.Error(t, err1) - assert.Contains(t, err1.Error(), "does not match wallet") + userSigA, err := userSignerA.Sign(packedA) + require.NoError(t, err) + sessionKeySigA, err := sessionSignerA.Sign(packedA) + require.NoError(t, err) - // 7. Test Invalid Signature (wrong data) - state.UserSig = hexutil.Encode(authSig) // Reset sig - state.Version = 2 // Change data - assert.Error(t, ValidateChannelSessionKeyAuthSigV1(state)) // Hash mismatch leads to recover address mismatch + stateA := ChannelSessionKeyStateV1{ + UserAddress: userAddressA, + SessionKey: sessionKeyAddrA, + Version: version, + Assets: assets, + ExpiresAt: expiresAt, + UserSig: hexutil.Encode(userSigA), + SessionKeySig: hexutil.Encode(sessionKeySigA), + } + require.NoError(t, ValidateChannelSessionKeyStateV1(stateA)) + + // Cross-session_key replay: substitute sessionKeyAddrB. packed bytes diverge, both + // recoveries yield unrelated addresses. + stateCrossKey := stateA + stateCrossKey.SessionKey = sessionKeyAddrB + err = ValidateChannelSessionKeyStateV1(stateCrossKey) + require.Error(t, err) + + // Cross-wallet replay: substitute userAddressB. metadataHash diverges, packed bytes + // diverge, both recoveries yield unrelated addresses. + stateCrossUser := stateA + stateCrossUser.UserAddress = userAddressB + err = ValidateChannelSessionKeyStateV1(stateCrossUser) + require.Error(t, err) } func TestGenerateSessionKeyStateIDV1(t *testing.T) { diff --git a/pkg/core/types.go b/pkg/core/types.go index a94bbbe1b..fcf9c1bff 100644 --- a/pkg/core/types.go +++ b/pkg/core/types.go @@ -30,7 +30,8 @@ var ( ChannelStatusVoid ChannelStatus = 0 ChannelStatusOpen ChannelStatus = 1 ChannelStatusChallenged ChannelStatus = 2 - ChannelStatusClosed ChannelStatus = 3 + ChannelStatusClosing ChannelStatus = 3 // co-signed Finalize stored off-chain; on-chain close pending + ChannelStatusClosed ChannelStatus = 4 ) func (s ChannelStatus) String() string { @@ -41,6 +42,8 @@ func (s ChannelStatus) String() string { return "open" case ChannelStatusChallenged: return "challenged" + case ChannelStatusClosing: + return "closing" case ChannelStatusClosed: return "closed" default: @@ -81,6 +84,8 @@ func (s *ChannelStatus) scanString(v string) error { *s = ChannelStatusOpen case ChannelStatusChallenged.String(): *s = ChannelStatusChallenged + case ChannelStatusClosing.String(): + *s = ChannelStatusClosing case ChannelStatusClosed.String(): *s = ChannelStatusClosed default: diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 1063016de..c3d2e3f4f 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -16,6 +16,11 @@ const ( // This version is encoded as the first byte of the channelId to prevent replay attacks // across different ChannelHub deployments on the same chain. ChannelHubVersion uint8 = 1 + + // ChannelMinChallengeDuration and ChannelMaxChallengeDuration mirror the + // ChannelHub challenge-duration bounds. + ChannelMinChallengeDuration uint32 = 24 * 60 * 60 + ChannelMaxChallengeDuration uint32 = 7 * 24 * 60 * 60 ) var ( diff --git a/pkg/rpc/api.go b/pkg/rpc/api.go index 1689aec08..235e41af0 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -126,12 +126,19 @@ type ChannelsV1GetLastKeyStatesRequest struct { // UserAddress is the user's wallet address UserAddress string `json:"user_address"` SessionKey *string `json:"session_key,omitempty"` // Optionally filter by SessionKey + // IncludeInactive, when true, includes latest states whose expires_at is in the past + // (expired or revoked). Defaults to false: only currently active states are returned. + IncludeInactive *bool `json:"include_inactive,omitempty"` + // Pagination contains pagination parameters (offset, limit, sort) + Pagination *PaginationParamsV1 `json:"pagination,omitempty"` } -// ChannelsV1GetSessionKeysResponse returns the list of active session keys. +// ChannelsV1GetLastKeyStatesResponse returns the latest session key states for the user. type ChannelsV1GetLastKeyStatesResponse struct { - // States is the list of active session key states for the user + // States is the list of latest session key states for the user, filtered by IncludeInactive. States []ChannelSessionKeyStateV1 `json:"states"` + // Metadata contains pagination information + Metadata PaginationMetadataV1 `json:"metadata"` } // ============================================================================ @@ -144,7 +151,7 @@ type AppSessionsV1SubmitDepositStateRequest struct { AppStateUpdate AppStateUpdateV1 `json:"app_state_update"` // QuorumSigs is the list of participant signatures for the app state update QuorumSigs []string `json:"quorum_sigs"` - // SigQuorum is the signature quorum for the application session + // UserState is the signed channel state from the user, used to fund the application session deposit UserState StateV1 `json:"user_state"` } @@ -254,12 +261,19 @@ type AppSessionsV1GetLastKeyStatesRequest struct { // UserAddress is the user's wallet address UserAddress string `json:"user_address"` SessionKey *string `json:"session_key,omitempty"` // Optionally filter by SessionKey + // IncludeInactive, when true, includes latest states whose expires_at is in the past + // (expired or revoked). Defaults to false: only currently active states are returned. + IncludeInactive *bool `json:"include_inactive,omitempty"` + // Pagination contains pagination parameters (offset, limit, sort) + Pagination *PaginationParamsV1 `json:"pagination,omitempty"` } -// SessionKeysV1GetSessionKeysResponse returns the list of active session keys. +// AppSessionsV1GetLastKeyStatesResponse returns the latest session key states for the user. type AppSessionsV1GetLastKeyStatesResponse struct { - // States is the list of active session key states for the user + // States is the list of latest session key states for the user, filtered by IncludeInactive. States []AppSessionKeyStateV1 `json:"states"` + // Metadata contains pagination information + Metadata PaginationMetadataV1 `json:"metadata"` } // ============================================================================ diff --git a/pkg/rpc/connection.go b/pkg/rpc/connection.go index 273e01701..e213da262 100644 --- a/pkg/rpc/connection.go +++ b/pkg/rpc/connection.go @@ -2,6 +2,7 @@ package rpc import ( "context" + "errors" "fmt" "io" "sync" @@ -26,6 +27,9 @@ var ( // defaultWsConnPongTimeout is the default timeout for receiving pong responses from clients. // If no pong is received within this duration after a ping, the connection is considered dead. defaultWsConnPongTimeout = 10 * time.Second + // defaultWsConnMaxMessageSize is the default cap on inbound WebSocket frame size in bytes. + // Frames exceeding this trigger close 1009 (Message Too Big) before allocation. + defaultWsConnMaxMessageSize int64 = 128 * 1024 ) // Connection represents an active RPC connection that handles bidirectional communication. @@ -40,6 +44,10 @@ type Connection interface { // Origin returns the origin of the connection, such as the client's IP address or other identifying information. Origin() string + // ApplicationID returns the application identifier supplied at connection time (via the + // app_id query parameter). Returns an empty string if no application_id was provided. + ApplicationID() string + // RawRequests returns a read-only channel for receiving incoming raw request messages. // Messages received on this channel are raw bytes that need to be unmarshaled // into Request objects for processing. The channel is closed when the @@ -75,6 +83,10 @@ type GorillaWsConnectionAdapter interface { // SetReadDeadline sets the deadline for future Read calls. // A zero value means reads will not time out. SetReadDeadline(t time.Time) error + // SetReadLimit sets the maximum size in bytes for a single inbound message. + // Frames exceeding the limit cause ReadMessage to return a *CloseError with + // code 1009 (CloseMessageTooBig) and the connection sends a close frame. + SetReadLimit(limit int64) } // WebsocketConnection implements the Connection interface using WebSocket transport. @@ -96,6 +108,8 @@ type WebsocketConnection struct { connectionID string // origin is the origin of the connection, such as the client's IP address origin string + // applicationID is the app_id query parameter supplied at WebSocket upgrade (may be empty) + applicationID string // websocketConn is the underlying WebSocket connection websocketConn GorillaWsConnectionAdapter // writeTimeout is the maximum duration to wait for a write to complete @@ -104,6 +118,11 @@ type WebsocketConnection struct { pingInterval time.Duration // pongTimeout is the maximum duration to wait for a pong response from the client pongTimeout time.Duration + // maxMessageSize caps inbound frame size in bytes. Always positive after + // NewWebsocketConnection: non-positive config values fall back to the default. + maxMessageSize int64 + // frameRateLimiter decides whether each inbound frame is admitted. + frameRateLimiter FrameRateLimiter // logger is used for logging events related to this connection logger log.Logger @@ -127,6 +146,9 @@ type WebsocketConnectionConfig struct { ConnectionID string // Origin is the origin of the connection, such as the client's IP address (optional) Origin string + // ApplicationID is the app_id query parameter supplied at WebSocket upgrade (optional). + // Caller is responsible for validation; the connection stores it as-is for metrics labeling. + ApplicationID string // WebsocketConn is the underlying WebSocket connection (required) WebsocketConn GorillaWsConnectionAdapter @@ -141,6 +163,14 @@ type WebsocketConnectionConfig struct { // PongTimeout is the maximum duration to wait for a pong response from the client (default: 10s). // If no pong is received within this duration, the connection is considered dead. PongTimeout time.Duration + // MaxMessageSize caps inbound frame size in bytes (default: 128 KiB). + // Frames larger than this are rejected with WebSocket close code 1009 + // before any allocation grows past the limit. Non-positive values fall + // back to the default; the cap cannot be disabled at this layer. + MaxMessageSize int64 + // FrameRateLimiter is consulted for every inbound frame; returning false closes + // the connection. nil → NoopFrameRateLimiter (no enforcement). + FrameRateLimiter FrameRateLimiter // Logger for connection events (default: no-op logger) Logger log.Logger // OnMessageSentHandler is called after a message is successfully sent (optional) @@ -178,17 +208,26 @@ func NewWebsocketConnection(config WebsocketConnectionConfig) (*WebsocketConnect if config.PongTimeout <= 0 { config.PongTimeout = defaultWsConnPongTimeout } + if config.MaxMessageSize <= 0 { + config.MaxMessageSize = defaultWsConnMaxMessageSize + } + if config.FrameRateLimiter == nil { + config.FrameRateLimiter = NoopFrameRateLimiter{} + } if config.OnMessageSentHandler == nil { config.OnMessageSentHandler = func([]byte) {} } return &WebsocketConnection{ - connectionID: config.ConnectionID, - origin: config.Origin, - websocketConn: config.WebsocketConn, - writeTimeout: config.WriteTimeout, - pingInterval: config.PingInterval, - pongTimeout: config.PongTimeout, + connectionID: config.ConnectionID, + origin: config.Origin, + applicationID: config.ApplicationID, + websocketConn: config.WebsocketConn, + writeTimeout: config.WriteTimeout, + pingInterval: config.PingInterval, + pongTimeout: config.PongTimeout, + maxMessageSize: config.MaxMessageSize, + frameRateLimiter: config.FrameRateLimiter, logger: config.Logger.WithKV("connectionID", config.ConnectionID), onMessageSentHandler: config.OnMessageSentHandler, @@ -219,6 +258,13 @@ func (conn *WebsocketConnection) Serve(parentCtx context.Context, handleClosure conn.ctx = parentCtx conn.mu.Unlock() + // Cap inbound frame size before any read takes place. Exceeding the limit + // causes the next ReadMessage to return *CloseError{Code: 1009} and the + // underlying connection sends a close frame to the client. + if conn.maxMessageSize > 0 { + conn.websocketConn.SetReadLimit(conn.maxMessageSize) + } + // Set up pong handler to refresh read deadline when pong is received from client. // This enables detection of dead connections - if no pong arrives within the timeout // after sending a ping, the read will fail and the connection will be closed. @@ -285,6 +331,12 @@ func (conn *WebsocketConnection) Origin() string { return conn.origin } +// ApplicationID returns the app_id query parameter supplied at WebSocket upgrade, +// or an empty string if none was provided. +func (conn *WebsocketConnection) ApplicationID() string { + return conn.applicationID +} + // RawRequests returns the channel for processing incoming requests. func (conn *WebsocketConnection) RawRequests() <-chan []byte { return conn.processSink @@ -322,10 +374,27 @@ func (conn *WebsocketConnection) readMessages(handleClosure func(error)) { for { _, messageBytes, err := conn.websocketConn.ReadMessage() if err != nil { - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure, websocket.CloseNormalClosure) { + switch { + case errors.Is(err, websocket.ErrReadLimit), + websocket.IsCloseError(err, websocket.CloseMessageTooBig): + // Expected attacker / misconfigured-client path. Local read limit + // exceeded → gorilla returns ErrReadLimit to us and best-effort + // sends close 1009 to the peer. The IsCloseError branch covers + // the symmetric case where the peer initiated a 1009 close. + // Treat both as graceful close, not abnormal termination. + conn.logger.Warn("inbound frame exceeded MaxMessageSize; closing connection", + "error", err, + "origin", conn.origin, + "max_message_size", conn.maxMessageSize, + ) + handleClosure(nil) + case websocket.IsUnexpectedCloseError(err, + websocket.CloseGoingAway, + websocket.CloseAbnormalClosure, + websocket.CloseNormalClosure): conn.logger.Error("WebSocket connection closed with unexpected reason", "error", err) handleClosure(err) - } else { + default: handleClosure(nil) // Normal closure } return @@ -336,6 +405,19 @@ func (conn *WebsocketConnection) readMessages(handleClosure func(error)) { continue // Skip empty messages } + if !conn.frameRateLimiter.Admit(time.Now(), len(messageBytes)) { + conn.logger.Warn("frame rate limit exceeded; closing connection", + "origin", conn.origin, + "frame_bytes", len(messageBytes), + ) + select { + case conn.closeConnCh <- struct{}{}: + default: + } + handleClosure(nil) + return + } + select { case conn.processSink <- messageBytes: // ok @@ -348,6 +430,7 @@ func (conn *WebsocketConnection) readMessages(handleClosure func(error)) { case conn.closeConnCh <- struct{}{}: default: } + handleClosure(nil) return } } diff --git a/pkg/rpc/connection_hub.go b/pkg/rpc/connection_hub.go index b92c5491b..83587761f 100644 --- a/pkg/rpc/connection_hub.go +++ b/pkg/rpc/connection_hub.go @@ -5,9 +5,10 @@ import ( "sync" ) -const defaultConnectionRegion = "default" - -type ObserveConnectionsFn func(region, origin string, count uint32) +// ObserveConnectionsFn is invoked on connect and disconnect with the current per-application +// connection count. A count of 0 signals that the bucket is empty and the observer should +// shed any per-label state (e.g., delete the Prometheus gauge series) to bound cardinality. +type ObserveConnectionsFn func(applicationID string, count uint32) // ConnectionHub provides centralized management of all active RPC connections. // It maintains thread-safe mappings between connection IDs and Connection instances, @@ -28,9 +29,9 @@ type ConnectionHub struct { // mu protects concurrent access to the maps mu sync.RWMutex - // sourceMap is an optional mapping of connection sources (e.g., IP addresses or regions) - sourceMap map[string]uint32 - // observeConnections is a callback function to monitor connection counts by region + // appConnCount tracks active connection counts keyed by application_id (may be empty string) + appConnCount map[string]uint32 + // observeConnections is a callback function to report per-application connection counts observeConnections ObserveConnectionsFn } @@ -41,7 +42,7 @@ func NewConnectionHub(observeConnections ObserveConnectionsFn) *ConnectionHub { return &ConnectionHub{ connections: make(map[string]Connection), authMapping: make(map[string]map[string]bool), - sourceMap: make(map[string]uint32), + appConnCount: make(map[string]uint32), observeConnections: observeConnections, } } @@ -60,18 +61,23 @@ func (hub *ConnectionHub) Add(conn Connection) error { connID := conn.ConnectionID() hub.mu.Lock() - defer hub.mu.Unlock() // If the connection already exists, return an error if _, exists := hub.connections[connID]; exists { + hub.mu.Unlock() return fmt.Errorf("connection with ID %s already exists", connID) } hub.connections[connID] = conn - sourceID := getSourceID(conn.Origin()) - hub.sourceMap[sourceID]++ - hub.observeConnections(defaultConnectionRegion, conn.Origin(), uint32(hub.sourceMap[sourceID])) + appID := conn.ApplicationID() + hub.appConnCount[appID]++ + count := hub.appConnCount[appID] + hub.mu.Unlock() + + // Invoke the observer outside the lock: SetRPCConnections takes Prometheus-internal + // mutexes, and holding hub.mu across that would serialize readers (including Publish). + hub.observeConnections(appID, count) return nil } @@ -103,22 +109,33 @@ func (hub *ConnectionHub) Get(connID string) Connection { // This method is safe for concurrent access. func (hub *ConnectionHub) Remove(connID string) { hub.mu.Lock() - defer hub.mu.Unlock() conn, exists := hub.connections[connID] if !exists { + hub.mu.Unlock() return // No connection to remove } delete(hub.connections, connID) - sourceID := getSourceID(conn.Origin()) - if count, exists := hub.sourceMap[sourceID]; exists && count > 0 { - hub.sourceMap[sourceID]-- - if hub.sourceMap[sourceID] == 0 { - delete(hub.sourceMap, sourceID) + appID := conn.ApplicationID() + count, tracked := hub.appConnCount[appID] + changed := false + if tracked && count > 0 { + hub.appConnCount[appID]-- + count = hub.appConnCount[appID] + if count == 0 { + delete(hub.appConnCount, appID) } + changed = true + } + hub.mu.Unlock() + + // Only notify the observer when the bucket actually changed; otherwise we would + // emit DeleteLabelValues for an app_id the gauge never tracked. Invoke outside + // hub.mu to avoid serializing readers behind Prometheus-internal locks. + if changed { + hub.observeConnections(appID, count) } - hub.observeConnections(defaultConnectionRegion, conn.Origin(), uint32(hub.sourceMap[sourceID])) } // Publish broadcasts a message to all active connections for a specific user. @@ -153,6 +170,3 @@ func (hub *ConnectionHub) Publish(userID string, response []byte) { } } -func getSourceID(origin string) string { - return origin -} diff --git a/pkg/rpc/connection_test.go b/pkg/rpc/connection_test.go index 294073dc7..4c5987b42 100644 --- a/pkg/rpc/connection_test.go +++ b/pkg/rpc/connection_test.go @@ -92,6 +92,179 @@ func TestWebsocketConnection_Serve(t *testing.T) { require.Equal(t, 2, wsConnMock.getCalledCloseCount()) } +func TestWebsocketConnection_Serve_AppliesReadLimit(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wsConnMock := newGorillaWsConnMock(ctx) + cfg := rpc.WebsocketConnectionConfig{ + ConnectionID: "conn-readlimit", + WebsocketConn: wsConnMock, + MaxMessageSize: 64 * 1024, + } + conn, err := rpc.NewWebsocketConnection(cfg) + require.NoError(t, err) + + conn.Serve(ctx, func(error) {}) + + // Serve calls SetReadLimit synchronously before spawning any goroutine, so + // the limit is observable immediately on return — no polling needed. + require.Equal(t, int64(64*1024), wsConnMock.getReadLimit()) +} + +// TestWebsocketConnection_LocalReadLimit_GracefulClose simulates the local +// SetReadLimit hitting on an inbound frame. Gorilla returns ErrReadLimit to +// the application (and best-effort sends close 1009 to the peer over the wire). +// The connection must treat this as a graceful close, not an abnormal error. +func TestWebsocketConnection_LocalReadLimit_GracefulClose(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wsConnMock := newGorillaWsConnMock(ctx) + cfg := rpc.WebsocketConnectionConfig{ + ConnectionID: "conn-readlimit", + WebsocketConn: wsConnMock, + } + conn, err := rpc.NewWebsocketConnection(cfg) + require.NoError(t, err) + + closureCh := make(chan error, 1) + conn.Serve(ctx, func(err error) { closureCh <- err }) + + wsConnMock.readErrCh <- websocket.ErrReadLimit + + select { + case err := <-closureCh: + require.NoError(t, err, "ErrReadLimit must close gracefully, not as abnormal error") + case <-time.After(500 * time.Millisecond): + t.Fatal("connection did not close after local read-limit hit") + } +} + +// TestWebsocketConnection_PeerInitiated1009_GracefulClose covers the symmetric +// case: the peer initiates a close with code 1009 (their read limit hit). +// Gorilla surfaces a *CloseError{1009} on ReadMessage in that case. +func TestWebsocketConnection_PeerInitiated1009_GracefulClose(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wsConnMock := newGorillaWsConnMock(ctx) + cfg := rpc.WebsocketConnectionConfig{ + ConnectionID: "conn-peer1009", + WebsocketConn: wsConnMock, + } + conn, err := rpc.NewWebsocketConnection(cfg) + require.NoError(t, err) + + closureCh := make(chan error, 1) + conn.Serve(ctx, func(err error) { closureCh <- err }) + + wsConnMock.readErrCh <- &websocket.CloseError{ + Code: websocket.CloseMessageTooBig, + Text: "peer-side read limit exceeded", + } + + select { + case err := <-closureCh: + require.NoError(t, err, "peer-sent 1009 must close gracefully, not as abnormal error") + case <-time.After(500 * time.Millisecond): + t.Fatal("connection did not close after peer-initiated 1009") + } +} + +// stubLimiter rejects after the Nth call. Records every call for assertions. +type stubLimiter struct { + rejectAt int + calls int +} + +func (s *stubLimiter) Admit(_ time.Time, _ int) bool { + s.calls++ + return s.calls < s.rejectAt +} + +func TestWebsocketConnection_RateLimitedFrame_ClosesConnection(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wsConnMock := newGorillaWsConnMock(ctx) + limiter := &stubLimiter{rejectAt: 2} // first frame admits, second rejects + cfg := rpc.WebsocketConnectionConfig{ + ConnectionID: "conn-ratelimit", + WebsocketConn: wsConnMock, + FrameRateLimiter: limiter, + } + conn, err := rpc.NewWebsocketConnection(cfg) + require.NoError(t, err) + + closureCh := make(chan error, 1) + conn.Serve(ctx, func(err error) { closureCh <- err }) + + // First frame admitted, drained by reader. + wsConnMock.addMessageToRead("ok") + select { + case got := <-conn.RawRequests(): + require.Equal(t, "ok", string(got)) + case <-time.After(200 * time.Millisecond): + t.Fatal("first frame not delivered") + } + + // Second frame rejected by limiter → connection should close. + wsConnMock.addMessageToRead("blocked") + + select { + case err := <-closureCh: + require.NoError(t, err, "rate-limit close is graceful") + case <-time.After(500 * time.Millisecond): + t.Fatal("connection did not close after rate-limited frame") + } + require.Equal(t, 2, limiter.calls, "limiter was consulted for both frames") +} + +func TestWebsocketConnection_ServeQueueFullClosesCleanly(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wsConnMock := newGorillaWsConnMock(ctx) + + cfg := rpc.WebsocketConnectionConfig{ + ConnectionID: "conn1", + WebsocketConn: wsConnMock, + ProcessBufferSize: 1, + } + conn, err := rpc.NewWebsocketConnection(cfg) + require.NoError(t, err) + + closed := make(chan struct{}) + conn.Serve(ctx, func(error) { close(closed) }) + + // First message fills processSink (no consumer reads RawRequests). + wsConnMock.addMessageToRead("msg1") + // Second message hits the queue-full branch. Without a handleClosure call, + // the internal wait group blocks forever and the parent closure never fires. + wsConnMock.addMessageToRead("msg2") + + select { + case <-closed: + case <-time.After(2 * time.Second): + t.Fatal("parent handleClosure not invoked after processSink overflow; goroutine leak") + } + + require.Eventually(t, func() bool { + return wsConnMock.getCalledCloseCount() == 1 + }, time.Second, 10*time.Millisecond, "underlying WebSocket Close() not called") +} + func TestWebsocketConnection_ConnectionID(t *testing.T) { t.Parallel() @@ -125,8 +298,10 @@ func TestWebsocketConnection_WriteRawResponse(t *testing.T) { type gorillaWsConnMock struct { ctx context.Context messageToReadCh chan []byte + readErrCh chan error lastWrittenMessage []byte calledCloseCount int + readLimit int64 mu sync.Mutex } @@ -135,6 +310,7 @@ func newGorillaWsConnMock(ctx context.Context) *gorillaWsConnMock { return &gorillaWsConnMock{ ctx: ctx, messageToReadCh: make(chan []byte, 1), + readErrCh: make(chan error, 1), } } @@ -145,6 +321,8 @@ func (m *gorillaWsConnMock) ReadMessage() (messageType int, p []byte, err error) Code: websocket.CloseNormalClosure, Text: "context cancelled", } + case err := <-m.readErrCh: + return 0, nil, err case msg := <-m.messageToReadCh: // Simulate reading a message return websocket.TextMessage, msg, nil @@ -202,3 +380,17 @@ func (m *gorillaWsConnMock) SetReadDeadline(t time.Time) error { // No-op for mock return nil } + +func (m *gorillaWsConnMock) SetReadLimit(limit int64) { + m.mu.Lock() + defer m.mu.Unlock() + + m.readLimit = limit +} + +func (m *gorillaWsConnMock) getReadLimit() int64 { + m.mu.Lock() + defer m.mu.Unlock() + + return m.readLimit +} diff --git a/pkg/rpc/node.go b/pkg/rpc/node.go index e1cc2d9e6..55c8f6a32 100644 --- a/pkg/rpc/node.go +++ b/pkg/rpc/node.go @@ -132,6 +132,14 @@ type WebsocketNodeConfig struct { WsConnWriteBufferSize int // WsConnProcessBufferSize is the capacity of each connection's incoming message queue (default: 10). WsConnProcessBufferSize int + // WsConnMaxMessageSize caps inbound WebSocket frame size in bytes per connection + // (default: 128 KiB). Frames exceeding this trigger close 1009 before allocation. + // Non-positive values fall back to the default; the cap cannot be disabled at + // this layer. + WsConnMaxMessageSize int64 + // NewFrameRateLimiter constructs a per-connection FrameRateLimiter on each + // upgrade. nil → no enforcement (NoopFrameRateLimiter is used). + NewFrameRateLimiter func() FrameRateLimiter } // NewWebsocketNode creates a new WebsocketNode instance with the provided configuration. @@ -149,7 +157,7 @@ func NewWebsocketNode(config WebsocketNodeConfig) (*WebsocketNode, error) { if config.ObserveConnections == nil { // Default implementation does nothing, but can be overridden for monitoring - config.ObserveConnections = func(region, origin string, count uint32) {} + config.ObserveConnections = func(applicationID string, count uint32) {} } if config.WsUpgraderReadBufferSize <= 0 { // It's the optimal default value as recommended @@ -220,13 +228,21 @@ func (wn *WebsocketNode) ServeHTTP(w http.ResponseWriter, r *http.Request) { connectionID := uuid.NewString() + var limiter FrameRateLimiter + if wn.cfg.NewFrameRateLimiter != nil { + limiter = wn.cfg.NewFrameRateLimiter() + } + connConfig := WebsocketConnectionConfig{ ConnectionID: connectionID, Origin: r.Header.Get("Origin"), + ApplicationID: applicationID, WebsocketConn: wsConnection, Logger: wn.cfg.Logger, ProcessBufferSize: wn.cfg.WsConnProcessBufferSize, WriteBufferSize: wn.cfg.WsConnWriteBufferSize, + MaxMessageSize: wn.cfg.WsConnMaxMessageSize, + FrameRateLimiter: limiter, } connection, err := NewWebsocketConnection(connConfig) if err != nil { diff --git a/pkg/rpc/rate_limiter.go b/pkg/rpc/rate_limiter.go new file mode 100644 index 000000000..dae245d0a --- /dev/null +++ b/pkg/rpc/rate_limiter.go @@ -0,0 +1,70 @@ +package rpc + +import "time" + +// FrameRateLimiter decides whether an inbound WebSocket frame is admitted. +// Implementations attached to a single connection may assume serial access +// from the connection's read goroutine; implementations shared across +// connections must be safe for concurrent use. +// +// Returning false causes the connection to close. +// +// Allocation note: Admit runs after the frame has been read off the wire and +// allocated on the Go heap. Per-frame size is bounded by SetReadLimit (see +// WebsocketConnectionConfig.MaxMessageSize), but a burst of N back-to-back +// max-sized frames can briefly hold up to N * MaxMessageSize bytes per +// connection before this hook closes it. Burst capacity should be sized with +// that ceiling in mind. +type FrameRateLimiter interface { + // Admit reports whether a frame of size bytes is permitted at now. + Admit(now time.Time, size int) bool +} + +// NoopFrameRateLimiter accepts every frame. Default when no limiter is +// configured; useful for tests and dev environments. +type NoopFrameRateLimiter struct{} + +// Admit always returns true. +func (NoopFrameRateLimiter) Admit(time.Time, int) bool { return true } + +// ByteTokenBucket is a token bucket on bytes read. One bucket per connection. +// Not safe for concurrent use; the connection's read goroutine is the sole +// caller of Admit. +type ByteTokenBucket struct { + bytesPerSec float64 + burst float64 + tokens float64 + last time.Time +} + +// NewByteTokenBucket returns a bucket pre-filled to burst capacity. +// +// bytesPerSec is the steady-state refill rate in bytes per second. +// burst is the maximum bucket size; a single frame larger than burst is +// always rejected. +func NewByteTokenBucket(bytesPerSec, burst float64) *ByteTokenBucket { + return &ByteTokenBucket{ + bytesPerSec: bytesPerSec, + burst: burst, + tokens: burst, + } +} + +// Admit refills tokens for elapsed time, caps at burst, then debits size. +// Returns false if size exceeds available tokens. +func (b *ByteTokenBucket) Admit(now time.Time, size int) bool { + if !b.last.IsZero() { + b.tokens += now.Sub(b.last).Seconds() * b.bytesPerSec + if b.tokens > b.burst { + b.tokens = b.burst + } + } + b.last = now + + cost := float64(size) + if b.tokens < cost { + return false + } + b.tokens -= cost + return true +} diff --git a/pkg/rpc/rate_limiter_test.go b/pkg/rpc/rate_limiter_test.go new file mode 100644 index 000000000..cc5b3e2fd --- /dev/null +++ b/pkg/rpc/rate_limiter_test.go @@ -0,0 +1,72 @@ +package rpc_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitrolite/pkg/rpc" +) + +func TestByteTokenBucket_BurstThenEmpty(t *testing.T) { + t.Parallel() + + b := rpc.NewByteTokenBucket(1024, 4096) + base := time.Unix(0, 0) + + require.True(t, b.Admit(base, 4096), "full burst admitted") + require.False(t, b.Admit(base, 1), "empty bucket rejects") +} + +func TestByteTokenBucket_Refill(t *testing.T) { + t.Parallel() + + b := rpc.NewByteTokenBucket(1024, 4096) + base := time.Unix(0, 0) + + require.True(t, b.Admit(base, 4096)) + require.False(t, b.Admit(base, 1)) + require.True(t, b.Admit(base.Add(time.Second), 1024), "1s of refill admits 1024 bytes") + require.False(t, b.Admit(base.Add(time.Second), 1), "bucket emptied again") +} + +func TestByteTokenBucket_BurstCap(t *testing.T) { + t.Parallel() + + b := rpc.NewByteTokenBucket(1024, 4096) + base := time.Unix(0, 0) + + // Long idle must not let tokens grow past burst. + require.True(t, b.Admit(base.Add(time.Hour), 4096)) + require.False(t, b.Admit(base.Add(time.Hour), 1)) +} + +func TestByteTokenBucket_FrameLargerThanBurstRejected(t *testing.T) { + t.Parallel() + + b := rpc.NewByteTokenBucket(1024, 4096) + require.False(t, b.Admit(time.Unix(0, 0), 4097), + "frame larger than burst is always rejected") +} + +func TestByteTokenBucket_PartialRefill(t *testing.T) { + t.Parallel() + + b := rpc.NewByteTokenBucket(1000, 1000) + base := time.Unix(0, 0) + + require.True(t, b.Admit(base, 1000)) + require.False(t, b.Admit(base.Add(500*time.Millisecond), 501), + "500ms refills 500 bytes, not enough for 501") + require.True(t, b.Admit(base.Add(500*time.Millisecond), 500), + "500ms refills exactly 500 bytes") +} + +func TestNoopFrameRateLimiter_AdmitsAll(t *testing.T) { + t.Parallel() + + var lim rpc.FrameRateLimiter = rpc.NoopFrameRateLimiter{} + require.True(t, lim.Admit(time.Now(), 1<<30)) + require.True(t, lim.Admit(time.Time{}, 0)) +} diff --git a/pkg/rpc/types.go b/pkg/rpc/types.go index a5ca97330..02ea60ea3 100644 --- a/pkg/rpc/types.go +++ b/pkg/rpc/types.go @@ -71,6 +71,8 @@ type ChannelSessionKeyStateV1 struct { ExpiresAt string `json:"expires_at"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key UserSig string `json:"user_sig"` + // SessionKeySig is the session-key holder's signature proving possession of the key being registered. + SessionKeySig string `json:"session_key_sig"` } // ============================================================================ @@ -214,6 +216,8 @@ type AppSessionKeyStateV1 struct { ExpiresAt string `json:"expires_at"` // UserSig is the user's signature over the session key metadata to authorize the registration/update of the session key UserSig string `json:"user_sig"` + // SessionKeySig is the session-key holder's signature proving possession of the key being registered. + SessionKeySig string `json:"session_key_sig"` } // ============================================================================ @@ -364,3 +368,8 @@ type PaginationMetadataV1 struct { // PageCount is the total number of pages PageCount uint32 `json:"page_count"` } + +// GetLastKeyStatesPageLimit is the API contract for the channels.v1 / app_sessions.v1 +// get_last_key_states endpoints: both the default and the maximum page size are 10. +// Defined in pkg/rpc so the two handler packages share a single source of truth. +const GetLastKeyStatesPageLimit uint32 = 10 diff --git a/pkg/sign/eth_msg_signer.go b/pkg/sign/eth_msg_signer.go index 8b376f07d..7f4fe1779 100644 --- a/pkg/sign/eth_msg_signer.go +++ b/pkg/sign/eth_msg_signer.go @@ -20,8 +20,8 @@ func (s *EthereumMsgSigner) Sign(hash []byte) (Signature, error) { return Signature(sig), nil } -// NewEthereumRawSigner creates a new Ethereum signer from a hex-encoded private key. -func NewEthereumMsgSigner(privateKeyHex string) (Signer, error) { +// NewEthereumMsgSigner creates a new Ethereum signer from a hex-encoded private key. +func NewEthereumMsgSigner(privateKeyHex string) (*EthereumMsgSigner, error) { signer, err := NewEthereumRawSigner(privateKeyHex) if err != nil { return nil, err @@ -30,8 +30,8 @@ func NewEthereumMsgSigner(privateKeyHex string) (Signer, error) { return NewEthereumMsgSignerFromRaw(signer) } -// NewEthereumRawSignerFronRaw creates a new Ethereum signer from an existing Signer instance. -func NewEthereumMsgSignerFromRaw(signer Signer) (Signer, error) { +// NewEthereumMsgSignerFromRaw creates a new Ethereum signer from an existing Signer instance. +func NewEthereumMsgSignerFromRaw(signer Signer) (*EthereumMsgSigner, error) { return &EthereumMsgSigner{ signer, }, nil diff --git a/protocol-description.md b/protocol-description.md index 9cab99a04..698979e94 100644 --- a/protocol-description.md +++ b/protocol-description.md @@ -559,9 +559,23 @@ This 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 -* Events emit the original signed state (before swap) for off-chain observability +* Migration lifecycle events (`MigrationInInitiated`, `MigrationOutInitiated`, `MigrationInFinalized`, `MigrationOutFinalized`) emit the original signed state (before swap) for off-chain observability +* `ChannelClosed` is an exception: it emits `meta.lastState`, which on the new home chain is the already-swapped state stored during `initiateMigration()` * This approach maintains the critical invariant: **ChannelEngine always sees homeLedger as the current chain** +#### Dual-close during abandoned migration + +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`): emits `finalState` with `homeLedger` = old home chain (original orientation) +* New home chain (`MIGRATING_IN`/`DISPUTED`): emits `finalState` with `homeLedger` = 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 `ChannelClosed` events by `(chainId, ChannelHub, channelId)`, never by `channelId` alone +* Treat each emission as a distinct local settlement; there is no single canonical `finalState` for an abandoned migration +* Code that persists the emitted `finalState` must handle the swapped ledger orientation for channels in `MIGRATING_IN` status + --- ## Security model summary @@ -582,8 +596,9 @@ This works because `prevStoredState` was swapped during `INITIATE_MIGRATION`. * **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 calls `IERC20Metadata.decimals()` on every state transition; tokens that do not implement this optional ERC-20 extension are rejected on-chain with `FailedToFetchDecimals`. Unlike rebasing and fee-on-transfer issues (which silently corrupt accounting), missing `decimals()` causes an immediate hard revert. - 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. + 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: @@ -595,6 +610,8 @@ This works because `prevStoredState` was swapped during `INITIATE_MIGRATION`. 2. **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. + --- ## Signature validation @@ -614,6 +631,7 @@ Since `approvedSignatureValidators` is part of the `channelId` computation, agre * 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 raw `keccak256` signatures (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. ### Node validator registry @@ -690,7 +708,7 @@ The current implementation binds each ChannelHub to a single node address at dep 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: a newly registered validator cannot be used until the delay has elapsed, creating an observable window during which a key compromise can be detected and users can revoke ERC20 approvals before the attack on undeposited funds can execute. +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. @@ -706,6 +724,25 @@ Subsequent operations accept both tiers, filtered by the bitmask stored at creat --- +## Informational Events + +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 a `MIGRATING_IN` channel transitions to `OPERATING` via any standard operation (deposit, withdraw, checkpoint, challenge) rather than an explicit `finalizeMigration()` call. The canonical migration completion signal is `MigrationOutFinalized`, 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). + +--- + ## Mental model * Off-chain protocol **decides what should happen**. diff --git a/scripts/drift/runtime-smoke.mjs b/scripts/drift/runtime-smoke.mjs index 9edf6cb96..dbaab94cb 100644 --- a/scripts/drift/runtime-smoke.mjs +++ b/scripts/drift/runtime-smoke.mjs @@ -328,10 +328,10 @@ async function runSmoke() { ); assertSmoke(Array.isArray(channelKeyStates), 'transform', 'channel key states is not an array'); - logStep('calling getLastKeyStates'); + logStep('calling getLastAppKeyStates'); const appSessionKeyStates = await withTimeout( - 'client.getLastKeyStates', - client.getLastKeyStates(wallet) + 'client.getLastAppKeyStates', + client.getLastAppKeyStates(wallet) ); assertSmoke(Array.isArray(appSessionKeyStates), 'transform', 'app session key states is not an array'); diff --git a/sdk/go/README.md b/sdk/go/README.md index d021a3718..e952e82d4 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -68,16 +68,18 @@ client.RebalanceAppSessions(ctx, signedUpdates) // Atomic rebalanc ### Session Keys — App Sessions ```go -client.SignSessionKeyState(state) // Sign an app session key state -client.SubmitAppSessionKeyState(ctx, state) // Register/update app session key -client.GetLastAppKeyStates(ctx, userAddress, opts) // Get active app session key states +client.SignSessionKeyState(state) // Wallet UserSig over app session key state +sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's SessionKeySig +client.SubmitAppSessionKeyState(ctx, state) // Register/update app session key (both sigs required) +client.GetLastAppKeyStates(ctx, userAddress, opts) // Get app session key states (active-only by default; opts.IncludeInactive=true to include expired) ``` ### Session Keys — Channels ```go -client.SignChannelSessionKeyState(state) // Sign a channel session key state -client.SubmitChannelSessionKeyState(ctx, state) // Register/update channel session key -client.GetLastChannelKeyStates(ctx, userAddress, opts) // Get active channel session key states +client.SignChannelSessionKeyState(state) // Wallet UserSig over channel session key state +sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's SessionKeySig +client.SubmitChannelSessionKeyState(ctx, state) // Register/update channel session key (both sigs required) +client.GetLastChannelKeyStates(ctx, userAddress, opts) // Get channel session key states (active-only by default; opts.IncludeInactive=true to include expired) ``` ### Shared Utilities @@ -418,8 +420,15 @@ sig, _ := appSessionSigner.Sign(packedRequest) ### Session Keys — App Sessions +Registration requires two signatures: the wallet's `UserSig` (authorizing the delegation) +and the session-key holder's `SessionKeySig` (proving possession of the key being +registered). The node rejects submits that lack a valid `SessionKeySig`. + ```go -// Sign and submit an app session key state +// sessionKeySigner must be a *sign.EthereumMsgSigner (raw EIP-191 signer) +// whose address equals state.SessionKey — not a wrapped sign.Signer, because +// the node recovers SessionKeySig as a raw 65-byte Ethereum message signature. +sessionKeySigner, _ := sign.NewEthereumMsgSigner(sessionKeyPrivHex) state := app.AppSessionKeyStateV1{ UserAddress: client.GetUserAddress(), SessionKey: "0xSessionKey...", @@ -428,21 +437,31 @@ state := app.AppSessionKeyStateV1{ AppSessionIDs: []string{}, ExpiresAt: time.Now().Add(24 * time.Hour), } -sig, err := client.SignSessionKeyState(state) -state.UserSig = sig -err = client.SubmitAppSessionKeyState(ctx, state) +state.UserSig, _ = client.SignSessionKeyState(state) +state.SessionKeySig, _ = sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) +err := client.SubmitAppSessionKeyState(ctx, state) -// Query active app session key states +// Query app session key states (active-only by default) states, err := client.GetLastAppKeyStates(ctx, userAddress, nil) states, err := client.GetLastAppKeyStates(ctx, userAddress, &sdk.GetLastKeyStatesOptions{ SessionKey: &sessionKeyAddr, }) + +// Include expired/revoked latest states (e.g. for rotation flows that need the prior version) +includeInactive := true +states, err = client.GetLastAppKeyStates(ctx, userAddress, &sdk.GetLastKeyStatesOptions{ + SessionKey: &sessionKeyAddr, + IncludeInactive: &includeInactive, +}) ``` ### Session Keys — Channels ```go -// Sign and submit a channel session key state +// sessionKeySigner must be a *sign.EthereumMsgSigner (raw EIP-191 signer) +// whose address equals state.SessionKey — not a wrapped sign.Signer, because +// the node recovers SessionKeySig as a raw 65-byte Ethereum message signature. +sessionKeySigner, _ := sign.NewEthereumMsgSigner(sessionKeyPrivHex) state := core.ChannelSessionKeyStateV1{ UserAddress: client.GetUserAddress(), SessionKey: "0xSessionKey...", @@ -450,15 +469,22 @@ state := core.ChannelSessionKeyStateV1{ Assets: []string{"usdc", "weth"}, ExpiresAt: time.Now().Add(24 * time.Hour), } -sig, err := client.SignChannelSessionKeyState(state) -state.UserSig = sig -err = client.SubmitChannelSessionKeyState(ctx, state) +state.UserSig, _ = client.SignChannelSessionKeyState(state) +state.SessionKeySig, _ = sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) +err := client.SubmitChannelSessionKeyState(ctx, state) -// Query active channel session key states +// Query channel session key states (active-only by default) states, err := client.GetLastChannelKeyStates(ctx, userAddress, nil) states, err := client.GetLastChannelKeyStates(ctx, userAddress, &sdk.GetLastChannelKeyStatesOptions{ SessionKey: &sessionKeyAddr, }) + +// Include expired/revoked latest states (e.g. for rotation flows that need the prior version) +includeInactive := true +states, err = client.GetLastChannelKeyStates(ctx, userAddress, &sdk.GetLastChannelKeyStatesOptions{ + SessionKey: &sessionKeyAddr, + IncludeInactive: &includeInactive, +}) ``` ## Key Concepts diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index cd91b852e..9fc11ed25 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -8,6 +8,7 @@ import ( "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/rpc" + "github.com/layer-3/nitrolite/pkg/sign" "github.com/shopspring/decimal" ) @@ -281,7 +282,9 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // ============================================================================ // SubmitAppSessionKeyState submits a session key state for registration or update. -// The state must be signed by the user's wallet to authorize the session key delegation. +// The state must carry both the wallet's UserSig (proving the user authorized the +// delegation) and the session-key holder's SessionKeySig (proving possession of the key +// being registered). Submits without a valid SessionKeySig are rejected. // // Parameters: // - state: The session key state containing delegation information @@ -298,8 +301,9 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // ApplicationIDs: []string{"app1"}, // AppSessionIDs: []string{}, // ExpiresAt: time.Now().Add(24 * time.Hour), -// UserSig: "0x...", // } +// state.UserSig, _ = client.SignSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) // err := client.SubmitAppSessionKeyState(ctx, state) func (c *Client) SubmitAppSessionKeyState(ctx context.Context, state app.AppSessionKeyStateV1) error { req := rpc.AppSessionsV1SubmitSessionKeyStateRequest{ @@ -316,16 +320,21 @@ func (c *Client) SubmitAppSessionKeyState(ctx context.Context, state app.AppSess type GetLastKeyStatesOptions struct { // SessionKey filters by a specific session key address SessionKey *string + // IncludeInactive, when set to true, includes latest states whose expires_at is in + // the past (expired or revoked). Defaults to false (active-only) when nil or false. + IncludeInactive *bool } -// GetLastAppKeyStates retrieves the latest session key states for a user. +// GetLastAppKeyStates retrieves the latest app session key states for a user. +// By default only currently active (non-expired) states are returned; set +// opts.IncludeInactive to true to include expired or revoked latest states. // // Parameters: // - userAddress: The user's wallet address -// - opts: Optional filters (pass nil for no filters) +// - opts: Optional filters (pass nil for active-only with no session-key filter) // // Returns: -// - Slice of AppSessionKeyStateV1 with the latest non-expired session key states +// - Slice of AppSessionKeyStateV1 with the latest session key states matching the filter // - Error if the request fails // // Example: @@ -340,6 +349,7 @@ func (c *Client) GetLastAppKeyStates(ctx context.Context, userAddress string, op } if opts != nil { req.SessionKey = opts.SessionKey + req.IncludeInactive = opts.IncludeInactive } resp, err := c.rpcClient.AppSessionsV1GetLastKeyStates(ctx, req) @@ -355,12 +365,13 @@ func (c *Client) GetLastAppKeyStates(ctx context.Context, userAddress string, op return states, nil } -// SignSessionKeyState signs a session key state using the client's state signer. -// This creates a properly formatted signature that can be set on the state's UserSig field -// before submitting via SubmitSessionKeyState. +// SignSessionKeyState produces the wallet UserSig over the session key state using the +// client's state signer. Set the returned hex on state.UserSig before submit. The matching +// session-key-holder SessionKeySig must also be populated (see SignAppSessionKeyOwnership) +// — submits with only one of the two are rejected. // // Parameters: -// - state: The session key state to sign (UserSig field is excluded from signing) +// - state: The session key state to sign (UserSig and SessionKeySig fields are excluded from signing) // // Returns: // - The hex-encoded signature string @@ -376,9 +387,9 @@ func (c *Client) GetLastAppKeyStates(ctx context.Context, userAddress string, op // AppSessionIDs: []string{}, // ExpiresAt: time.Now().Add(24 * time.Hour), // } -// sig, err := client.SignSessionKeyState(state) -// state.UserSig = sig -// err = client.SubmitSessionKeyState(ctx, state) +// state.UserSig, _ = client.SignSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignAppSessionKeyOwnership(state, sessionKeySigner) +// err = client.SubmitAppSessionKeyState(ctx, state) func (c *Client) SignSessionKeyState(state app.AppSessionKeyStateV1) (string, error) { packed, err := app.PackAppSessionKeyStateV1(state) if err != nil { @@ -393,3 +404,26 @@ func (c *Client) SignSessionKeyState(state app.AppSessionKeyStateV1) (string, er // Strip the channel signer type prefix byte; session key auth uses plain EIP-191 signatures return hexutil.Encode(sig[1:]), nil } + +// SignAppSessionKeyOwnership produces the session-key holder's ownership signature over the +// packed app-session key state. The signer must be the holder of the session key being +// registered; the resulting hex-encoded signature is intended to populate state.SessionKeySig +// before submitting via SubmitAppSessionKeyState. The packed state already binds user_address, +// so replay across wallets is not possible. +// +// The parameter is narrowed to *sign.EthereumMsgSigner because the server recovers +// SessionKeySig under sign.TypeEthereumMsg — a broader signer interface could produce a +// signature without the EIP-191 prefix (or with extra wrapper bytes) that the server rejects. +func SignAppSessionKeyOwnership(state app.AppSessionKeyStateV1, sessionKeySigner *sign.EthereumMsgSigner) (string, error) { + packed, err := app.PackAppSessionKeyStateV1(state) + if err != nil { + return "", fmt.Errorf("failed to pack session key state: %w", err) + } + + sig, err := sessionKeySigner.Sign(packed) + if err != nil { + return "", fmt.Errorf("failed to sign session key ownership: %w", err) + } + + return hexutil.Encode(sig), nil +} diff --git a/sdk/go/channel.go b/sdk/go/channel.go index 380150a51..46b425343 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -848,10 +848,15 @@ func (c *Client) requestChannelCreation(ctx context.Context, state core.State, c type GetLastChannelKeyStatesOptions struct { // SessionKey filters by a specific session key address SessionKey *string + // IncludeInactive, when set to true, includes latest states whose expires_at is in + // the past (expired or revoked). Defaults to false (active-only) when nil or false. + IncludeInactive *bool } // SubmitChannelSessionKeyState submits a channel session key state for registration or update. -// The state must be signed by the user's wallet to authorize the session key delegation. +// The state must carry both the wallet's UserSig (proving the user authorized the +// delegation) and the session-key holder's SessionKeySig (proving possession of the key +// being registered). Submits without a valid SessionKeySig are rejected. // // Parameters: // - state: The channel session key state containing delegation information @@ -867,8 +872,9 @@ type GetLastChannelKeyStatesOptions struct { // Version: 1, // Assets: []string{"usdc", "weth"}, // ExpiresAt: time.Now().Add(24 * time.Hour), -// UserSig: "0x...", // } +// state.UserSig, _ = client.SignChannelSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) // err := client.SubmitChannelSessionKeyState(ctx, state) func (c *Client) SubmitChannelSessionKeyState(ctx context.Context, state core.ChannelSessionKeyStateV1) error { req := rpc.ChannelsV1SubmitSessionKeyStateRequest{ @@ -882,13 +888,15 @@ func (c *Client) SubmitChannelSessionKeyState(ctx context.Context, state core.Ch } // GetLastChannelKeyStates retrieves the latest channel session key states for a user. +// By default only currently active (non-expired) states are returned; set +// opts.IncludeInactive to true to include expired or revoked latest states. // // Parameters: // - userAddress: The user's wallet address -// - opts: Optional filters (pass nil for no filters) +// - opts: Optional filters (pass nil for active-only with no session-key filter) // // Returns: -// - Slice of ChannelSessionKeyStateV1 with the latest non-expired session key states +// - Slice of ChannelSessionKeyStateV1 with the latest session key states matching the filter // - Error if the request fails // // Example: @@ -903,6 +911,7 @@ func (c *Client) GetLastChannelKeyStates(ctx context.Context, userAddress string } if opts != nil { req.SessionKey = opts.SessionKey + req.IncludeInactive = opts.IncludeInactive } resp, err := c.rpcClient.ChannelsV1GetLastKeyStates(ctx, req) @@ -918,12 +927,13 @@ func (c *Client) GetLastChannelKeyStates(ctx context.Context, userAddress string return states, nil } -// SignChannelSessionKeyState signs a channel session key state using the client's state signer. -// This creates a properly formatted signature that can be set on the state's UserSig field -// before submitting via SubmitChannelSessionKeyState. +// SignChannelSessionKeyState produces the wallet UserSig over the channel session key +// state using the client's state signer. Set the returned hex on state.UserSig before +// submit. The matching session-key-holder SessionKeySig must also be populated (see +// SignChannelSessionKeyOwnership) — submits with only one of the two are rejected. // // Parameters: -// - state: The channel session key state to sign (UserSig field is excluded from signing) +// - state: The channel session key state to sign (UserSig and SessionKeySig fields are excluded from signing) // // Returns: // - The hex-encoded signature string @@ -938,11 +948,11 @@ func (c *Client) GetLastChannelKeyStates(ctx context.Context, userAddress string // Assets: []string{"usdc"}, // ExpiresAt: time.Now().Add(24 * time.Hour), // } -// sig, err := client.SignChannelSessionKeyState(state) -// state.UserSig = sig +// state.UserSig, _ = client.SignChannelSessionKeyState(state) +// state.SessionKeySig, _ = sdk.SignChannelSessionKeyOwnership(state, sessionKeySigner) // err = client.SubmitChannelSessionKeyState(ctx, state) func (c *Client) SignChannelSessionKeyState(state core.ChannelSessionKeyStateV1) (string, error) { - metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.Version, state.Assets, state.ExpiresAt.Unix()) + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.UserAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) if err != nil { return "", fmt.Errorf("failed to compute metadata hash: %w", err) } @@ -965,6 +975,33 @@ func (c *Client) SignChannelSessionKeyState(state core.ChannelSessionKeyStateV1) return sig.String(), nil } +// SignChannelSessionKeyOwnership produces the session-key holder's ownership signature for a +// channel session key registration. The signer must hold the session key; the returned hex +// string populates state.SessionKeySig before submit. The signed payload binds session_key +// into the metadata hash so a signature minted for one key cannot be replayed for another. +// +// The parameter is narrowed to *sign.EthereumMsgSigner because the server recovers +// SessionKeySig under sign.TypeEthereumMsg — a broader signer interface could produce a +// signature without the EIP-191 prefix (or with extra wrapper bytes) that the server rejects. +func SignChannelSessionKeyOwnership(state core.ChannelSessionKeyStateV1, sessionKeySigner *sign.EthereumMsgSigner) (string, error) { + metadataHash, err := core.GetChannelSessionKeyAuthMetadataHashV1(state.UserAddress, state.Version, state.Assets, state.ExpiresAt.Unix()) + if err != nil { + return "", fmt.Errorf("failed to compute metadata hash: %w", err) + } + + packed, err := core.PackChannelKeyStateV1(state.SessionKey, metadataHash) + if err != nil { + return "", fmt.Errorf("failed to pack channel session key state: %w", err) + } + + sig, err := sessionKeySigner.Sign(packed) + if err != nil { + return "", fmt.Errorf("failed to sign channel session key ownership: %w", err) + } + + return sig.String(), nil +} + // ApproveToken approves the ChannelHub contract to spend tokens on behalf of the user. // This is required before depositing ERC-20 tokens. Native tokens (e.g., ETH) do not // require approval and will return an error if attempted. diff --git a/sdk/go/examples/app_sessions/lifecycle.go b/sdk/go/examples/app_sessions/lifecycle.go index efe0dbda7..3cb586df2 100644 --- a/sdk/go/examples/app_sessions/lifecycle.go +++ b/sdk/go/examples/app_sessions/lifecycle.go @@ -207,12 +207,21 @@ func main() { log.Fatal(err) } + // Wallet's UserSig authorizes the delegation. appSessionKey3StateSig, err := wallet3Signer.Sign(packedAppSessionKey3State) if err != nil { log.Fatal(err) } appSessionKey3State.UserSig = appSessionKey3StateSig.String() + // Session-key holder's SessionKeySig proves possession of the key being registered. + // Both signatures are required at submit time. + appSessionKey3OwnershipSig, err := msgSigner3.Sign(packedAppSessionKey3State) + if err != nil { + log.Fatal(err) + } + appSessionKey3State.SessionKeySig = appSessionKey3OwnershipSig.String() + if err := wallet3Client.SubmitAppSessionKeyState(context.Background(), appSessionKey3State); err != nil { log.Fatal(err) } diff --git a/sdk/go/examples/validator_watcher/main.go b/sdk/go/examples/validator_watcher/main.go new file mode 100644 index 000000000..dae917592 --- /dev/null +++ b/sdk/go/examples/validator_watcher/main.go @@ -0,0 +1,133 @@ +package main + +// Validator Registration Monitor +// +// This example shows how to watch for ValidatorRegistered events on the ChannelHub +// contract. App builders should run this monitoring loop and alert users whenever an +// unexpected validator is registered — users then have a 1-day window +// (VALIDATOR_ACTIVATION_DELAY) to revoke their ERC20 approvals before the validator +// becomes usable. See contracts/SECURITY.md for the full security context. +// +// The RPC URL must be a WebSocket endpoint (wss://) because event subscriptions +// require a persistent connection. HTTP endpoints are not supported. +// +// Gap-free monitoring: each event carries a BlockNumber. On reconnect the example +// passes lastBlock+1 as fromBlock so any events emitted during the outage are +// replayed before live events resume — the 1-day safety window is preserved even +// across network interruptions. + +import ( + "context" + "fmt" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/layer-3/nitrolite/pkg/core" + "github.com/layer-3/nitrolite/pkg/sign" + sdk "github.com/layer-3/nitrolite/sdk/go" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + nitronodeURL := "wss://nitronode-sandbox.yellow.org/v1/ws" + // WebSocket RPC is required for event subscriptions. + wsRPCURL := "wss://sepolia.drpc.org" + chainID := uint64(11155111) + + // Load private key from environment to avoid accidental exposure in error messages. + privateKeyHex := os.Getenv("PRIVATE_KEY") + if privateKeyHex == "" { + log.Fatal("PRIVATE_KEY env var not set") + } + + stateSigner, err := sign.NewEthereumMsgSigner(privateKeyHex) + if err != nil { + log.Fatalf("failed to create state signer: %v", err) + } + channelSigner, err := core.NewChannelDefaultSigner(stateSigner) + if err != nil { + log.Fatalf("failed to create channel signer: %v", err) + } + txSigner, err := sign.NewEthereumRawSigner(privateKeyHex) + if err != nil { + log.Fatalf("failed to create tx signer: %v", err) + } + + client, err := sdk.NewClient( + nitronodeURL, + channelSigner, + txSigner, + sdk.WithBlockchainRPC(chainID, wsRPCURL), + ) + if err != nil { + log.Fatalf("failed to create SDK client: %v", err) + } + defer client.Close() + + fmt.Printf("Monitoring ValidatorRegistered events on chain %d...\n", chainID) + fmt.Println("Press Ctrl+C to stop.") + + // fromBlock tracks where to resume on reconnect. Zero on the first call means + // "skip historical replay and start from the current chain head". Subsequent + // reconnects pass lastBlock+1 to replay any events emitted during the outage. + // + // Limitation: if the subscription dies before any ValidatorRegistered event + // has been received, fromBlock stays 0 and the reconnect again skips history — + // events emitted during that outage window will be missed. + // + // For production use, initialise fromBlock to the ChannelHub contract's + // deployment block (or the last block number persisted in your own store) + // so every reconnect replays from a known-good anchor point. + var fromBlock uint64 + + for ctx.Err() == nil { + events, err := client.WatchValidatorRegistered(ctx, chainID, fromBlock) + if err != nil { + log.Printf("failed to start validator watcher (fromBlock=%d): %v — retrying in 5s", fromBlock, err) + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + continue + } + } + + for ev := range events { + handleValidatorRegistered(ev) + // Advance fromBlock so the next reconnect replays from the block after this event. + fromBlock = ev.BlockNumber + 1 + } + + if ctx.Err() != nil { + break + } + log.Printf("Validator watcher subscription lost (will resume from block %d) — resubscribing in 5s", fromBlock) + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + } + } + + fmt.Println("Validator watcher stopped.") +} + +// handleValidatorRegistered is called for every ValidatorRegistered event, +// including legitimate first-time registrations during normal node operation. +// This example alerts unconditionally; production code should compare the +// incoming validator ID and address against a set of expected validators +// (communicated through official Nitrolite channels) and only alert when +// an unknown validator appears. +func handleValidatorRegistered(ev *core.ValidatorRegisteredEvent) { + fmt.Printf( + "[ALERT] New validator registered on chain %d at block %d: ID=%d address=%s\n", + ev.BlockchainID, ev.BlockNumber, ev.ValidatorID, ev.Validator, + ) + fmt.Println("Verify this validator is expected via official Nitrolite channels.") + fmt.Println("If unexpected, revoke all ERC20 approvals to the ChannelHub contract immediately.") + fmt.Println("You have 1 day (VALIDATOR_ACTIVATION_DELAY) before the validator becomes active.") +} diff --git a/sdk/go/utils.go b/sdk/go/utils.go index 88fc8bb14..c339dbae5 100644 --- a/sdk/go/utils.go +++ b/sdk/go/utils.go @@ -130,6 +130,8 @@ func transformChannel(channel rpc.ChannelV1) (core.Channel, error) { channelStatus = core.ChannelStatusOpen case "challenged": channelStatus = core.ChannelStatusChallenged + case "closing": + channelStatus = core.ChannelStatusClosing case "closed": channelStatus = core.ChannelStatusClosed } @@ -513,12 +515,13 @@ func transformSignedAppStateUpdateToRPC(signed app.SignedAppStateUpdateV1) rpc.S // transformChannelSessionKeyStateToRPC converts core.ChannelSessionKeyStateV1 to RPC ChannelSessionKeyStateV1. func transformChannelSessionKeyStateToRPC(state core.ChannelSessionKeyStateV1) rpc.ChannelSessionKeyStateV1 { return rpc.ChannelSessionKeyStateV1{ - UserAddress: state.UserAddress, - SessionKey: state.SessionKey, - Version: strconv.FormatUint(state.Version, 10), - Assets: state.Assets, - ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), - UserSig: state.UserSig, + UserAddress: state.UserAddress, + SessionKey: state.SessionKey, + Version: strconv.FormatUint(state.Version, 10), + Assets: state.Assets, + ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } @@ -540,12 +543,13 @@ func transformChannelSessionKeyState(state rpc.ChannelSessionKeyStateV1) (core.C } return core.ChannelSessionKeyStateV1{ - UserAddress: strings.ToLower(state.UserAddress), - SessionKey: strings.ToLower(state.SessionKey), - Version: version, - Assets: assets, - ExpiresAt: time.Unix(expiresAtUnix, 0), - UserSig: state.UserSig, + UserAddress: strings.ToLower(state.UserAddress), + SessionKey: strings.ToLower(state.SessionKey), + Version: version, + Assets: assets, + ExpiresAt: time.Unix(expiresAtUnix, 0), + UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } @@ -576,6 +580,7 @@ func transformSessionKeyStateToRPC(state app.AppSessionKeyStateV1) rpc.AppSessio AppSessionIDs: state.AppSessionIDs, ExpiresAt: strconv.FormatInt(state.ExpiresAt.Unix(), 10), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, } } @@ -609,6 +614,7 @@ func transformSessionKeyState(state rpc.AppSessionKeyStateV1) (app.AppSessionKey AppSessionIDs: appSessionIDs, ExpiresAt: time.Unix(expiresAtUnix, 0), UserSig: state.UserSig, + SessionKeySig: state.SessionKeySig, }, nil } diff --git a/sdk/go/validator_watcher.go b/sdk/go/validator_watcher.go new file mode 100644 index 000000000..3deac82b4 --- /dev/null +++ b/sdk/go/validator_watcher.go @@ -0,0 +1,71 @@ +package sdk + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/layer-3/nitrolite/pkg/blockchain/evm" + "github.com/layer-3/nitrolite/pkg/core" +) + +// WatchValidatorRegistered subscribes to ValidatorRegistered events on the ChannelHub +// contract for the given chain and delivers them on the returned channel. +// +// Each event carries the newly registered validator ID, its contract address, and the +// block number at which it was emitted. App builders should alert users and prompt them +// to revoke ERC20 approvals granted to ChannelHub whenever an unexpected validator +// appears — see contracts/SECURITY.md. +// +// Gap-free monitoring: pass fromBlock = 0 on the first call. On reconnect, pass +// lastEvent.BlockNumber + 1 so any events emitted during the outage are replayed +// before live events resume. This ensures the 1-day VALIDATOR_ACTIVATION_DELAY +// window is not silently shortened by network interruptions. +// +// The channel is closed when ctx is cancelled or the subscription is lost. On a +// lost subscription (network drop), call WatchValidatorRegistered again with the +// last received BlockNumber + 1 to resubscribe without gaps. +// +// The RPC URL configured via WithBlockchainRPC for chainID must be a WebSocket +// endpoint (wss:// or ws://). HTTP endpoints do not support event subscriptions +// and will return an error. +func (c *Client) WatchValidatorRegistered(ctx context.Context, chainID uint64, fromBlock uint64) (<-chan *core.ValidatorRegisteredEvent, error) { + rpcURL, exists := c.config.BlockchainRPCs[chainID] + if !exists { + return nil, fmt.Errorf("blockchain RPC not configured for chain %d (use WithBlockchainRPC)", chainID) + } + + channelHubAddress, err := c.getChannelHubAddress(ctx, chainID) + if err != nil { + return nil, fmt.Errorf("failed to get ChannelHub address for chain %d: %w", chainID, err) + } + + ethCl, err := ethclient.Dial(rpcURL) + if err != nil { + return nil, fmt.Errorf("failed to connect to blockchain RPC for chain %d: %w", chainID, err) + } + + rawCh, err := evm.WatchValidatorRegistered(ctx, common.HexToAddress(channelHubAddress), ethCl, chainID, fromBlock) + if err != nil { + ethCl.Close() + return nil, err + } + + // Proxy events to the caller and close the ethClient once the subscription ends. + outCh := make(chan *core.ValidatorRegisteredEvent, 16) + go func() { + defer ethCl.Close() + defer close(outCh) + for ev := range rawCh { + select { + case outCh <- ev: + case <-ctx.Done(): + return + } + } + }() + + return outCh, nil +} diff --git a/sdk/ts-compat/README.md b/sdk/ts-compat/README.md index ca2fc28f1..764083fe2 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -178,14 +178,21 @@ App-session allocation strings remain **human-readable decimal strings** such as ### Session Key Operations +Both `state.user_sig` (wallet authorization) and `state.session_key_sig` (proof of +possession by the session-key holder) are required at submit time. The `*Ownership` +helpers produce `session_key_sig`. + | Method | Description | |---|---| -| `signChannelSessionKeyState(state)` | Sign a channel session-key state payload | -| `submitChannelSessionKeyState(state)` | Register/submit a channel session-key state | -| `getLastChannelKeyStates(userAddress, sessionKey?)` | Fetch channel session-key states for wallet/key | -| `signSessionKeyState(state)` | Sign an app-session key state payload | -| `submitSessionKeyState(state)` | Register/submit an app-session key state | -| `getLastKeyStates(userAddress, sessionKey?)` | Fetch app-session key states for wallet/key | +| `signChannelSessionKeyState(state)` | Wallet `user_sig` over channel session-key state | +| `signChannelSessionKeyOwnership(state, sessionKeySigner)` | Session-key holder's `session_key_sig` for channel state | +| `submitChannelSessionKeyState(state)` | Register/submit a channel session-key state (both sigs required) | +| `getLastChannelKeyStates(userAddress, sessionKey?, options?)` | Fetch channel session-key states (active-only by default; `{ includeInactive: true }` for expired/revoked) | +| `signSessionKeyState(state)` | Wallet `user_sig` over app session-key state | +| `signAppSessionKeyOwnership(state, sessionKeySigner)` | Session-key holder's `session_key_sig` for app state | +| `submitSessionKeyState(state)` | Register/submit an app-session key state (both sigs required) | +| `getLastAppKeyStates(userAddress, sessionKey?, options?)` | Fetch app-session key states (active-only by default; `{ includeInactive: true }` for expired/revoked) | +| `getLastKeyStates(userAddress, sessionKey?)` | **Deprecated** — 0.5.x alias for `getLastAppKeyStates`; no `includeInactive` option | ### Transfers @@ -457,7 +464,7 @@ All legacy compat types are re-exported from `@yellow-org/sdk-compat`: ### Enums - `RPCMethod` — RPC method names (`Ping`, `GetConfig`, `GetChannels`, etc.) -- `RPCChannelStatus` — Channel status values (`Open`, `Closed`, `Resizing`, `Challenged`) +- `RPCChannelStatus` — Channel status values (`Open`, `Closed`, `Resizing`, `Challenged`, `Closing`) ### Wire Types diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index f4ba1cfc5..ee08107d9 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -2,6 +2,7 @@ import { Client, ChannelDefaultSigner, ChannelSessionKeyStateSigner, + type EthereumMsgSigner, type StateSigner, type TransactionSigner, } from '@yellow-org/sdk'; @@ -597,7 +598,8 @@ export class NitroliteClient { 0: 'void', 1: 'open', 2: 'challenged', - 3: 'closed', + 3: 'closing', + 4: 'closed', }; async getChannels(): Promise { @@ -859,6 +861,13 @@ export class NitroliteClient { return this.innerClient.signChannelSessionKeyState(state); } + async signChannelSessionKeyOwnership( + state: ChannelSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner, + ): Promise { + return this.innerClient.signChannelSessionKeyOwnership(state, sessionKeySigner); + } + async submitChannelSessionKeyState(state: ChannelSessionKeyStateV1): Promise { await this.innerClient.submitChannelSessionKeyState(state); } @@ -866,20 +875,39 @@ export class NitroliteClient { async getLastChannelKeyStates( userAddress: string, sessionKey?: string, + options?: { includeInactive?: boolean }, ): Promise { - return this.innerClient.getLastChannelKeyStates(userAddress, sessionKey); + return this.innerClient.getLastChannelKeyStates(userAddress, sessionKey, options); } async signSessionKeyState(state: AppSessionKeyStateV1): Promise { return this.innerClient.signSessionKeyState(state); } + async signAppSessionKeyOwnership( + state: AppSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner, + ): Promise { + return this.innerClient.signAppSessionKeyOwnership(state, sessionKeySigner); + } + async submitSessionKeyState(state: AppSessionKeyStateV1): Promise { await this.innerClient.submitSessionKeyState(state); } + async getLastAppKeyStates( + userAddress: string, + sessionKey?: string, + options?: { includeInactive?: boolean }, + ): Promise { + return this.innerClient.getLastAppKeyStates(userAddress, sessionKey, options); + } + + /** + * @deprecated Use `getLastAppKeyStates` instead. Retained for 0.5.x callers; will be removed in the next major. + */ async getLastKeyStates(userAddress: string, sessionKey?: string): Promise { - return this.innerClient.getLastKeyStates(userAddress, sessionKey); + return this.getLastAppKeyStates(userAddress, sessionKey); } // ----------------------------------------------------------------------- diff --git a/sdk/ts-compat/src/types.ts b/sdk/ts-compat/src/types.ts index 3880fb9ee..83d46c8cb 100644 --- a/sdk/ts-compat/src/types.ts +++ b/sdk/ts-compat/src/types.ts @@ -37,6 +37,7 @@ export enum RPCChannelStatus { Closed = 'closed', Resizing = 'resizing', Challenged = 'challenged', + Closing = 'closing', } export enum RPCProtocolVersion { diff --git a/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap index 1b4a6dd19..9dc60c883 100644 --- a/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap +++ b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -547,8 +547,9 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "getChannels: (): Promise", "getConfig: (): Promise", "getEscrowChannel: (escrowChannelId: string): Promise", + "getLastAppKeyStates: (userAddress: string, sessionKey?: string, options?: { includeInactive?: boolean; }): Promise", "getLastAppSessionsListError: (): string | null", - "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLastChannelKeyStates: (userAddress: string, sessionKey?: string, options?: { includeInactive?: boolean; }): Promise", "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", "getLedgerEntries: (wallet?: Address): Promise", "getLockedBalance: (chainId: number, wallet?: Address): Promise", @@ -567,6 +568,8 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "resolveAsset: (symbol: string): Promise", "resolveAssetDisplay: (tokenAddress: Address | string, _chainId?: number): Promise<{ symbol: string; decimals: number; } | null>", "resolveToken: (tokenAddress: Address | string): Promise", + "signAppSessionKeyOwnership: (state: AppSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", + "signChannelSessionKeyOwnership: (state: ChannelSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", "signSessionKeyState: (state: AppSessionKeyStateV1): Promise", "submitAppState: (params: SubmitAppStateRequestParams): Promise<{ appSessionId: string; version: number; status: string; }>", @@ -821,6 +824,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "Closed = 'closed'", "Resizing = 'resizing'", "Challenged = 'challenged'", + "Closing = 'closing'", ], "name": "RPCChannelStatus", }, diff --git a/sdk/ts-compat/test/unit/client-mapping.test.ts b/sdk/ts-compat/test/unit/client-mapping.test.ts index 6f168c8de..38cfdc98a 100644 --- a/sdk/ts-compat/test/unit/client-mapping.test.ts +++ b/sdk/ts-compat/test/unit/client-mapping.test.ts @@ -288,6 +288,75 @@ describe('NitroliteClient compat mappings', () => { ]); }); + it.each([ + { numeric: 0, expected: 'void' }, + { numeric: 1, expected: 'open' }, + { numeric: 2, expected: 'challenged' }, + { numeric: 3, expected: 'closing' }, + { numeric: 4, expected: 'closed' }, + ])('maps channel status $numeric to "$expected" in getChannels()', async ({ numeric, expected }) => { + const { client } = makeCompatClient({ + getChannels: jest.fn().mockResolvedValue({ + channels: [ + { + channelId: 'channel-1', + userWallet: USER, + status: numeric, + asset: 'yusd', + tokenAddress: CURRENT_TOKEN, + blockchainId: CURRENT_CHAIN, + challengeDuration: 86400, + nonce: 1n, + stateVersion: 1n, + }, + ], + }), + getLatestState: jest.fn().mockResolvedValue({ + homeLedger: { userBalance: new Decimal('0') }, + }), + }); + await client.refreshAssets(); + const channels = await client.getChannels(); + expect(channels[0].status).toBe(expected); + }); + + it('forwards includeInactive on session-key list methods through to the inner SDK', async () => { + const sessionKey = '0x0000000000000000000000000000000000000d01'; + const { client, innerClient } = makeCompatClient({ + getLastChannelKeyStates: jest.fn().mockResolvedValue([]), + getLastAppKeyStates: jest.fn().mockResolvedValue([]), + }); + + await client.getLastChannelKeyStates(USER, sessionKey, { includeInactive: true }); + await client.getLastAppKeyStates(USER, sessionKey, { includeInactive: true }); + + expect(innerClient.getLastChannelKeyStates).toHaveBeenCalledWith(USER, sessionKey, { + includeInactive: true, + }); + expect(innerClient.getLastAppKeyStates).toHaveBeenCalledWith(USER, sessionKey, { + includeInactive: true, + }); + + await client.getLastChannelKeyStates(USER); + await client.getLastAppKeyStates(USER); + + expect(innerClient.getLastChannelKeyStates).toHaveBeenLastCalledWith(USER, undefined, undefined); + expect(innerClient.getLastAppKeyStates).toHaveBeenLastCalledWith(USER, undefined, undefined); + }); + + it('keeps the deprecated getLastKeyStates alias forwarding to getLastAppKeyStates', async () => { + const sessionKey = '0x0000000000000000000000000000000000000d02'; + const { client, innerClient } = makeCompatClient({ + getLastAppKeyStates: jest.fn().mockResolvedValue([]), + }); + + await client.getLastKeyStates(USER, sessionKey); + expect(innerClient.getLastAppKeyStates).toHaveBeenCalledWith(USER, sessionKey, undefined); + + await client.getLastKeyStates(USER); + expect(innerClient.getLastAppKeyStates).toHaveBeenLastCalledWith(USER, undefined, undefined); + }); + it('keeps unsupported legacy methods honest and getOpenChannels delegates to the current chain hub', async () => { const { client } = makeCompatClient(); const readContract = jest.fn().mockResolvedValue(['0xabc', '0xdef']); diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts index 1e8ccccbb..820fff7bd 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -197,10 +197,13 @@ describe('compat public runtime API drift guard', () => { expect(client?.properties).toEqual( expect.arrayContaining([ expect.stringContaining('signChannelSessionKeyState:'), + expect.stringContaining('signChannelSessionKeyOwnership:'), expect.stringContaining('submitChannelSessionKeyState:'), expect.stringContaining('getLastChannelKeyStates:'), expect.stringContaining('signSessionKeyState:'), + expect.stringContaining('signAppSessionKeyOwnership:'), expect.stringContaining('submitSessionKeyState:'), + expect.stringContaining('getLastAppKeyStates:'), expect.stringContaining('getLastKeyStates:'), ]) ); diff --git a/sdk/ts/README.md b/sdk/ts/README.md index b43cfd078..8ffc2310d 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -78,16 +78,18 @@ client.rebalanceAppSessions(signedUpdates) // Atomic rebala ### App Session Keys ```typescript -client.signSessionKeyState(state) // Sign app session key state -client.submitSessionKeyState(state) // Register/update app session key -client.getLastKeyStates(userAddress, sessionKey?) // Get active app session key states +client.signSessionKeyState(state) // Wallet user_sig over app session key state +client.signAppSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's session_key_sig +client.submitSessionKeyState(state) // Register/update app session key (both sigs required) +client.getLastAppKeyStates(userAddress, sessionKey?, options?) // Get app session key states (active-only by default; pass { includeInactive: true } to include expired) ``` ### Channel Session Keys ```typescript -client.signChannelSessionKeyState(state) // Sign channel session key state -client.submitChannelSessionKeyState(state) // Register/update channel session key -client.getLastChannelKeyStates(userAddress, sessionKey?) // Get active channel session key states +client.signChannelSessionKeyState(state) // Wallet user_sig over channel session key state +client.signChannelSessionKeyOwnership(state, sessionKeySigner) // Session-key holder's session_key_sig +client.submitChannelSessionKeyState(state) // Register/update channel session key (both sigs required) +client.getLastChannelKeyStates(userAddress, sessionKey?, options?) // Get channel session key states (active-only by default; pass { includeInactive: true } to include expired) ``` ### Shared Utilities @@ -492,58 +494,65 @@ const sessionKeySigner = new AppSessionKeySignerV1(sessionKeyMsgSigner); ### App Session Keys +Registration requires two signatures: the wallet's `user_sig` (authorizing the +delegation) and the session-key holder's `session_key_sig` (proving possession of the key +being registered). The node rejects submits that lack a valid `session_key_sig`. + ```typescript -// Sign and submit an app session key state -const sig = await client.signSessionKeyState({ +// sessionKeyHolder is an EthereumMsgSigner whose address equals state.session_key. +// Use a raw message signer (not a wrapped StateSigner) — the node expects a +// raw 65-byte EIP-191 signature for session_key_sig. +const sessionKeyHolder = new EthereumMsgSigner(sessionKeyPrivateKey); +const state = { user_address: '0x1234...', session_key: '0xabcd...', version: '1', application_ids: ['app1'], app_session_ids: [], expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: '0x', -}); + user_sig: '', + session_key_sig: '', +}; +state.user_sig = await client.signSessionKeyState(state); +state.session_key_sig = await client.signAppSessionKeyOwnership(state, sessionKeyHolder); -await client.submitSessionKeyState({ - user_address: '0x1234...', - session_key: '0xabcd...', - version: '1', - application_ids: ['app1'], - app_session_ids: [], - expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: sig, -}); +await client.submitSessionKeyState(state); -// Query active app session key states -const states = await client.getLastKeyStates('0x1234...'); -const filtered = await client.getLastKeyStates('0x1234...', '0xSessionKey...'); +// Query app session key states (active-only by default) +const states = await client.getLastAppKeyStates('0x1234...'); +const filtered = await client.getLastAppKeyStates('0x1234...', '0xSessionKey...'); + +// Include expired/revoked latest states (e.g. for rotation flows that need the prior version) +const all = await client.getLastAppKeyStates('0x1234...', '0xSessionKey...', { includeInactive: true }); ``` ### Channel Session Keys ```typescript -// Sign and submit a channel session key state -const sig = await client.signChannelSessionKeyState({ +// sessionKeyHolder is an EthereumMsgSigner whose address equals state.session_key. +// Use a raw message signer (not a wrapped StateSigner) — the node expects a +// raw 65-byte EIP-191 signature for session_key_sig. +const sessionKeyHolder = new EthereumMsgSigner(sessionKeyPrivateKey); +const state = { user_address: '0x1234...', session_key: '0xabcd...', version: '1', assets: ['usdc'], expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: '0x', -}); + user_sig: '', + session_key_sig: '', +}; +state.user_sig = await client.signChannelSessionKeyState(state); +state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessionKeyHolder); -await client.submitChannelSessionKeyState({ - user_address: '0x1234...', - session_key: '0xabcd...', - version: '1', - assets: ['usdc'], - expires_at: String(Math.floor(Date.now() / 1000) + 86400), - user_sig: sig, -}); +await client.submitChannelSessionKeyState(state); -// Query active channel session key states +// Query channel session key states (active-only by default) const states = await client.getLastChannelKeyStates('0x1234...'); const filtered = await client.getLastChannelKeyStates('0x1234...', '0xSessionKey...'); + +// Include expired/revoked latest states (e.g. for rotation flows that need the prior version) +const all = await client.getLastChannelKeyStates('0x1234...', '0xSessionKey...', { includeInactive: true }); ``` ## Key Concepts diff --git a/sdk/ts/examples/app_sessions/lifecycle.ts b/sdk/ts/examples/app_sessions/lifecycle.ts index bcda8c570..777e020f1 100644 --- a/sdk/ts/examples/app_sessions/lifecycle.ts +++ b/sdk/ts/examples/app_sessions/lifecycle.ts @@ -167,12 +167,18 @@ async function main() { app_session_ids: [], expires_at: String(expiresAt), user_sig: '', + session_key_sig: '', }; const packedAppSessionKey3State = packAppSessionKeyStateV1(appSessionKey3State); + + // Wallet's user_sig authorizes the delegation. const wallet3MsgSigner = new EthereumMsgSigner(wallet3PrivateKey); - const appSessionKey3StateSig = await wallet3MsgSigner.signMessage(packedAppSessionKey3State); - appSessionKey3State.user_sig = appSessionKey3StateSig; + appSessionKey3State.user_sig = await wallet3MsgSigner.signMessage(packedAppSessionKey3State); + + // Session-key holder's session_key_sig proves possession of the key being registered. + // Both signatures are required at submit time. + appSessionKey3State.session_key_sig = await sessionKey3MsgSigner.signMessage(packedAppSessionKey3State); await wallet3Client.submitSessionKeyState(appSessionKey3State); diff --git a/sdk/ts/examples/example-app/README.md b/sdk/ts/examples/example-app/README.md index 7d8198c3d..9d41b6480 100644 --- a/sdk/ts/examples/example-app/README.md +++ b/sdk/ts/examples/example-app/README.md @@ -232,6 +232,7 @@ Session keys let your app sign state updates automatically without wallet popups import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import { ChannelSessionKeyStateSigner, + EthereumMsgSigner, getChannelSessionKeyAuthMetadataHashV1, } from '@layer-3/nitrolite'; @@ -253,14 +254,19 @@ const state = { assets: ['usdc', 'weth'], expires_at: expiresAt.toString(), user_sig: '', + session_key_sig: '', }; -// 4. Sign with the main wallet and submit +// 4. Sign ownership with the session key, then user_sig with the main wallet, then submit. +// Both signatures are required; the server rejects submits with either missing. +const sessionKeySigner = new EthereumMsgSigner(privateKey); +state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessionKeySigner); state.user_sig = await client.signChannelSessionKeyState(state); await client.submitChannelSessionKeyState(state); // 5. Compute the metadata hash const metadataHash = getChannelSessionKeyAuthMetadataHashV1( + address, version, ['usdc', 'weth'], expiresAt, @@ -290,7 +296,7 @@ Now `sessionClient` signs off-chain state updates with the session key — no wa Submit a new version with empty assets to revoke a session key: ```ts -import { packChannelKeyStateV1 } from '@layer-3/nitrolite'; +import { EthereumMsgSigner, packChannelKeyStateV1 } from '@layer-3/nitrolite'; const existing = await client.getLastChannelKeyStates(address, sessionKeyAddress); const latest = existing[0]; @@ -302,10 +308,17 @@ const revokeState = { assets: [], expires_at: latest.expires_at, user_sig: '', + session_key_sig: '', }; +// Revoke still requires the session-key holder's possession proof — sign with the session key +// (the holder is consenting to retire it) before submit. +const sessionKeySigner = new EthereumMsgSigner(sessionKeyPrivateKey); +revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revokeState, sessionKeySigner); + // Sign the revocation with the main wallet (EIP-191) const metadataHash = getChannelSessionKeyAuthMetadataHashV1( + address, BigInt(revokeState.version), [], BigInt(revokeState.expires_at), diff --git a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx index e215180d8..0a1329286 100644 --- a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx +++ b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx @@ -9,6 +9,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import type { WalletClient } from 'viem'; import Decimal from 'decimal.js'; import { + EthereumMsgSigner, getChannelSessionKeyAuthMetadataHashV1, packChannelKeyStateV1, } from '@yellow-org/sdk'; @@ -24,10 +25,11 @@ import ActionModal from './ActionModal'; */ async function signSessionKeyStateWithWallet( wc: WalletClient, - state: { session_key: string; version: string; assets: string[]; expires_at: string }, + state: { user_address: string; session_key: string; version: string; assets: string[]; expires_at: string }, ): Promise<`0x${string}`> { if (!wc.account) throw new Error('Wallet client does not have an account'); const metadataHash = getChannelSessionKeyAuthMetadataHashV1( + state.user_address as `0x${string}`, BigInt(state.version), state.assets, BigInt(state.expires_at), @@ -257,17 +259,22 @@ export default function WalletDashboard({ assets: assetList, expires_at: expiresAt.toString(), user_sig: '', + session_key_sig: '', }; - // Sign using the SDK method (goes through ChannelDefaultSigner, strips prefix) + // Wallet's user_sig authorizes the delegation (goes through ChannelDefaultSigner, strips prefix). const sig = await client.signChannelSessionKeyState(state); state.user_sig = sig; + // Session-key holder's session_key_sig proves possession of the key being registered. + const sessionKeySigner = new EthereumMsgSigner(newSk.privateKey as `0x${string}`); + state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessionKeySigner); + // Submit to nitronode await client.submitChannelSessionKeyState(state); // Compute metadata hash for the session key signer - const metadataHash = getChannelSessionKeyAuthMetadataHashV1(version, assetList, expiresAt); + const metadataHash = getChannelSessionKeyAuthMetadataHashV1(address as `0x${string}`, version, assetList, expiresAt); const activeSk: SessionKeyState = { ...newSk, @@ -302,9 +309,13 @@ export default function WalletDashboard({ assets: [] as string[], expires_at: latest.expires_at, user_sig: '', + session_key_sig: '', }; const sig = await signSessionKeyStateWithWallet(walletClient, revokeState); revokeState.user_sig = sig; + // Session-key holder's session_key_sig is required on every submit, including revoke. + const sessionKeySigner = new EthereumMsgSigner(sessionKey.privateKey as `0x${string}`); + revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revokeState, sessionKeySigner); await client.submitChannelSessionKeyState(revokeState); } } catch (revokeErr) { @@ -473,6 +484,22 @@ export default function WalletDashboard({ const revokeId = `${ks.session_key}-${ks.version}`; try { setRevokingKey(revokeId); + + // Revoke requires both user_sig and session_key_sig — the latter must come from the + // session key's private key. The example app only has that key in local state for the + // currently active session key; arbitrary keys cannot be revoked here without the + // private key and will need to expire naturally via expires_at. + const isCurrentlyActive = + sessionKey?.active && sessionKey.address.toLowerCase() === ks.session_key.toLowerCase(); + if (!isCurrentlyActive || !sessionKey?.privateKey) { + showStatus( + 'error', + 'Revoke not supported for this key', + 'session_key_sig requires the session key\'s private key, which is only available for the active local key. Other keys must expire via their expires_at.', + ); + return; + } + const newVersion = BigInt(ks.version) + 1n; const revokeState = { user_address: address, @@ -481,18 +508,17 @@ export default function WalletDashboard({ assets: [] as string[], expires_at: ks.expires_at, user_sig: '', + session_key_sig: '', }; const sig = await signSessionKeyStateWithWallet(walletClient, revokeState); revokeState.user_sig = sig; + const sessionKeySigner = new EthereumMsgSigner(sessionKey.privateKey as `0x${string}`); + revokeState.session_key_sig = await client.signChannelSessionKeyOwnership(revokeState, sessionKeySigner); await client.submitChannelSessionKeyState(revokeState); showStatus('success', 'Session key revoked', `Key ${formatAddress(ks.session_key)}`); - // If revoked key is the currently active one, clear it - if (sessionKey?.active && sessionKey.address.toLowerCase() === ks.session_key.toLowerCase()) { - await onClearSessionKey(); - } - + await onClearSessionKey(); await fetchKeyStates(); } catch (error) { showStatus('error', 'Revoke failed', error instanceof Error ? error.message : String(error)); diff --git a/sdk/ts/src/app/types.ts b/sdk/ts/src/app/types.ts index 262d4ffe0..a88019038 100644 --- a/sdk/ts/src/app/types.ts +++ b/sdk/ts/src/app/types.ts @@ -176,6 +176,8 @@ export interface AppSessionKeyStateV1 { expires_at: string; /** User's signature over the session key metadata */ user_sig: string; + /** Session-key holder's signature proving possession of the key being registered */ + session_key_sig: string; } /** diff --git a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts index b6d6c06d7..7dff25ff5 100644 --- a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts +++ b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts @@ -48,6 +48,19 @@ export const ChannelHubAbi = [ ], stateMutability: 'view' }, + { + type: 'function', + name: 'MAX_CHALLENGE_DURATION', + inputs: [], + outputs: [ + { + name: '', + type: 'uint32', + internalType: 'uint32' + } + ], + stateMutability: 'view' + }, { type: 'function', name: 'MAX_DEPOSIT_ESCROW_STEPS', @@ -4143,6 +4156,12 @@ export const ChannelHubAbi = [ type: 'event', name: 'EscrowDepositsPurged', inputs: [ + { + name: 'escrowIds', + type: 'bytes32[]', + indexed: false, + internalType: 'bytes32[]' + }, { name: 'purgedCount', type: 'uint256', diff --git a/sdk/ts/src/blockchain/evm/index.ts b/sdk/ts/src/blockchain/evm/index.ts index 07327b6af..5e9427155 100644 --- a/sdk/ts/src/blockchain/evm/index.ts +++ b/sdk/ts/src/blockchain/evm/index.ts @@ -10,3 +10,4 @@ export * from './channel_hub_abi.js'; export * from './client.js'; export * from './app_registry_abi.js'; export * from './locking_client.js'; +export * from './validator_watcher.js'; diff --git a/sdk/ts/src/blockchain/evm/validator_watcher.ts b/sdk/ts/src/blockchain/evm/validator_watcher.ts new file mode 100644 index 000000000..d9da5e21c --- /dev/null +++ b/sdk/ts/src/blockchain/evm/validator_watcher.ts @@ -0,0 +1,165 @@ +import { Address, getAddress } from 'viem'; +import { EVMClient } from './interface.js'; +import { ChannelHubAbi } from './channel_hub_abi.js'; +import { ValidatorRegisteredEvent } from '../../core/event.js'; + +// Typed single-event ABI slice used for getLogs (historical replay). +// watchContractEvent infers types from the full ChannelHubAbi + eventName. +// internalType matches the canonical ChannelHubAbi entry. +const VALIDATOR_REGISTERED_ABI = [ + { + type: 'event', + name: 'ValidatorRegistered', + inputs: [ + { name: 'validatorId', type: 'uint8', indexed: true, internalType: 'uint8' }, + { name: 'validator', type: 'address', indexed: true, internalType: 'contract ISignatureValidator' }, + ], + anonymous: false, + }, +] as const; + +/** + * Subscribes to ValidatorRegistered events emitted by the ChannelHub contract + * and yields them as an async stream. + * + * Historical replay: when fromBlock > 0n the generator fetches all matching + * logs from fromBlock to the current chain head before switching to live events, + * filling any gap caused by a prior outage. Pass fromBlock = 0n on the first + * call and lastEvent.blockNumber + 1n on each reconnect. + * + * Transition safety: the live subscription is anchored to the block immediately + * after the historical getLogs upper bound (or the current head when fromBlock = 0n), + * so no events are lost in the getLogs-to-first-poll window. + * + * Reorg safety: logs with removed = true are skipped. + * + * Cancellation: pass an AbortSignal to stop the generator cleanly. On abort the + * generator returns without throwing, so no error is logged for normal shutdown. + * + * With an HTTP transport viem polls getLogs at the configured interval (default + * 4 s). With a WebSocket transport (wss:// URL) viem uses push subscriptions. + */ +export async function* watchValidatorRegistered( + contractAddress: Address, + client: EVMClient, + blockchainId: bigint, + fromBlock: bigint, + signal?: AbortSignal, +): AsyncGenerator { + // Fetch the current block number upfront. It is used: + // - as the upper bound for historical getLogs, + // - as the anchor for watchContractEvent (liveFromBlock = headBlock + 1n), + // closing the getLogs-to-first-poll transition gap (F-01). + let headBlock: bigint = 0n; + try { + headBlock = await client.getBlockNumber(); + } catch (err) { + if (!signal?.aborted) { + console.warn('[nitrolite] watchValidatorRegistered: failed to fetch block number, historical replay and transition gap-fill skipped', err); + } + } + + // Historical phase: replay events emitted while the subscription was down. + if (fromBlock > 0n && headBlock >= fromBlock) { + try { + const logs = await client.getLogs({ + address: contractAddress, + event: VALIDATOR_REGISTERED_ABI[0], + fromBlock, + toBlock: headBlock, + strict: true, + }); + for (const log of logs) { + if (log.removed) continue; + if (signal?.aborted) return; + yield { + blockchainId, + validatorId: log.args.validatorId, + validator: getAddress(log.args.validator), + blockNumber: log.blockNumber ?? headBlock, + }; + } + } catch (err) { + if (!signal?.aborted) { + console.warn('[nitrolite] watchValidatorRegistered: failed to fetch historical logs, gap fill incomplete', err); + } + } + } + + if (signal?.aborted) return; + + // Live phase: bridge watchContractEvent callbacks into the async generator + // using a promise queue so callers can use standard for-await-of syntax. + // + // liveFromBlock closes the transition gap: watchContractEvent starts polling + // from headBlock + 1n so no events between getLogs toBlock and the first poll + // are missed. + const liveFromBlock = headBlock > 0n ? headBlock + 1n : undefined; + + const queue: ValidatorRegisteredEvent[] = []; + let wakeUp: (() => void) | null = null; + let watchError: Error | null = null; + let done = false; + + const notify = (): void => { + const resolve = wakeUp; + wakeUp = null; + resolve?.(); + }; + + const unwatch = client.watchContractEvent({ + address: contractAddress, + abi: ChannelHubAbi, + eventName: 'ValidatorRegistered', + fromBlock: liveFromBlock, + onLogs(logs) { + for (const log of logs) { + if (log.removed) continue; + // Skip logs with null blockNumber — they are pending and cannot + // be used as a reconnect anchor (blockNumber + 1n). + if (log.blockNumber === null) continue; + const { validatorId, validator } = log.args; + if (validatorId === undefined || !validator) continue; + queue.push({ + blockchainId, + validatorId, + validator: getAddress(validator), + blockNumber: log.blockNumber, + }); + } + notify(); + }, + onError(err) { + watchError = err; + done = true; + notify(); + }, + }); + + const onAbort = (): void => { + done = true; + notify(); + }; + signal?.addEventListener('abort', onAbort); + + try { + while (!done || queue.length > 0) { + while (queue.length > 0) { + if (signal?.aborted) return; + yield queue.shift()!; + } + if (!done) { + await new Promise((resolve) => { + wakeUp = resolve; + }); + } + } + // Propagate subscription errors but not clean aborts. + if (watchError && !signal?.aborted) { + throw watchError; + } + } finally { + signal?.removeEventListener('abort', onAbort); + unwatch(); + } +} diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 7c42baa77..cca12426a 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -39,7 +39,7 @@ import * as blockchain from './blockchain/index.js'; import { nextState, applyChannelCreation, applyAcknowledgementTransition, applyHomeDepositTransition, applyHomeWithdrawalTransition, applyTransferSendTransition, applyFinalizeTransition, applyCommitTransition } from './core/state.js'; import { newVoidState } from './core/types.js'; import { packState, packChallengeState } from './core/state_packer.js'; -import { StateSigner, TransactionSigner } from './signers.js'; +import { EthereumMsgSigner, StateSigner, TransactionSigner } from './signers.js'; /** * Default challenge period for channels (1 day in seconds) @@ -1656,13 +1656,16 @@ export class Client { /** * Sign a channel session key state using the client's state signer. * This creates a properly formatted EIP-191 signature that can be set on the state's - * user_sig field before submitting via submitChannelSessionKeyState. + * user_sig field before submitting via submitChannelSessionKeyState. The matching + * session_key_sig (see signChannelSessionKeyOwnership) must also be populated — submits + * with only one of the two are rejected. * - * @param state - The channel session key state to sign (user_sig field is excluded from signing) + * @param state - The channel session key state to sign (user_sig and session_key_sig fields are excluded from signing) * @returns The hex-encoded signature string */ async signChannelSessionKeyState(state: ChannelSessionKeyStateV1): Promise { const metadataHash = core.getChannelSessionKeyAuthMetadataHashV1( + state.user_address as Address, BigInt(state.version), state.assets, BigInt(state.expires_at) @@ -1675,9 +1678,44 @@ export class Client { return stripSignerTypePrefix(channelSig); } + /** + * Produce the session-key holder's ownership signature for a channel session key + * registration. The caller-supplied signer must hold the session key being registered; + * the returned hex string populates state.session_key_sig before submit. session_key is + * bound into the metadata hash so a signature minted for one key cannot be replayed for + * another. + * + * The parameter is narrowed to EthereumMsgSigner because the server recovers + * session_key_sig as a raw 65-byte EIP-191 signature — a broader StateSigner could wrap + * the signature with type-prefix bytes (e.g. ChannelDefaultSigner, ChannelSessionKeyStateSigner) + * that the server rejects. + * + * @param state - The channel session key state to sign (session_key_sig field is excluded) + * @param sessionKeySigner - EthereumMsgSigner whose address equals state.session_key + * @returns The hex-encoded signature string + */ + async signChannelSessionKeyOwnership( + state: ChannelSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner + ): Promise { + const metadataHash = core.getChannelSessionKeyAuthMetadataHashV1( + state.user_address as Address, + BigInt(state.version), + state.assets, + BigInt(state.expires_at) + ); + const packed = core.packChannelKeyStateV1( + state.session_key as Address, + metadataHash + ); + return await sessionKeySigner.signMessage(packed); + } + /** * Submit a channel session key state for registration or update. - * The state must be signed by the user's wallet to authorize the session key delegation. + * The state must carry both the wallet's user_sig (proving the user authorized the + * delegation) and the session-key holder's session_key_sig (proving possession of the + * key being registered). Submits without a valid session_key_sig are rejected. * * @param state - The channel session key state containing delegation information */ @@ -1689,19 +1727,25 @@ export class Client { } /** - * Retrieve the latest channel session key states for a user. + * Retrieve the latest channel session key states for a user. Defaults to + * active-only (server filters expired states); pass `includeInactive: true` + * to surface expired or revoked latest states (e.g. for rotation flows that + * need to read the prior version after expiry). * * @param userAddress - The user's wallet address * @param sessionKey - Optional session key address to filter by - * @returns List of active channel session key states + * @param options - Optional include-inactive flag + * @returns List of channel session key states matching the filter */ async getLastChannelKeyStates( userAddress: string, - sessionKey?: string + sessionKey?: string, + options?: { includeInactive?: boolean } ): Promise { const req: API.ChannelsV1GetLastKeyStatesRequest = { user_address: userAddress, session_key: sessionKey, + include_inactive: options?.includeInactive, }; const resp = await this.rpcClient.channelsV1GetLastKeyStates(req); if (!Array.isArray(resp.states)) { @@ -1719,9 +1763,11 @@ export class Client { /** * Sign an app session key state using the client's state signer. * This creates a properly formatted EIP-191 signature that can be set on the state's - * user_sig field before submitting via submitSessionKeyState. + * user_sig field before submitting via submitSessionKeyState. The matching + * session_key_sig (see signAppSessionKeyOwnership) must also be populated — submits + * with only one of the two are rejected. * - * @param state - The app session key state to sign (user_sig field is excluded from signing) + * @param state - The app session key state to sign (user_sig and session_key_sig fields are excluded from signing) * @returns The hex-encoded signature string */ async signSessionKeyState(state: app.AppSessionKeyStateV1): Promise { @@ -1730,9 +1776,34 @@ export class Client { return stripSignerTypePrefix(channelSig); } + /** + * Produce the session-key holder's ownership signature for an app session key + * registration. The caller-supplied signer must hold the session key being registered; + * the returned hex string populates state.session_key_sig before submit. The packed + * app-session state already binds user_address, so the same packed bytes are used for + * both the wallet's user_sig and the session-key holder's session_key_sig. + * + * The parameter is narrowed to EthereumMsgSigner because the server recovers + * session_key_sig as a raw 65-byte EIP-191 signature — a broader StateSigner could wrap + * the signature with type-prefix bytes (e.g. AppSessionWalletSignerV1) that the server rejects. + * + * @param state - The app session key state to sign (session_key_sig field is excluded) + * @param sessionKeySigner - EthereumMsgSigner whose address equals state.session_key + * @returns The hex-encoded signature string + */ + async signAppSessionKeyOwnership( + state: app.AppSessionKeyStateV1, + sessionKeySigner: EthereumMsgSigner + ): Promise { + const packed = app.packAppSessionKeyStateV1(state); + return await sessionKeySigner.signMessage(packed); + } + /** * Submit an app session key state for registration or update. - * The state must be signed by the user's wallet to authorize the session key delegation. + * The state must carry both the wallet's user_sig (proving the user authorized the + * delegation) and the session-key holder's session_key_sig (proving possession of the + * key being registered). Submits without a valid session_key_sig are rejected. * * @param state - The session key state containing delegation information */ @@ -1744,19 +1815,25 @@ export class Client { } /** - * Retrieve the latest session key states for a user. + * Retrieve the latest app session key states for a user. Defaults to + * active-only (server filters expired states); pass `includeInactive: true` + * to surface expired or revoked latest states (e.g. for rotation flows that + * need to read the prior version after expiry). * * @param userAddress - The user's wallet address * @param sessionKey - Optional session key address to filter by - * @returns List of active session key states + * @param options - Optional include-inactive flag + * @returns List of app session key states matching the filter */ - async getLastKeyStates( + async getLastAppKeyStates( userAddress: string, - sessionKey?: string + sessionKey?: string, + options?: { includeInactive?: boolean } ): Promise { const req: API.AppSessionsV1GetLastKeyStatesRequest = { user_address: userAddress, session_key: sessionKey, + include_inactive: options?.includeInactive, }; const resp = await this.rpcClient.appSessionsV1GetLastKeyStates(req); if (!Array.isArray(resp.states)) { @@ -1767,6 +1844,62 @@ export class Client { ); } + // ============================================================================ + // On-Chain Event Watching + // ============================================================================ + + /** + * Subscribes to ValidatorRegistered events emitted by the ChannelHub contract + * on the given chain and yields them as an async stream. + * + * Gap-free monitoring: pass fromBlock = 0n on the first call. On reconnect, + * pass lastEvent.blockNumber + 1n so any events emitted during the outage + * are replayed before live events resume. This preserves the 1-day + * VALIDATOR_ACTIVATION_DELAY safety window across network interruptions. + * + * The generator completes when the AbortSignal fires or the subscription is + * lost. Reconnect by calling watchValidatorRegistered again with the last + * received blockNumber + 1n as fromBlock. + * + * The RPC URL configured via withBlockchainRPC for chainId should be a + * WebSocket endpoint (wss://) for push-based delivery. With an HTTP endpoint + * viem falls back to polling getLogs at a 4-second interval. + * + * @example + * ```ts + * const ac = new AbortController(); + * let fromBlock = 0n; + * while (!ac.signal.aborted) { + * for await (const ev of client.watchValidatorRegistered(chainId, fromBlock, ac.signal)) { + * console.warn(`Unexpected validator ${ev.validatorId} at block ${ev.blockNumber}`); + * fromBlock = ev.blockNumber + 1n; + * } + * await new Promise(r => setTimeout(r, 5_000)); // back off before reconnect + * } + * ``` + */ + async *watchValidatorRegistered( + chainId: bigint, + fromBlock: bigint = 0n, + signal?: AbortSignal, + ): AsyncGenerator { + const { rpcUrl, blockchainInfo } = await this.getBlockchainRPCInfo(chainId); + + if (!blockchainInfo.channelHubAddress) { + throw new Error(`channel hub address not configured for blockchain ${chainId}`); + } + + const { publicClient } = this.createEVMClients(chainId, rpcUrl); + + yield* blockchain.evm.watchValidatorRegistered( + blockchainInfo.channelHubAddress as Address, + publicClient, + chainId, + fromBlock, + signal, + ); + } + // ============================================================================ // Private Helper Methods // ============================================================================ diff --git a/sdk/ts/src/core/event.ts b/sdk/ts/src/core/event.ts index 0c811fb02..f57d95e16 100644 --- a/sdk/ts/src/core/event.ts +++ b/sdk/ts/src/core/event.ts @@ -99,3 +99,24 @@ export type EscrowWithdrawalChallengedEvent = ChannelChallengedEvent; * EscrowWithdrawalFinalizedEvent represents the EscrowWithdrawalFinalized event */ export type EscrowWithdrawalFinalizedEvent = ChannelEvent; + +// ============================================================================ +// Validator Events +// ============================================================================ + +/** + * Emitted when the node registers a new signature validator on ChannelHub. + * Users must react to unexpected registrations by revoking ERC20 approvals + * granted to ChannelHub — see contracts/SECURITY.md. + * + * `validator` is always EIP-55 checksummed. Compare with getAddress(ev.validator) + * to avoid silent mismatches against lowercase or non-checksummed config values. + * + * Pass `blockNumber + 1n` as `fromBlock` on reconnect for gap-free monitoring. + */ +export interface ValidatorRegisteredEvent { + blockchainId: bigint; + validatorId: number; // uint8 + validator: Address; // EIP-55 checksummed + blockNumber: bigint; // use as fromBlock on reconnect +} diff --git a/sdk/ts/src/core/types.ts b/sdk/ts/src/core/types.ts index ae2a58cc6..fe2c300dc 100644 --- a/sdk/ts/src/core/types.ts +++ b/sdk/ts/src/core/types.ts @@ -24,7 +24,8 @@ export enum ChannelStatus { Void = 0, Open = 1, Challenged = 2, - Closed = 3, + Closing = 3, + Closed = 4, } export enum TransitionType { diff --git a/sdk/ts/src/core/utils.ts b/sdk/ts/src/core/utils.ts index 429ec05d7..5b5d38457 100644 --- a/sdk/ts/src/core/utils.ts +++ b/sdk/ts/src/core/utils.ts @@ -407,26 +407,31 @@ function parseAccountIdToBytes32(accountId: string | undefined): `0x${string}` { } /** - * Computes the metadata hash for a channel session key authorization. - * Matches Go SDK's GetChannelSessionKeyAuthMetadataHashV1. + * Computes the metadata hash for a channel session key authorization. user_address is bound + * into the hash; together with the session_key already in packChannelKeyStateV1, this binds + * the signed payload to a single (wallet, session_key) pair so signatures cannot be replayed + * across wallets or session keys. Matches Go SDK's GetChannelSessionKeyAuthMetadataHashV1. * + * @param userAddress - The wallet address authorizing the session key * @param version - Session key state version * @param assets - Asset symbols associated with the session key * @param expiresAt - Unix timestamp in seconds when the session key expires * @returns Keccak256 hash of the ABI-encoded metadata */ export function getChannelSessionKeyAuthMetadataHashV1( + userAddress: Address, version: bigint, assets: string[], expiresAt: bigint ): `0x${string}` { const packed = encodeAbiParameters( [ + { type: 'address' }, // user_address { type: 'uint64' }, // version { type: 'string[]' }, // assets { type: 'uint64' }, // expires_at ], - [version, assets, expiresAt] + [userAddress, version, assets, expiresAt] ); return keccak256(packed); } @@ -451,3 +456,4 @@ export function packChannelKeyStateV1( [sessionKey, metadataHash] ); } + diff --git a/sdk/ts/src/rpc/api.ts b/sdk/ts/src/rpc/api.ts index 38ffbbe99..970004b96 100644 --- a/sdk/ts/src/rpc/api.ts +++ b/sdk/ts/src/rpc/api.ts @@ -134,11 +134,20 @@ export interface ChannelsV1GetLastKeyStatesRequest { user_address: string; /** Optionally filter by session key address */ session_key?: string; + /** + * When true, include latest states whose expires_at is in the past (expired or + * revoked). Defaults to false on the server when omitted, returning active-only. + */ + include_inactive?: boolean; + /** Pagination parameters */ + pagination?: PaginationParamsV1; } export interface ChannelsV1GetLastKeyStatesResponse { /** List of active channel session key states for the user */ states: ChannelSessionKeyStateV1[]; + /** Pagination metadata */ + metadata: PaginationMetadataV1; } // ============================================================================ @@ -242,11 +251,20 @@ export interface AppSessionsV1GetLastKeyStatesRequest { user_address: string; /** Optionally filter by session key address */ session_key?: string; + /** + * When true, include latest states whose expires_at is in the past (expired or + * revoked). Defaults to false on the server when omitted, returning active-only. + */ + include_inactive?: boolean; + /** Pagination parameters */ + pagination?: PaginationParamsV1; } export interface AppSessionsV1GetLastKeyStatesResponse { /** List of active session key states for the user */ states: AppSessionKeyStateV1[]; + /** Pagination metadata */ + metadata: PaginationMetadataV1; } // ============================================================================ diff --git a/sdk/ts/src/rpc/types.ts b/sdk/ts/src/rpc/types.ts index 0fbb8453e..549be8f17 100644 --- a/sdk/ts/src/rpc/types.ts +++ b/sdk/ts/src/rpc/types.ts @@ -145,6 +145,8 @@ export interface ChannelSessionKeyStateV1 { expires_at: string; /** User's signature over the session key metadata */ user_sig: string; + /** Session-key holder's signature proving possession of the key being registered */ + session_key_sig: string; } // ============================================================================ diff --git a/sdk/ts/src/session_key_state_transforms.ts b/sdk/ts/src/session_key_state_transforms.ts index 2a1a9fe3a..f3a16f804 100644 --- a/sdk/ts/src/session_key_state_transforms.ts +++ b/sdk/ts/src/session_key_state_transforms.ts @@ -37,6 +37,7 @@ export function transformChannelSessionKeyState( assets: requireStringArrayField(raw, context, 'assets'), expires_at: requireStringField(raw, context, 'expires_at'), user_sig: requireStringField(raw, context, 'user_sig'), + session_key_sig: requireStringField(raw, context, 'session_key_sig'), }; } @@ -52,5 +53,6 @@ export function transformAppSessionKeyState( app_session_ids: requireStringArrayField(raw, context, 'app_session_ids'), expires_at: requireStringField(raw, context, 'expires_at'), user_sig: requireStringField(raw, context, 'user_sig'), + session_key_sig: requireStringField(raw, context, 'session_key_sig'), }; } diff --git a/sdk/ts/src/utils.ts b/sdk/ts/src/utils.ts index d3199ed9b..65fc7c7b1 100644 --- a/sdk/ts/src/utils.ts +++ b/sdk/ts/src/utils.ts @@ -120,6 +120,8 @@ function parseChannelStatus(status: string): core.ChannelStatus { return core.ChannelStatus.Open; case 'challenged': return core.ChannelStatus.Challenged; + case 'closing': + return core.ChannelStatus.Closing; case 'closed': return core.ChannelStatus.Closed; default: diff --git a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index ea7c38057..d72b4f3fa 100644 --- a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap +++ b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -218,6 +218,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "application_ids: string[]", "expires_at: string", "session_key: string", + "session_key_sig: string", "user_address: string", "user_sig: string", "version: string", @@ -321,6 +322,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "AppSessionsV1GetLastKeyStatesRequest", "properties": [ + "include_inactive: boolean", + "pagination: PaginationParamsV1", "session_key: string", "user_address: string", ], @@ -330,6 +333,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "AppSessionsV1GetLastKeyStatesResponse", "properties": [ + "metadata: PaginationMetadataV1", "states: AppSessionKeyStateV1[]", ], "signatures": [], @@ -781,6 +785,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "assets: string[]", "expires_at: string", "session_key: string", + "session_key_sig: string", "user_address: string", "user_sig: string", "version: string", @@ -801,7 +806,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "Void = 0", "Open = 1", "Challenged = 2", - "Closed = 3", + "Closing = 3", + "Closed = 4", ], "name": "ChannelStatus", }, @@ -883,6 +889,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "ChannelsV1GetLastKeyStatesRequest", "properties": [ + "include_inactive: boolean", + "pagination: PaginationParamsV1", "session_key: string", "user_address: string", ], @@ -892,6 +900,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "ChannelsV1GetLastKeyStatesResponse", "properties": [ + "metadata: PaginationMetadataV1", "states: ChannelSessionKeyStateV1[]", ], "signatures": [], @@ -1052,8 +1061,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "getConfig: (): Promise", "getEscrowChannel: (escrowChannelId: string): Promise", "getHomeChannel: (wallet: Address, asset: string): Promise", - "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", - "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLastAppKeyStates: (userAddress: string, sessionKey?: string, options?: { includeInactive?: boolean; }): Promise", + "getLastChannelKeyStates: (userAddress: string, sessionKey?: string, options?: { includeInactive?: boolean; }): Promise", "getLatestState: (wallet: Address, asset: string, onlySigned: boolean): Promise", "getLockedBalance: (chainId: bigint, wallet: string): Promise", "getOnChainBalance: (chainId: bigint, asset: string, wallet: Address): Promise", @@ -1064,6 +1073,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "rebalanceAppSessions: (signedUpdates: app.SignedAppStateUpdateV1[]): Promise", "registerApp: (appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise", "setHomeBlockchain: (asset: string, blockchainId: bigint): Promise", + "signAppSessionKeyOwnership: (state: app.AppSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", + "signChannelSessionKeyOwnership: (state: ChannelSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", "signSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", "signState: (state: core.State): Promise", @@ -1074,6 +1085,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "transfer: (recipientWallet: string, asset: string, amount: Decimal): Promise", "validateAndSignState: (currentState: core.State, proposedState: core.State): Promise", "waitForClose: (): Promise", + "watchValidatorRegistered: (chainId: bigint, fromBlock?: bigint, signal?: AbortSignal): AsyncGenerator", "withdraw: (blockchainId: bigint, asset: string, amount: Decimal): Promise", "withdrawSecurityTokens: (blockchainId: bigint, destinationWalletAddress: string): Promise", ], @@ -1352,7 +1364,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "function", "name": "getChannelSessionKeyAuthMetadataHashV1", "signatures": [ - "(version: bigint, assets: string[], expiresAt: bigint): \`0x\${string}\`", + "(userAddress: Address, version: bigint, assets: string[], expiresAt: bigint): \`0x\${string}\`", ], }, { @@ -2428,6 +2440,17 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "(ledger: Ledger): void", ], }, + { + "kind": "interface", + "name": "ValidatorRegisteredEvent", + "properties": [ + "blockNumber: bigint", + "blockchainId: bigint", + "validator: Address", + "validatorId: number", + ], + "signatures": [], + }, { "constructors": [ "(config?: WebsocketDialerConfig): WebsocketDialer", diff --git a/sdk/ts/test/unit/blockchain/evm/validator_watcher.test.ts b/sdk/ts/test/unit/blockchain/evm/validator_watcher.test.ts new file mode 100644 index 000000000..daafe1b8e --- /dev/null +++ b/sdk/ts/test/unit/blockchain/evm/validator_watcher.test.ts @@ -0,0 +1,342 @@ +import { jest } from '@jest/globals'; +import { watchValidatorRegistered } from '../../../../src/blockchain/evm/validator_watcher'; +import type { EVMClient } from '../../../../src/blockchain/evm/interface'; +import type { ValidatorRegisteredEvent } from '../../../../src/core/event'; + +// ── fixtures ────────────────────────────────────────────────────────────────── + +const CONTRACT = '0x1111111111111111111111111111111111111111' as const; +const VALIDATOR = '0x2222222222222222222222222222222222222222' as const; +const CHAIN_ID = 1n; + +type OnLogs = (logs: LogEntry[]) => void; +type OnError = (err: Error) => void; + +interface LogEntry { + removed: boolean; + blockNumber: bigint | null; + args: { validatorId?: number; validator?: string }; +} + +function makeLog(validatorId: number, blockNumber: bigint | null = 10n, removed = false): LogEntry { + return { removed, blockNumber, args: { validatorId, validator: VALIDATOR } }; +} + +/** + * Flush microtask queue so that mocked resolved promises (getBlockNumber, getLogs) + * can settle and the generator can advance to the live subscription phase. + */ +async function flush(ticks = 10): Promise { + for (let i = 0; i < ticks; i++) await Promise.resolve(); +} + +type Harness = { + client: EVMClient; + triggerLogs: OnLogs; + triggerError: OnError; + unwatchSpy: jest.Mock; +}; + +function makeClient(opts: { + headBlock?: bigint; + histLogs?: LogEntry[]; + getBlockNumberError?: Error; + getLogsError?: Error; +} = {}): Harness { + let onLogs: OnLogs | undefined; + let onError: OnError | undefined; + const unwatchSpy = jest.fn(); + + const client = { + getBlockNumber: opts.getBlockNumberError + ? jest.fn().mockRejectedValue(opts.getBlockNumberError) + : jest.fn().mockResolvedValue(opts.headBlock ?? 100n), + getLogs: opts.getLogsError + ? jest.fn().mockRejectedValue(opts.getLogsError) + : jest.fn().mockResolvedValue(opts.histLogs ?? []), + watchContractEvent: jest.fn().mockImplementation( + ({ onLogs: ol, onError: oe }: { onLogs: OnLogs; onError: OnError }) => { + onLogs = ol; + onError = oe; + return unwatchSpy; + }, + ), + } as unknown as EVMClient; + + return { + client, + triggerLogs: (logs) => { + if (!onLogs) throw new Error('watchContractEvent not yet reached'); + onLogs(logs); + }, + triggerError: (err) => { + if (!onError) throw new Error('watchContractEvent not yet reached'); + onError(err); + }, + unwatchSpy, + }; +} + +// ── tests ───────────────────────────────────────────────────────────────────── +// +// Pattern used throughout: +// const p = gen.next(); // start/advance the generator — do NOT await yet +// await flush(); // let mocked async ops (getBlockNumber, getLogs) settle +// triggerLogs/triggerError // inject behaviour +// await p; // now consume the result +// +// This keeps the gen.next() promise in scope so it can be caught if it rejects. + +describe('watchValidatorRegistered — live events (fromBlock = 0n)', () => { + it('yields an event pushed by watchContractEvent', async () => { + const { client, triggerLogs, triggerError, unwatchSpy } = makeClient(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const first = gen.next(); // generator starts, suspends at getBlockNumber + await flush(); // generator reaches live phase, suspends at wakeUp + triggerLogs([makeLog(1, 42n)]); // queue event + wake up generator + const { value } = await first; // generator yields the event + + expect(value).toMatchObject({ + blockchainId: CHAIN_ID, + validatorId: 1, + validator: VALIDATOR, + blockNumber: 42n, + }); + + const closing = gen.next(); + triggerError(new Error('done')); + await closing.catch(() => {}); + expect(unwatchSpy).toHaveBeenCalledTimes(1); + }); + + it('passes liveFromBlock = headBlock + 1n to watchContractEvent', async () => { + const { client, triggerError } = makeClient({ headBlock: 50n }); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const p = gen.next(); + await flush(); + triggerError(new Error('end')); + await p.catch(() => {}); + + expect(client.watchContractEvent).toHaveBeenCalledWith( + expect.objectContaining({ fromBlock: 51n }), + ); + }); + + it('calls unwatch when the subscription ends', async () => { + const { client, triggerError, unwatchSpy } = makeClient(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const p = gen.next(); + await flush(); + triggerError(new Error('done')); // ends the subscription + await p.catch(() => {}); + expect(unwatchSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('watchValidatorRegistered — historical replay (fromBlock > 0n)', () => { + it('yields historical logs in order before live events', async () => { + const { client, triggerError } = makeClient({ + headBlock: 50n, + histLogs: [makeLog(7, 20n), makeLog(8, 25n)], + }); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 5n); + + await flush(); // getBlockNumber + getLogs settle; generator yields first hist log + const ev1 = await gen.next(); + const ev2 = await gen.next(); + + expect(ev1.value).toMatchObject({ validatorId: 7, blockNumber: 20n }); + expect(ev2.value).toMatchObject({ validatorId: 8, blockNumber: 25n }); + expect(client.getLogs).toHaveBeenCalledWith( + expect.objectContaining({ fromBlock: 5n, toBlock: 50n }), + ); + + // Close cleanly: generator is at live phase, wake it and end it + const pClose = gen.next(); + await flush(); + triggerError(new Error('done')); + await pClose.catch(() => {}); + }); + + it('anchors live subscription at headBlock + 1n after historical fetch', async () => { + const { client, triggerError } = makeClient({ headBlock: 80n, histLogs: [] }); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 10n); + + const p = gen.next(); + await flush(); + triggerError(new Error('end')); + await p.catch(() => {}); + + expect(client.watchContractEvent).toHaveBeenCalledWith( + expect.objectContaining({ fromBlock: 81n }), + ); + }); + + it('skips historical phase when getBlockNumber fails and proceeds to live', async () => { + const { client, triggerError } = makeClient({ + getBlockNumberError: new Error('rpc down'), + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 5n); + + const p = gen.next(); + await flush(); + expect(client.getLogs).not.toHaveBeenCalled(); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[nitrolite]'), expect.anything()); + + triggerError(new Error('end')); + await p.catch(() => {}); + warnSpy.mockRestore(); + }); + + it('continues to live phase and yields live events when getLogs fails', async () => { + const { client, triggerLogs, triggerError } = makeClient({ + headBlock: 50n, + getLogsError: new Error('range too large'), + }); + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 5n); + + const first = gen.next(); + await flush(); // getLogs rejects; generator enters live phase + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('gap fill incomplete'), expect.anything()); + triggerLogs([makeLog(3, 55n)]); + const { value } = await first; + expect(value?.validatorId).toBe(3); + + const pClose = gen.next(); + triggerError(new Error('done')); + await pClose.catch(() => {}); + warnSpy.mockRestore(); + }); +}); + +describe('watchValidatorRegistered — reorg safety', () => { + it('skips removed=true logs during historical replay', async () => { + const { client, triggerError } = makeClient({ + headBlock: 50n, + histLogs: [makeLog(9, 10n, /* removed= */ true)], + }); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 5n); + const p = gen.next(); // start the generator + await flush(); // getLogs resolves, removed log skipped, live phase entered + expect(client.getLogs).toHaveBeenCalledTimes(1); + triggerError(new Error('done')); // wake and close + await p.catch(() => {}); + }); + + it('skips removed=true logs during live subscription', async () => { + const { client, triggerLogs, triggerError } = makeClient(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const p = gen.next(); + await flush(); + triggerLogs([makeLog(9, 10n, /* removed= */ true)]); // removed → dropped + triggerError(new Error('end')); // ends the generator + await p.catch(() => {}); + // If a removed log had been yielded, p would have resolved with it rather + // than rejecting from watchError — the catch confirms it was dropped. + }); + + it('skips live logs with null blockNumber', async () => { + const { client, triggerLogs, triggerError } = makeClient(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const p = gen.next(); + await flush(); + triggerLogs([makeLog(5, /* blockNumber= */ null)]); // null block → dropped + triggerError(new Error('end')); + await p.catch(() => {}); + }); +}); + +describe('watchValidatorRegistered — cancellation via AbortSignal', () => { + it('stops cleanly when AbortSignal fires during live phase', async () => { + const { client, triggerLogs, unwatchSpy } = makeClient(); + const ac = new AbortController(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n, ac.signal); + + const first = gen.next(); + await flush(); + triggerLogs([makeLog(1, 1n)]); + const { value } = await first; + expect(value?.validatorId).toBe(1); + + ac.abort(); + const tail = await gen.next(); + expect(tail.done).toBe(true); + expect(unwatchSpy).toHaveBeenCalledTimes(1); + }); + + it('stops cleanly when AbortSignal fires during historical replay', async () => { + let resolveGetLogs!: (v: LogEntry[]) => void; + const client = { + getBlockNumber: jest.fn().mockResolvedValue(50n), + getLogs: jest.fn().mockReturnValue(new Promise(r => { resolveGetLogs = r; })), + watchContractEvent: jest.fn(), + } as unknown as EVMClient; + + const ac = new AbortController(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 1n, ac.signal); + const started = gen.next(); + await flush(); // getBlockNumber resolved; getLogs is still pending + + ac.abort(); + resolveGetLogs([makeLog(1, 5n)]); // resolves after abort + await flush(); + + const result = await started; + expect(result.done).toBe(true); + expect(client.watchContractEvent).not.toHaveBeenCalled(); + }); +}); + +describe('watchValidatorRegistered — subscription error handling', () => { + it('propagates subscription errors as a thrown exception', async () => { + const { client, triggerError } = makeClient(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const p = gen.next(); + await flush(); + triggerError(new Error('connection dropped')); + await expect(p).rejects.toThrow('connection dropped'); + }); + + it('drains all queued events before propagating the subscription error', async () => { + const { client, triggerLogs, triggerError } = makeClient(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n); + + const first = gen.next(); + await flush(); + + // Queue two events then immediately error + triggerLogs([makeLog(1, 1n), makeLog(2, 2n)]); + triggerError(new Error('conn lost')); + + const ev1 = await first; + const ev2 = await gen.next(); + expect(ev1.value?.validatorId).toBe(1); + expect(ev2.value?.validatorId).toBe(2); + await expect(gen.next()).rejects.toThrow('conn lost'); + }); + + it('does not throw when abort fires while a watch error is also pending', async () => { + const { client, triggerError } = makeClient(); + const ac = new AbortController(); + const gen = watchValidatorRegistered(CONTRACT, client, CHAIN_ID, 0n, ac.signal); + + const p = gen.next(); + await flush(); + + triggerError(new Error('should not propagate')); + ac.abort(); + + // abort wins — generator returns cleanly without throwing + const result = await p; + expect(result.done).toBe(true); + }); +}); diff --git a/sdk/ts/test/unit/rpc-drift.test.ts b/sdk/ts/test/unit/rpc-drift.test.ts index d88898c9c..44404c310 100644 --- a/sdk/ts/test/unit/rpc-drift.test.ts +++ b/sdk/ts/test/unit/rpc-drift.test.ts @@ -83,7 +83,7 @@ describe('TS RPC drift guards', () => { ['app_sessions.v1.get_app_sessions', 'getAppSessions'], ['app_sessions.v1.create_app_session', 'createAppSession'], ['app_sessions.v1.submit_session_key_state', 'submitSessionKeyState'], - ['app_sessions.v1.get_last_key_states', 'getLastKeyStates'], + ['app_sessions.v1.get_last_key_states', 'getLastAppKeyStates'], ['apps.v1.get_apps', 'getApps'], ['apps.v1.submit_app_version', 'registerApp'], ]); diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts index 9dcba41a1..5528952c9 100644 --- a/sdk/ts/test/unit/transform-drift.test.ts +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -51,6 +51,7 @@ const channelKeyStateRaw = { assets: ['YUSD'], expires_at: '1739812234', user_sig: '0xabc123', + session_key_sig: '0xabc124', }; const appSessionKeyStateRaw = { @@ -61,6 +62,7 @@ const appSessionKeyStateRaw = { app_session_ids: ['0x00000000000000000000000000000000000000000000000000000000000000b1'], expires_at: '1739812234', user_sig: '0xdef456', + session_key_sig: '0xdef457', }; describe('Nitronode response transform drift guards', () => { @@ -227,6 +229,13 @@ describe('Nitronode response transform drift guards', () => { expect(transformAppSessionKeyState(appSessionKeyStateRaw)).toEqual(appSessionKeyStateRaw); }); + it('accepts empty session_key_sig for rows written before the column existed', () => { + const legacyChannel = { ...channelKeyStateRaw, session_key_sig: '' }; + const legacyApp = { ...appSessionKeyStateRaw, session_key_sig: '' }; + expect(transformChannelSessionKeyState(legacyChannel)).toEqual(legacyChannel); + expect(transformAppSessionKeyState(legacyApp)).toEqual(legacyApp); + }); + it('rejects malformed key-state fixtures with clear errors', () => { expect(() => transformChannelSessionKeyState( @@ -293,7 +302,7 @@ describe('Nitronode response transform drift guards', () => { clientLike, userAddress ); - const appSessionKeyStates = await (Client.prototype.getLastKeyStates as any).call( + const appSessionKeyStates = await (Client.prototype.getLastAppKeyStates as any).call( clientLike, userAddress ); @@ -318,6 +327,57 @@ describe('Nitronode response transform drift guards', () => { }); }); + it('forwards includeInactive to channel and app key-state wire requests', async () => { + const channelsV1GetLastKeyStates = jest.fn(async () => ({ states: [] })); + const appSessionsV1GetLastKeyStates = jest.fn(async () => ({ states: [] })); + const clientLike = { + rpcClient: { + channelsV1GetLastKeyStates, + appSessionsV1GetLastKeyStates, + }, + }; + + await (Client.prototype.getLastChannelKeyStates as any).call( + clientLike, + userAddress, + sessionKeyAddress, + { includeInactive: true } + ); + await (Client.prototype.getLastAppKeyStates as any).call( + clientLike, + userAddress, + sessionKeyAddress, + { includeInactive: true } + ); + + expect(channelsV1GetLastKeyStates).toHaveBeenCalledWith({ + user_address: userAddress, + session_key: sessionKeyAddress, + include_inactive: true, + }); + expect(appSessionsV1GetLastKeyStates).toHaveBeenCalledWith({ + user_address: userAddress, + session_key: sessionKeyAddress, + include_inactive: true, + }); + + // Default call (no options) must leave include_inactive undefined so the server + // applies its active-only default rather than seeing an explicit `false`. + await (Client.prototype.getLastChannelKeyStates as any).call(clientLike, userAddress); + await (Client.prototype.getLastAppKeyStates as any).call(clientLike, userAddress); + + expect(channelsV1GetLastKeyStates).toHaveBeenLastCalledWith({ + user_address: userAddress, + session_key: undefined, + include_inactive: undefined, + }); + expect(appSessionsV1GetLastKeyStates).toHaveBeenLastCalledWith({ + user_address: userAddress, + session_key: undefined, + include_inactive: undefined, + }); + }); + it('rejects malformed key-state response containers before mapping', async () => { const clientLike = { rpcClient: { @@ -334,7 +394,7 @@ describe('Nitronode response transform drift guards', () => { (Client.prototype.getLastChannelKeyStates as any).call(clientLike, userAddress) ).rejects.toThrow('Invalid channel key states response: expected states to be an array'); await expect( - (Client.prototype.getLastKeyStates as any).call(clientLike, userAddress) + (Client.prototype.getLastAppKeyStates as any).call(clientLike, userAddress) ).rejects.toThrow('Invalid app key states response: expected states to be an array'); });