From 6e7613fa592dd9f11fac951460f938b5341e647f Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Fri, 8 May 2026 15:56:14 +0200 Subject: [PATCH 01/26] MF-L01: fix(contracts/ChannelHub): cap ERC20 transfer returndata copy to 32 bytes (#726) --- contracts/src/ChannelHub.sol | 27 ++++-- .../ChannelHub_nonRevertingPushFunds.t.sol | 85 +++++++++++++++++++ .../test/mocks/MalformedReturningERC20.sol | 12 ++- contracts/test/mocks/OversizedReturnERC20.sol | 41 +++++++++ 4 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 contracts/test/mocks/OversizedReturnERC20.sol diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index 42790eeca..86d4feeea 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -1425,16 +1425,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/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/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); + } +} From 0cd799823363f1cfa3da6f7155fb010ad5997f22 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Fri, 8 May 2026 17:00:13 +0300 Subject: [PATCH 02/26] MF-H01: fix(nitronode): paginate get_last_key_states endpoints (#724) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Audit finding **MF-H01**: `channels.v1.get_last_key_states` and `app_sessions.v1.get_last_key_states` returned every session-key state for a wallet in a single response. A user who registers many keys for their own wallet could repeatedly trigger expensive DB reads and large payloads (DB / CPU / memory exhaustion). - Add mandatory pagination with a hard maximum page size of **10** to both endpoints. Default limit also **10**; values above the cap are silently clamped. - Store layer now returns `(states, totalCount, err)`, computed via a `COUNT()` subquery against the same join. Handlers populate the standard `PaginationMetadataV1` response field used elsewhere in the v1 API. - `ORDER BY (created_at DESC, id ASC)` for stable pagination — `id` is the primary key, used as a deterministic tiebreaker so concurrent inserts in the same `created_at` tick do not cause overlapping or missed rows across pages. ## Changes - `pkg/rpc/api.go`: add `Pagination` to `*GetLastKeyStatesRequest`, add `Metadata` to `*GetLastKeyStatesResponse` for both `channels.v1` and `app_sessions.v1`. - `nitronode/store/database/{channel,app_session}_session_key_state.go`: new signature `(wallet, sessionKey, limit, offset) -> (states, totalCount, err)`. Adds COUNT subquery + `LIMIT`/`OFFSET` + stable order. - `nitronode/store/database/interface.go`: signatures updated. - `nitronode/api/{channel_v1,app_session_v1}/`: handler `Store` interfaces, handlers, and mocks updated. Handlers parse pagination params, clamp to default 10 / max 10, build metadata. - `docs/api.yaml`: document new `pagination` request field and `metadata` response field for both methods. - DB store tests: bulk-updated existing call sites; added a new pagination subtest (5 keys, page 2/2/1, no overlap, `totalCount == 5`). ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `go test ./...` — all packages pass, including new pagination tests in `nitronode/store/database`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Added pagination to get_last_key_states for channels and apps: requests accept optional pagination params; responses include pagination metadata (page, per-page, total count, page count). Maximum page size is capped at 10. --------- Co-authored-by: Maharshi Mishra Co-authored-by: Claude Opus 4.7 (1M context) --- .github/workflows/main-pr.yml | 94 + .github/workflows/main-push.yml | 94 + .../protocol-drift-external-smoke.yml | 58 + .github/workflows/test-sdk.yml | 18 + docs/api.yaml | 22 +- docs/data_models.mmd | 12 + nitronode/api/app_session_v1/README.md | 7 + .../app_session_v1/create_app_session_test.go | 30 +- .../api/app_session_v1/get_last_key_states.go | 60 +- .../get_last_key_states_test.go | 134 + nitronode/api/app_session_v1/handler.go | 55 +- nitronode/api/app_session_v1/interface.go | 5 +- .../rebalance_app_sessions_test.go | 24 +- .../app_session_v1/submit_app_state_test.go | 38 +- .../submit_session_key_state.go | 29 +- .../submit_session_key_state_test.go | 85 +- nitronode/api/app_session_v1/testing.go | 19 +- .../api/channel_v1/get_last_key_states.go | 56 +- .../channel_v1/get_last_key_states_test.go | 133 + nitronode/api/channel_v1/handler.go | 47 +- nitronode/api/channel_v1/interface.go | 13 +- .../channel_v1/submit_session_key_state.go | 29 +- .../submit_session_key_state_test.go | 86 +- nitronode/api/channel_v1/testing.go | 19 +- nitronode/api/rpc_router.go | 5 +- ...7000000_add_current_session_key_states.sql | 39 + nitronode/main.go | 1 + nitronode/runtime.go | 11 +- .../store/database/app_session_key_state.go | 78 +- .../database/app_session_key_state_test.go | 60 +- .../database/channel_session_key_state.go | 70 +- .../channel_session_key_state_test.go | 80 +- .../database/current_session_key_state.go | 135 + .../current_session_key_state_test.go | 213 ++ nitronode/store/database/database.go | 46 +- nitronode/store/database/interface.go | 20 +- nitronode/store/database/testing.go | 4 +- pkg/rpc/api.go | 8 + pkg/rpc/types.go | 5 + scripts/check-protocol-drift.sh | 59 + scripts/drift/generate-app-signing-vectors.go | 139 + scripts/drift/runtime-smoke.mjs | 402 +++ sdk/PROTOCOL_DRIFT_GUARDS.md | 112 + sdk/ts-compat/package.json | 1 + .../public-api-drift.test.ts.snap | 1123 +++++++ sdk/ts-compat/test/unit/client.test.ts | 51 + .../test/unit/public-api-drift.test.ts | 269 ++ sdk/ts/package-lock.json | 3 +- sdk/ts/package.json | 4 +- sdk/ts/src/blockchain/evm/channel_hub_abi.ts | 11 +- sdk/ts/src/client.ts | 18 +- sdk/ts/src/rpc/api.ts | 8 + sdk/ts/src/session_key_state_transforms.ts | 56 + sdk/ts/src/utils.ts | 64 +- .../public-api-drift.test.ts.snap | 2661 +++++++++++++++++ sdk/ts/test/unit/abi-drift.test.ts | 278 ++ sdk/ts/test/unit/app-signing-drift.test.ts | 251 ++ sdk/ts/test/unit/public-api-drift.test.ts | 247 ++ sdk/ts/test/unit/rpc-drift.test.ts | 87 + sdk/ts/test/unit/rpc-dto-drift.test.ts | 173 ++ sdk/ts/test/unit/transform-drift.test.ts | 372 +++ 61 files changed, 8078 insertions(+), 253 deletions(-) create mode 100644 .github/workflows/protocol-drift-external-smoke.yml create mode 100644 nitronode/api/app_session_v1/get_last_key_states_test.go create mode 100644 nitronode/api/channel_v1/get_last_key_states_test.go create mode 100644 nitronode/config/migrations/postgres/20260507000000_add_current_session_key_states.sql create mode 100644 nitronode/store/database/current_session_key_state.go create mode 100644 nitronode/store/database/current_session_key_state_test.go create mode 100755 scripts/check-protocol-drift.sh create mode 100644 scripts/drift/generate-app-signing-vectors.go create mode 100644 scripts/drift/runtime-smoke.mjs create mode 100644 sdk/PROTOCOL_DRIFT_GUARDS.md create mode 100644 sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap create mode 100644 sdk/ts-compat/test/unit/public-api-drift.test.ts create mode 100644 sdk/ts/src/session_key_state_transforms.ts create mode 100644 sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap create mode 100644 sdk/ts/test/unit/abi-drift.test.ts create mode 100644 sdk/ts/test/unit/app-signing-drift.test.ts create mode 100644 sdk/ts/test/unit/public-api-drift.test.ts create mode 100644 sdk/ts/test/unit/rpc-dto-drift.test.ts create mode 100644 sdk/ts/test/unit/transform-drift.test.ts diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml index e6aa01213..18c733e48 100644 --- a/.github/workflows/main-pr.yml +++ b/.github/workflows/main-pr.yml @@ -22,6 +22,7 @@ jobs: with: project-path: 'sdk/ts' project-name: 'TS SDK' + forge-build: true test-sdk-compat: name: Test (TS SDK Compat) @@ -31,6 +32,99 @@ jobs: project-name: 'TS SDK Compat' bootstrap-project-path: 'sdk/ts' + test-protocol-drift-static: + name: Test (Protocol Drift Static) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + + - name: Build contract artifacts + run: forge build + working-directory: contracts + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run static protocol drift checks + run: ./scripts/check-protocol-drift.sh --static + + test-protocol-drift-runtime: + name: Test (Protocol Drift Runtime) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run runtime protocol drift smoke + run: ./scripts/check-protocol-drift.sh --runtime + env: + NITRONODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + + - name: Upload runtime smoke logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: protocol-drift-runtime-smoke-logs + path: runtime-smoke-logs + if-no-files-found: ignore + build-and-publish-nitronode: name: Build and Publish (Nitronode) needs: test-nitronode diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml index d084702f0..baffbf128 100644 --- a/.github/workflows/main-push.yml +++ b/.github/workflows/main-push.yml @@ -22,6 +22,7 @@ jobs: with: project-path: 'sdk/ts' project-name: 'TS SDK' + forge-build: true test-sdk-compat: name: Test (TS SDK Compat) @@ -31,6 +32,99 @@ jobs: project-name: 'TS SDK Compat' bootstrap-project-path: 'sdk/ts' + test-protocol-drift-static: + name: Test (Protocol Drift Static) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + + - name: Build contract artifacts + run: forge build + working-directory: contracts + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run static protocol drift checks + run: ./scripts/check-protocol-drift.sh --static + + test-protocol-drift-runtime: + name: Test (Protocol Drift Runtime) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run runtime protocol drift smoke + run: ./scripts/check-protocol-drift.sh --runtime + env: + NITRONODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + + - name: Upload runtime smoke logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: protocol-drift-runtime-smoke-logs + path: runtime-smoke-logs + if-no-files-found: ignore + # build-and-publish-sdk: # needs: [test-sdk-ts, test-sdk-compat] # name: Build and Publish (SDK) diff --git a/.github/workflows/protocol-drift-external-smoke.yml b/.github/workflows/protocol-drift-external-smoke.yml new file mode 100644 index 000000000..d28befb86 --- /dev/null +++ b/.github/workflows/protocol-drift-external-smoke.yml @@ -0,0 +1,58 @@ +name: Protocol Drift External Smoke + +on: + workflow_dispatch: + inputs: + nitronode_ws_url: + description: Existing Nitronode WebSocket URL to smoke test, for example wss://... + required: true + +permissions: + contents: read + +jobs: + protocol-drift-external-smoke: + name: Protocol Drift External Smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run external Nitronode compatibility smoke + run: ./scripts/check-protocol-drift.sh --runtime + env: + NITRONODE_RUNTIME_SMOKE_EXTERNAL: '1' + NITRONODE_RUNTIME_SMOKE_WS_URL: ${{ inputs.nitronode_ws_url }} + NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY: ${{ secrets.NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY }} + NITRONODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + + - name: Upload external smoke logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: protocol-drift-external-smoke-logs + path: runtime-smoke-logs + if-no-files-found: ignore diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml index a69729f9e..da0a6c9c2 100644 --- a/.github/workflows/test-sdk.yml +++ b/.github/workflows/test-sdk.yml @@ -16,6 +16,11 @@ on: required: false type: string default: '' + forge-build: + description: 'Build Foundry artifacts before validating this SDK project' + required: false + type: boolean + default: false jobs: test: @@ -25,6 +30,19 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + submodules: ${{ inputs.forge-build && 'recursive' || 'false' }} + + - name: Install Foundry + if: ${{ inputs.forge-build }} + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + + - name: Build contract artifacts + if: ${{ inputs.forge-build }} + run: forge build + working-directory: contracts - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/docs/api.yaml b/docs/api.yaml index db4eea051..eb42a763d 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -639,7 +639,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. Mandatory pagination caps response size (max page size 10). request: - field_name: user_address type: string @@ -648,12 +648,19 @@ api: type: string description: Optionally filter by session key 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 + type: channel_session_key_state description: List of active session key states for the user + - field_name: metadata + type: pagination_metadata + description: Pagination information errors: - message: account_not_found description: The specified account was not found @@ -827,21 +834,28 @@ 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. 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: 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 + - 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..e997137b8 100644 --- a/docs/data_models.mmd +++ b/docs/data_models.mmd @@ -224,6 +224,14 @@ 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 + } + %% ===== BLOCKCHAIN TABLES ===== class ContractEvent { @@ -321,6 +329,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/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_test.go b/nitronode/api/app_session_v1/create_app_session_test.go index 4b8047048..7bd3186e2 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 @@ -244,7 +244,7 @@ func TestCreateAppSession_ZeroNonce(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -309,7 +309,7 @@ func TestCreateAppSession_QuorumExceedsTotalWeights(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -380,7 +380,7 @@ func TestCreateAppSession_NoSignatures(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -445,7 +445,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 +527,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 +623,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 +716,7 @@ func TestCreateAppSession_InvalidSignatureHex(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -785,7 +785,7 @@ func TestCreateAppSession_SignatureRecoveryFailure(t *testing.T) { "0xnode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) // Test data @@ -854,7 +854,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 +924,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 +1001,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 +1102,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 +1193,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..0bc40e916 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,43 @@ 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 + } + logger.Debug("retrieving session key states", - "wallet", req.UserAddress, - "sessionKey", req.SessionKey) + "userAddress", req.UserAddress, + "sessionKey", req.SessionKey, + "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, limit, offset) return err }) @@ -46,7 +71,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 +83,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..472c10092 --- /dev/null +++ b/nitronode/api/app_session_v1/get_last_key_states_test.go @@ -0,0 +1,134 @@ +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), 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), 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), 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) +} + +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") +} diff --git a/nitronode/api/app_session_v1/handler.go b/nitronode/api/app_session_v1/handler.go index e8918dec6..e731ddfdf 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, } } diff --git a/nitronode/api/app_session_v1/interface.go b/nitronode/api/app_session_v1/interface.go index 6720ad7b0..b0b33b2c5 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" @@ -43,9 +44,11 @@ type Store interface { EnsureNoOngoingStateTransitions(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(wallet string, sessionKey *string, 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..af704a71a 100644 --- a/nitronode/api/app_session_v1/submit_app_state_test.go +++ b/nitronode/api/app_session_v1/submit_app_state_test.go @@ -37,7 +37,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 +153,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 +276,7 @@ func TestSubmitAppState_WithdrawIntent_Success(t *testing.T) { nodeAddress, true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -412,7 +412,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" @@ -544,7 +544,7 @@ func TestSubmitAppState_CloseIntent_Success(t *testing.T) { nodeAddress, true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -709,7 +709,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 +809,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 +923,7 @@ func TestSubmitAppState_WithdrawIntent_MissingAllocation_Rejected(t *testing.T) "0xNode", true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, + 32, 1024, 256, 16, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1035,7 +1035,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 +1091,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 +1162,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 +1238,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 +1299,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 +1408,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 +1513,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 +1635,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 +1741,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 +1832,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 +1925,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_session_key_state.go b/nitronode/api/app_session_v1/submit_session_key_state.go index dd8b096db..4015f2ad9 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state.go +++ b/nitronode/api/app_session_v1/submit_session_key_state.go @@ -6,6 +6,7 @@ import ( "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" @@ -105,10 +106,32 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // 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) + 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..d99569bd5 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,6 +14,7 @@ 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" @@ -82,7 +83,7 @@ func TestSubmitSessionKeyState_Success(t *testing.T) { reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner) - 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) @@ -227,7 +228,7 @@ func TestSubmitSessionKeyState_AtMaxLimit(t *testing.T) { reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, appIDs, sessionIDs, expiresAt, userSigner) - 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) @@ -379,7 +380,7 @@ func TestSubmitSessionKeyState_VersionMismatch(t *testing.T) { // Submit version 3 when latest is 0 (expects 1) reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, []string{}, expiresAt, userSigner) - 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 +399,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) + + 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) + + 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() diff --git a/nitronode/api/app_session_v1/testing.go b/nitronode/api/app_session_v1/testing.go index f5c997af3..e2effdf26 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" @@ -107,6 +108,16 @@ func (m *MockStore) EnsureNoOngoingStateTransitions(wallet, asset string) error 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 +128,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, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) { + args := m.Called(wallet, sessionKey, 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/channel_v1/get_last_key_states.go b/nitronode/api/channel_v1/get_last_key_states.go index 001d96008..3cc748948 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,39 @@ 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 + } + logger.Debug("retrieving channel session key states", "userAddress", req.UserAddress, - "sessionKey", req.SessionKey) + "sessionKey", req.SessionKey, + "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, limit, offset) return err }) @@ -46,7 +71,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 +83,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..914f13540 --- /dev/null +++ b/nitronode/api/channel_v1/get_last_key_states_test.go @@ -0,0 +1,133 @@ +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), 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), 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), 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) +} + +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") +} diff --git a/nitronode/api/channel_v1/handler.go b/nitronode/api/channel_v1/handler.go index b32fec5ac..a1745ce59 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, } } diff --git a/nitronode/api/channel_v1/interface.go b/nitronode/api/channel_v1/interface.go index 393b757ec..1f2e84723 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" ) @@ -68,6 +69,14 @@ type Store interface { // 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 +85,8 @@ 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. Results are paginated. + GetLastChannelSessionKeyStates(wallet string, sessionKey *string, 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/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index 68de5c238..e1009c53f 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -3,6 +3,7 @@ package channel_v1 import ( "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" @@ -69,10 +70,32 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // 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) + 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..b3d8356fc 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -14,6 +14,7 @@ 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" @@ -68,7 +69,7 @@ func TestChannelSubmitSessionKeyState_Success(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner) - 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) @@ -146,7 +147,7 @@ func TestChannelSubmitSessionKeyState_AtMaxLimit(t *testing.T) { reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner) - 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) @@ -295,7 +296,7 @@ func TestChannelSubmitSessionKeyState_VersionMismatch(t *testing.T) { // Submit version 3 when latest is 0 (expects 1) reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{}, expiresAt, userSigner) - 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 +315,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) + + 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) + + 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() diff --git a/nitronode/api/channel_v1/testing.go b/nitronode/api/channel_v1/testing.go index f2d8af3ac..0d50290ac 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" ) @@ -94,6 +95,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 +115,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, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) { + args := m.Called(wallet, sessionKey, 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/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/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/main.go b/nitronode/main.go index 8c596bfeb..568cc6cca 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, } diff --git a/nitronode/runtime.go b/nitronode/runtime.go index b114ee132..2093e6342 100644 --- a/nitronode/runtime.go +++ b/nitronode/runtime.go @@ -90,11 +90,12 @@ 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"` } // InitBackbone initializes the backbone components of the application. diff --git a/nitronode/store/database/app_session_key_state.go b/nitronode/store/database/app_session_key_state.go index 76bcdd597..f582be5af 100644 --- a/nitronode/store/database/app_session_key_state.go +++ b/nitronode/store/database/app_session_key_state.go @@ -96,35 +96,47 @@ 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. +// Results are paginated; totalCount is the unpaginated total of matching session keys. +func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) { wallet = strings.ToLower(wallet) - 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.Model(&CurrentSessionKeyStateV1{}). + Where("user_address = ? AND kind = ? AND version > 0", wallet, SessionKeyKindAppSession) if sessionKey != nil && *sessionKey != "" { - subQuery = subQuery.Where("session_key = ?", strings.ToLower(*sessionKey)) + pointerQuery = pointerQuery.Where("session_key = ?", strings.ToLower(*sessionKey)) } - 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)) + } 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 +144,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 +166,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 +205,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 { diff --git a/nitronode/store/database/app_session_key_state_test.go b/nitronode/store/database/app_session_key_state_test.go index 823f76049..bd1c6c1ab 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, 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, 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, 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, 100, 0) require.NoError(t, err) assert.Empty(t, results) }) @@ -530,12 +530,60 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreAppSessionKeyState(state2)) - results, err := store.GetLastAppSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, 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, 2, 0) + require.NoError(t, err) + assert.Len(t, page1, 2) + assert.Equal(t, uint32(numKeys), total) + + page2, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, 2, 2) + require.NoError(t, err) + assert.Len(t, page2, 2) + assert.Equal(t, uint32(numKeys), total) + + page3, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, 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) + } + }) } func TestDBStore_AppSessionKeyState_ForeignRelations(t *testing.T) { @@ -736,7 +784,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, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) diff --git a/nitronode/store/database/channel_session_key_state.go b/nitronode/store/database/channel_session_key_state.go index aa41159a7..be04e0d39 100644 --- a/nitronode/store/database/channel_session_key_state.go +++ b/nitronode/store/database/channel_session_key_state.go @@ -78,34 +78,46 @@ 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. +// Results are paginated; totalCount is the unpaginated total of matching session keys. +func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) { wallet = strings.ToLower(wallet) - 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.Model(&CurrentSessionKeyStateV1{}). + Where("user_address = ? AND kind = ? AND version > 0", wallet, SessionKeyKindChannel) if sessionKey != nil && *sessionKey != "" { - subQuery = subQuery.Where("session_key = ?", strings.ToLower(*sessionKey)) + pointerQuery = pointerQuery.Where("session_key = ?", strings.ToLower(*sessionKey)) } - 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). + 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.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)) + } 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 +125,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 +147,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 +164,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 { diff --git a/nitronode/store/database/channel_session_key_state_test.go b/nitronode/store/database/channel_session_key_state_test.go index 4263cc63d..1ea89e7c4 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, 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, 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, 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, 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, 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, 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, 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, 100, 0) require.NoError(t, err) assert.Empty(t, results) }) @@ -347,12 +347,70 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(state2)) - results, err := store.GetLastChannelSessionKeyStates(testUser1, nil) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, 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, 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, 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, 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) + } + }) +} + +// 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) { @@ -802,7 +860,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, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) @@ -854,7 +912,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, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) 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..552c28dbe --- /dev/null +++ b/nitronode/store/database/current_session_key_state.go @@ -0,0 +1,135 @@ +package database + +import ( + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// 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). +type CurrentSessionKeyStateV1 struct { + UserAddress string `gorm:"column:user_address;primaryKey;size:42"` + SessionKey string `gorm:"column:session_key;primaryKey;size:42"` + Kind SessionKeyKind `gorm:"column:kind;primaryKey;type:smallint"` + 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 ensures a pointer row exists for (user, session_key, kind) and locks it +// for the duration of the surrounding transaction. Returns the current version (0 if newly +// created). Mirrors LockUserState. On non-postgres dialects, falls back to read-without-lock. +func (s *DBStore) LockSessionKeyState(userAddress, sessionKey string, kind SessionKeyKind) (uint64, error) { + userAddress = strings.ToLower(userAddress) + sessionKey = strings.ToLower(sessionKey) + + if s.db.Dialector.Name() == "postgres" { + 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) + } + + var locked CurrentSessionKeyStateV1 + err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_address = ? AND session_key = ? AND kind = ?", userAddress, sessionKey, kind). + First(&locked).Error + if err != nil { + return 0, fmt.Errorf("failed to lock current session key state: %w", err) + } + return locked.Version, nil + } + + var existing CurrentSessionKeyStateV1 + err := s.db.Where("user_address = ? AND session_key = ? AND kind = ?", userAddress, sessionKey, kind). + First(&existing).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + seed := CurrentSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKey, + Kind: kind, + Version: 0, + UpdatedAt: time.Now().UTC(), + } + if err := s.db.Create(&seed).Error; err != nil { + return 0, fmt.Errorf("failed to create current session key state: %w", err) + } + return 0, nil + } + return 0, fmt.Errorf("failed to read current session key state: %w", err) + } + return existing.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..234a49b86 --- /dev/null +++ b/nitronode/store/database/current_session_key_state_test.go @@ -0,0 +1,213 @@ +package database + +import ( + "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()) +} + +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("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 f58601d90..d0d1435c5 100644 --- a/nitronode/store/database/database.go +++ b/nitronode/store/database/database.go @@ -1,9 +1,11 @@ package database import ( + "database/sql" "embed" "fmt" "log" + "strings" "time" "github.com/jmoiron/sqlx" @@ -120,7 +122,9 @@ func connectToSqlite(cnf DatabaseConfig) (*gorm.DB, error) { } // Migrate sqlite - migrateSqlite(db) + if err := migrateSqlite(db); err != nil { + return nil, fmt.Errorf("failed to auto-migrate sqlite: %w", err) + } log.Println("Successfully auto-migrated") @@ -165,17 +169,15 @@ func ensurePostgresqlSchema(cnf DatabaseConfig) error { return err } - queryDbCheck := fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name='%s'", cnf.Schema) - if res, err := db.Exec(queryDbCheck); err != nil { - return fmt.Errorf("error while checking schema existance: %s", err.Error()) - } else if rows, err := res.RowsAffected(); err != nil { + var exists int + if err := db.QueryRow("SELECT 1 FROM information_schema.schemata WHERE schema_name=$1", cnf.Schema).Scan(&exists); err != nil && err != sql.ErrNoRows { return fmt.Errorf("error while checking schema existance: %s", err.Error()) - } else if rows > 0 { + } else if err == nil { log.Printf("Schema already exists: %s\n", cnf.Schema) return nil } - if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", cnf.Schema)); err != nil { + if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quotePostgresIdentifier(cnf.Schema))); err != nil { return fmt.Errorf("error while creating schema: %s", err.Error()) } @@ -197,7 +199,7 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { if cnf.Schema != "" { switch cnf.Driver { case "postgres": - if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s", cnf.Schema)); err != nil { + if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s", quotePostgresIdentifier(cnf.Schema))); err != nil { return fmt.Errorf("failed to set search path: %v", err) } } @@ -206,7 +208,7 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { log.Println("Applying database migrations") goose.SetBaseFS(embedMigrations) if err := goose.Up(db, "config/migrations/"+cnf.Driver); err != nil { - panic(err) + return fmt.Errorf("goose migration failed: %w", err) } log.Println("Applied migrations") @@ -214,8 +216,32 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { } func migrateSqlite(db *gorm.DB) error { - if err := db.AutoMigrate(&AppV1{}, &AppLedgerEntryV1{}, &Channel{}, &AppSessionV1{}, &ContractEvent{}, &State{}, &Transaction{}, &BlockchainAction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}); err != nil { + if err := db.AutoMigrate( + &AppV1{}, + &AppLedgerEntryV1{}, + &Channel{}, + &AppSessionV1{}, + &AppParticipantV1{}, + &ContractEvent{}, + &State{}, + &Transaction{}, + &BlockchainAction{}, + &AppSessionKeyStateV1{}, + &AppSessionKeyApplicationV1{}, + &AppSessionKeyAppSessionIDV1{}, + &ChannelSessionKeyStateV1{}, + &ChannelSessionKeyAssetV1{}, + &CurrentSessionKeyStateV1{}, + &UserBalance{}, + &UserStakedV1{}, + &ActionLogEntryV1{}, + &LifespanMetric{}, + ); err != nil { return err } return nil } + +func quotePostgresIdentifier(identifier string) string { + return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"` +} diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index ca3511b00..72f87dc70 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -160,6 +160,16 @@ 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 ensures the (user, session_key, kind) pointer row exists and locks + // it for the surrounding transaction. Returns the current version (0 if newly created). + 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 +185,9 @@ 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. Results are paginated; totalCount is the unpaginated total matching the filter. + GetLastAppSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) // --- Channel Session Key State Operations --- @@ -188,8 +199,9 @@ 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. Results are paginated; totalCount is the unpaginated + // total matching the filter. + GetLastChannelSessionKeyStates(wallet string, sessionKey *string, 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/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/rpc/api.go b/pkg/rpc/api.go index 1689aec08..a67ae12e3 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -126,12 +126,16 @@ 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 + // Pagination contains pagination parameters (offset, limit, sort) + Pagination *PaginationParamsV1 `json:"pagination,omitempty"` } // ChannelsV1GetSessionKeysResponse returns the list of active session keys. type ChannelsV1GetLastKeyStatesResponse struct { // States is the list of active session key states for the user States []ChannelSessionKeyStateV1 `json:"states"` + // Metadata contains pagination information + Metadata PaginationMetadataV1 `json:"metadata"` } // ============================================================================ @@ -254,12 +258,16 @@ 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 + // Pagination contains pagination parameters (offset, limit, sort) + Pagination *PaginationParamsV1 `json:"pagination,omitempty"` } // SessionKeysV1GetSessionKeysResponse returns the list of active session keys. type AppSessionsV1GetLastKeyStatesResponse struct { // States is the list of active session key states for the user States []AppSessionKeyStateV1 `json:"states"` + // Metadata contains pagination information + Metadata PaginationMetadataV1 `json:"metadata"` } // ============================================================================ diff --git a/pkg/rpc/types.go b/pkg/rpc/types.go index a5ca97330..2291302d0 100644 --- a/pkg/rpc/types.go +++ b/pkg/rpc/types.go @@ -364,3 +364,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/scripts/check-protocol-drift.sh b/scripts/check-protocol-drift.sh new file mode 100755 index 000000000..3ea0148dc --- /dev/null +++ b/scripts/check-protocol-drift.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'USAGE' +Usage: scripts/check-protocol-drift.sh [--static|--runtime] + + --static Run deterministic protocol/SDK/compat drift checks. + --runtime Run runtime smoke checks against an ephemeral/local Nitronode. + +Runtime smoke starts an isolated local Nitronode with a temporary config by +default. Set NITRONODE_RUNTIME_SMOKE_EXTERNAL=1, NITRONODE_RUNTIME_SMOKE_WS_URL, +and NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY to run the same lightweight compatibility +smoke against an existing node. This is not a load or stress test. +USAGE +} + +run_package() { + local package_path="$1" + local command_name="$2" + local full_path="$ROOT/$package_path" + + if [[ ! -d "$full_path" ]]; then + echo "::error::drift check package path does not exist: $package_path" >&2 + return 1 + fi + + echo + echo "==> $package_path: npm run $command_name" + ( + cd "$full_path" + npm run "$command_name" + ) +} + +mode="${1:---static}" + +case "$mode" in + --static) + echo "==> Running deterministic Nitrolite protocol drift checks" + run_package "sdk/ts" "drift:check" + run_package "sdk/ts-compat" "drift:check" + ;; + --runtime) + echo "==> Running Nitrolite protocol runtime smoke checks" + run_package "sdk/ts" "build:ci" + run_package "sdk/ts-compat" "build:ci" + node "$ROOT/scripts/drift/runtime-smoke.mjs" + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/scripts/drift/generate-app-signing-vectors.go b/scripts/drift/generate-app-signing-vectors.go new file mode 100644 index 000000000..00fcbb93b --- /dev/null +++ b/scripts/drift/generate-app-signing-vectors.go @@ -0,0 +1,139 @@ +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "math" + "os" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/layer-3/nitrolite/pkg/app" + "github.com/shopspring/decimal" +) + +const ( + user = "0x1111111111111111111111111111111111111111" + svc = "0x2222222222222222222222222222222222222222" +) + +type vector struct { + Name string `json:"name"` + Hash string `json:"hash"` +} + +func main() { + definition := app.AppDefinitionV1{ + ApplicationID: "store-v1", + Participants: []app.AppParticipantV1{ + {WalletAddress: user, SignatureWeight: 1}, + {WalletAddress: svc, SignatureWeight: 1}, + }, + Quorum: 2, + Nonce: 123456789, + } + + appSessionID, err := app.GenerateAppSessionIDV1(definition) + must("generate app session id", err) + + vectors := []vector{ + hashCreate("create_session", definition, `{"cart":"demo"}`), + {Name: "app_session_id", Hash: appSessionID}, + hashUpdate("deposit", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 2, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("1.25")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0")}, + }, + SessionData: `{"intent":"deposit"}`, + }), + hashUpdate("operate_purchase", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentOperate, + Version: 3, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("0.35")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0.90")}, + }, + SessionData: `{"intent":"purchase","item_id":1,"item_price":"0.90"}`, + }), + hashUpdate("withdraw", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentWithdraw, + Version: 4, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("0.10")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0.90")}, + }, + SessionData: `{"intent":"withdraw"}`, + }), + hashUpdate("fractional_deposit", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 5, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("1.23456789")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0")}, + }, + SessionData: `{"intent":"deposit","note":"fractional"}`, + }), + hashUpdate("max_uint64_version", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentWithdraw, + Version: math.MaxUint64, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("0")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("1.25")}, + }, + SessionData: `{"intent":"withdraw","boundary":"max_uint64_version"}`, + }), + hashCreate("max_uint64_nonce_create_session", app.AppDefinitionV1{ + ApplicationID: definition.ApplicationID, + Participants: definition.Participants, + Quorum: definition.Quorum, + Nonce: math.MaxUint64, + }, `{"cart":"max-nonce"}`), + hashSessionKey("session_key_state", app.AppSessionKeyStateV1{ + UserAddress: user, + SessionKey: svc, + Version: 1, + ApplicationIDs: []string{"0x00000000000000000000000000000000000000000000000000000000000000a1"}, + AppSessionIDs: []string{"0x00000000000000000000000000000000000000000000000000000000000000b1"}, + ExpiresAt: time.Unix(1739812234, 0).UTC(), + UserSig: "0xSig", + }), + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + must("encode vectors", encoder.Encode(vectors)) +} + +func hashCreate(name string, definition app.AppDefinitionV1, sessionData string) vector { + hash, err := app.PackCreateAppSessionRequestV1(definition, sessionData) + must(name, err) + return vector{Name: name, Hash: hexutil.Encode(hash)} +} + +func hashUpdate(name string, update app.AppStateUpdateV1) vector { + hash, err := app.PackAppStateUpdateV1(update) + must(name, err) + return vector{Name: name, Hash: hexutil.Encode(hash)} +} + +func hashSessionKey(name string, state app.AppSessionKeyStateV1) vector { + hash, err := app.PackAppSessionKeyStateV1(state) + must(name, err) + return vector{Name: name, Hash: hexutil.Encode(hash)} +} + +func must(action string, err error) { + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%s: %v\n", action, err) + os.Exit(1) + } +} diff --git a/scripts/drift/runtime-smoke.mjs b/scripts/drift/runtime-smoke.mjs new file mode 100644 index 000000000..9edf6cb96 --- /dev/null +++ b/scripts/drift/runtime-smoke.mjs @@ -0,0 +1,402 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { Client, createSigners, withErrorHandler } from '../../sdk/ts/dist/index.js'; +import { NitroliteClient } from '../../sdk/ts-compat/dist/index.js'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '../..'); +const sdkRequire = createRequire(path.join(repoRoot, 'sdk/ts/package.json')); +const WebSocketCtor = globalThis.WebSocket ?? sdkRequire('ws'); +const wsURL = process.env.NITRONODE_RUNTIME_SMOKE_WS_URL ?? 'ws://127.0.0.1:7824/ws'; +const readyTimeoutMs = Number(process.env.NITRONODE_RUNTIME_SMOKE_READY_TIMEOUT_MS ?? 15000); +const adversarialMode = process.env.NITRONODE_RUNTIME_SMOKE_ADVERSARIAL ?? ''; +const externalLogDirInput = process.env.NITRONODE_RUNTIME_SMOKE_LOG_DIR ?? ''; +const useExternalNode = process.env.NITRONODE_RUNTIME_SMOKE_EXTERNAL === '1'; +const anvilPrivateKey = + '0x59c6995e998f97a5a0044966f094538f0d0921e301baca6a9ae52cd7834c90b9'; + +class SmokeError extends Error { + constructor(category, message, cause) { + super(`[${category}] ${message}${cause ? `: ${cause.message ?? cause}` : ''}`); + this.name = 'SmokeError'; + this.category = category; + this.cause = cause; + } +} + +function assertSmoke(condition, category, message) { + if (!condition) { + throw new SmokeError(category, message); + } +} + +function resolveRepoChildPath(input, label) { + const resolved = path.resolve(repoRoot, input); + const relative = path.relative(repoRoot, resolved); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new SmokeError('setup', `${label} must resolve inside the repository`); + } + return resolved; +} + +function privateKeyForMode() { + const configuredPrivateKey = process.env.NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY; + if (useExternalNode) { + if (!configuredPrivateKey) { + throw new SmokeError( + 'setup', + 'NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY is required when NITRONODE_RUNTIME_SMOKE_EXTERNAL=1' + ); + } + return configuredPrivateKey; + } + + // Well-known Anvil/Hardhat test account #2, used only for isolated local smoke. + return configuredPrivateKey ?? anvilPrivateKey; +} + +function childEnv(configDir) { + const env = { + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + NITRONODE_CONFIG_DIR_PATH: configDir, + }; + + return Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined)); +} + +function logStep(message) { + console.log(`[runtime-smoke] ${message}`); +} + +const externalLogDir = externalLogDirInput + ? resolveRepoChildPath(externalLogDirInput, 'NITRONODE_RUNTIME_SMOKE_LOG_DIR') + : ''; +const privateKey = privateKeyForMode(); + +async function withTimeout(label, promise, timeoutMs = 5000) { + const timeout = sleep(timeoutMs).then(() => { + throw new SmokeError('timeout', `${label} timed out after ${timeoutMs}ms`); + }); + return Promise.race([promise, timeout]); +} + +function openWebSocket(url, timeoutMs = 500) { + return new Promise((resolve, reject) => { + const ws = new WebSocketCtor(url); + let settled = false; + + const finish = (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + ws.close(); + } catch { + // Ignore close errors while probing readiness. + } + if (err) reject(err); + else resolve(); + }; + + const timer = setTimeout(() => finish(new Error('WebSocket connect timeout')), timeoutMs); + ws.onopen = () => finish(); + ws.onerror = () => finish(new Error('WebSocket connection error')); + ws.onclose = () => finish(new Error('WebSocket closed before open')); + }); +} + +async function waitForWebSocket(url, child = null, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + if (child && child.exitCode !== null) { + throw new SmokeError( + 'startup', + `Nitronode exited before readiness with code ${child.exitCode}` + ); + } + + try { + await openWebSocket(url); + return; + } catch (err) { + lastError = err; + await sleep(250); + } + } + + throw new SmokeError( + 'connection', + `Nitronode did not accept WebSocket connections at ${url}`, + lastError + ); +} + +async function stopProcess(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + + child.kill('SIGTERM'); + const exited = await Promise.race([ + once(child, 'exit').then(() => true), + sleep(5000).then(() => false), + ]); + if (exited) return; + + child.kill('SIGKILL'); +} + +async function closeClient(client) { + if (!client) return; + + const closed = await Promise.race([ + client.close().then( + () => true, + (err) => { + console.warn(`[runtime-smoke] client.close failed: ${err.message ?? err}`); + return true; + } + ), + sleep(3000).then(() => false), + ]); + + if (!closed) { + console.warn('[runtime-smoke] client.close timed out; continuing cleanup'); + } +} + +async function runCommand(command, args, options, category) { + return new Promise((resolve, reject) => { + let stderr = ''; + const child = spawn(command, args, options); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', (err) => reject(new SmokeError(category, `${command} failed to start`, err))); + child.on('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new SmokeError( + category, + `${command} ${args.join(' ')} exited with ${signal ?? code}${stderr ? `\n${stderr}` : ''}` + ) + ); + }); + }); +} + +async function writeConfig(configDir) { + await writeFile( + path.join(configDir, '.env'), + [ + 'NITRONODE_DATABASE_DRIVER=sqlite', + 'NITRONODE_SIGNER_TYPE=key', + `NITRONODE_SIGNER_KEY=${privateKey}`, + 'NITRONODE_LOG_LEVEL=error', + '', + ].join('\n') + ); + + if (adversarialMode === 'bad-config') { + await writeFile(path.join(configDir, 'blockchains.yaml'), 'blockchains:\n - name: BAD_NAME\n'); + await writeFile(path.join(configDir, 'assets.yaml'), 'assets: []\n'); + return; + } + + await writeFile(path.join(configDir, 'blockchains.yaml'), 'blockchains: []\n'); + await writeFile(path.join(configDir, 'assets.yaml'), 'assets: []\n'); +} + +async function writeFailureLogs(paths, stdout, stderr, summary) { + await writeFile(paths.stdoutPath, stdout); + await writeFile(paths.stderrPath, stderr); + + if (!externalLogDir) return; + + await mkdir(externalLogDir, { recursive: true }); + await writeFile(path.join(externalLogDir, 'summary.txt'), summary); + await writeFile(path.join(externalLogDir, 'nitronode.stdout.log'), stdout); + await writeFile(path.join(externalLogDir, 'nitronode.stderr.log'), stderr); +} + +async function runSmoke() { + const configDir = await mkdtemp(path.join(tmpdir(), 'nitrolite-runtime-smoke-')); + const binaryPath = path.join(configDir, 'nitronode-smoke'); + const stdoutPath = path.join(configDir, 'nitronode.stdout.log'); + const stderrPath = path.join(configDir, 'nitronode.stderr.log'); + let stdout = ''; + let stderr = ''; + let client = null; + let child = null; + let compatLogLines = []; + + const logs = () => [ + `stdout (${stdoutPath}):`, + stdout.trim() || '', + `stderr (${stderrPath}):`, + stderr.trim() || '', + ].join('\n'); + + try { + if (useExternalNode) { + logStep(`using external Nitronode at ${wsURL}`); + } else { + logStep(`writing isolated config in ${configDir}`); + await writeConfig(configDir); + logStep('building temporary Nitronode binary'); + await runCommand('go', ['build', '-o', binaryPath, './nitronode'], { cwd: repoRoot }, 'setup'); + + logStep(`starting Nitronode and waiting for ${wsURL}`); + child = spawn(binaryPath, { + cwd: repoRoot, + env: childEnv(configDir), + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + } + + await waitForWebSocket(wsURL, child, readyTimeoutMs); + + const { stateSigner, txSigner } = createSigners(privateKey); + const wallet = stateSigner.getAddress(); + logStep(`creating TS SDK client for wallet ${wallet}`); + client = await withTimeout( + 'Client.create', + Client.create(wsURL, stateSigner, txSigner, withErrorHandler(() => {})) + ); + + logStep('calling ping'); + await withTimeout('client.ping', client.ping()); + + logStep('calling getConfig'); + const config = await withTimeout('client.getConfig', client.getConfig()); + assertSmoke(typeof config.nodeAddress === 'string', 'transform', 'node config nodeAddress is not a string'); + assertSmoke(Array.isArray(config.blockchains), 'transform', 'node config blockchains is not an array'); + assertSmoke( + Array.isArray(config.supportedSigValidators), + 'transform', + 'node config supportedSigValidators is not an array' + ); + if (!useExternalNode) { + assertSmoke( + config.nodeAddress.toLowerCase() === wallet.toLowerCase(), + 'transform', + `expected node address ${wallet}, got ${config.nodeAddress}` + ); + assertSmoke(config.blockchains.length === 0, 'transform', 'runtime smoke config should expose no blockchains'); + } + + logStep('calling getAssets'); + const assets = await withTimeout('client.getAssets', client.getAssets()); + assertSmoke(Array.isArray(assets), 'transform', 'assets response is not an array'); + if (!useExternalNode) { + assertSmoke(assets.length === 0, 'transform', 'runtime smoke config should expose no assets'); + } + + logStep('calling getAppSessions'); + const appSessions = await withTimeout( + 'client.getAppSessions', + client.getAppSessions({ wallet }) + ); + assertSmoke(Array.isArray(appSessions.sessions), 'transform', 'app sessions is not an array'); + assertSmoke(appSessions.sessions.length === 0, 'transform', 'expected no app sessions for smoke wallet'); + + logStep('calling getLastChannelKeyStates'); + const channelKeyStates = await withTimeout( + 'client.getLastChannelKeyStates', + client.getLastChannelKeyStates(wallet) + ); + assertSmoke(Array.isArray(channelKeyStates), 'transform', 'channel key states is not an array'); + + logStep('calling getLastKeyStates'); + const appSessionKeyStates = await withTimeout( + 'client.getLastKeyStates', + client.getLastKeyStates(wallet) + ); + assertSmoke(Array.isArray(appSessionKeyStates), 'transform', 'app session key states is not an array'); + + logStep('validating compat getAppSessionsList mapping'); + const compatClient = Object.create(NitroliteClient.prototype); + compatClient.userAddress = wallet; + compatClient.innerClient = client; + compatClient.assetsBySymbol = new Map(); + compatClient._lastAppSessionsListError = null; + compatClient._lastAppSessionsListErrorLogged = null; + + const originalInfo = console.info; + const originalWarn = console.warn; + let compatSessions; + try { + compatLogLines = []; + console.info = (...args) => compatLogLines.push(['info', ...args].join(' ')); + console.warn = (...args) => compatLogLines.push(['warn', ...args].join(' ')); + compatSessions = await withTimeout( + 'compat.getAppSessionsList', + compatClient.getAppSessionsList() + ); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + assertSmoke(Array.isArray(compatSessions), 'compat mapping', 'compat sessions is not an array'); + assertSmoke(compatSessions.length === 0, 'compat mapping', 'expected no compat app sessions'); + assertSmoke( + compatClient.getLastAppSessionsListError() === null, + 'compat mapping', + `compat mapping reported ${compatClient.getLastAppSessionsListError()}` + ); + + logStep('runtime smoke passed'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + const summary = compatLogLines.length > 0 + ? `${message}\n\ncompat logs:\n${compatLogLines.join('\n')}` + : message; + await writeFailureLogs({ stdoutPath, stderrPath }, stdout, stderr, summary); + console.error(message); + if (compatLogLines.length > 0) { + console.error(`compat logs:\n${compatLogLines.join('\n')}`); + } + if (err instanceof SmokeError) { + console.error(logs()); + } + process.exitCode = 1; + } finally { + try { + await closeClient(client); + } finally { + if (child) { + logStep('stopping Nitronode'); + await stopProcess(child); + } + if (process.exitCode) { + console.error(`runtime smoke logs preserved at ${configDir}`); + } else { + await rm(configDir, { recursive: true, force: true }); + } + } + } +} + +await runSmoke(); +process.exit(process.exitCode ?? 0); diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md new file mode 100644 index 000000000..c4f87a13a --- /dev/null +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -0,0 +1,112 @@ +# Protocol Drift Guards + +This repo has deterministic drift checks for protocol, `@yellow-org/sdk`, and `@yellow-org/sdk-compat` surfaces that demo apps depend on. + +## Commands + +Run all implemented static checks from the repo root: + +```bash +(cd contracts && forge build) +./scripts/check-protocol-drift.sh --static +``` + +`forge build` is required because ABI drift tests compare checked-in SDK ABIs +against generated Foundry artifacts in `contracts/out`. + +Run package checks directly: + +```bash +(cd sdk/ts && npm run drift:check) +(cd sdk/ts-compat && npm run drift:check) +``` + +Run the lightweight runtime smoke from the repo root: + +```bash +./scripts/check-protocol-drift.sh --runtime +``` + +The runtime smoke builds the TS SDK, builds TS compat, builds a temporary local Nitronode binary, starts it with isolated SQLite config, and connects to `ws://127.0.0.1:7824/ws`. It checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping over the live SDK app-session result. + +This is not a load test. It uses empty local `blockchains` and `assets` config so PR CI does not depend on external RPC endpoints, wallets, or shared Nitronode deployments. + +To run the same lightweight compatibility smoke against an existing Nitronode, use external-node mode: + +```bash +NITRONODE_RUNTIME_SMOKE_EXTERNAL=1 \ +NITRONODE_RUNTIME_SMOKE_WS_URL= \ +NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY=<0x-private-key> \ +./scripts/check-protocol-drift.sh --runtime +``` + +External-node mode does not start a local Nitronode and does not assert local-only empty config. It still checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping. + +## Guard Layers + +- RPC method drift: compares Go RPC method literals, Nitronode router registrations, TS method constants, and public TS client wrappers. +- RPC DTO drift: compares Go JSON-tagged DTO structs against TS request/response interfaces for required fields, optional fields, and scalar/container shape. +- Public API drift: snapshots root runtime exports and compiler-derived TypeScript signatures for `@yellow-org/sdk` and `@yellow-org/sdk-compat`, including type-only exports, interfaces, functions, classes, public class methods, enums, constants, and type aliases. +- ABI drift: compares checked-in `ChannelHub` functions against the current Foundry artifact, checks SDK-consumed ERC20 functions against the ERC20 artifact, and guards the manually checked-in AppRegistry ABI against an explicit consumed-function manifest until that contract artifact exists in this repo. +- Signing drift: compares TS app-session and session-key packers against Go-generated canonical vectors for create, deposit, withdraw, operate, fractional decimal, and uint64 boundary cases. +- Transform drift: checks raw Nitronode response fixtures for app sessions, node config, assets, and strict failure on unsupported required shapes. +- Compat drift: checks current v1 app-session shape, legacy flat fallback shape, and asset decimal conversion in `NitroliteClient.getAppSessionsList()`. +- Runtime smoke drift: starts an isolated local Nitronode and verifies live SDK/compat calls against the current runtime response shape. + +## Intentional Updates + +For intentional public runtime API changes, update snapshots with: + +```bash +cd sdk/ts && npm run drift:check -- -u +cd sdk/ts-compat && npm run drift:check -- -u +``` + +For intentional ABI changes, regenerate artifacts and SDK ABI files before running drift checks: + +```bash +cd contracts && forge build +cd ../sdk/ts && npm run codegen-abi +``` + +For a new RPC method, update all applicable surfaces in the same PR: Go method constants, router registration, TS method constants, and the public TS client wrapper unless the method is intentionally raw-only. + +For a new DTO field, update the Go JSON-tagged struct and TS request/response interface together. Optionality must match unless a small, named override is added to the drift test. + +For a new response transform, add a raw fixture and expected behavior test in the relevant drift test. Unsupported wire shapes should fail clearly instead of silently producing partial data. If the high-level client method performs the transform inline, add a client-level mock test in addition to any isolated transform test. + +For intentional app/session-key signing vector changes, regenerate the Go source-of-truth hashes from the repo root: + +```bash +go run ./scripts/drift/generate-app-signing-vectors.go +``` + +Then update `sdk/ts/test/unit/app-signing-drift.test.ts` with the changed hashes in the same PR as the Go packing/protocol change. + +## Adversarial Proof + +Each guard includes at least one negative test or mutation-style check that proves the guard would fail if the relevant surface drifted. These checks must use fixtures, temp copies, or local in-test mutations. They must not leave tracked files dirty. + +## Troubleshooting + +- Missing RPC method or client wrapper: update Go method constants, router registrations, TS method constants, and the public TS client wrapper together. If the method is intentionally raw-only, add an explicit exemption in the RPC drift test. +- DTO optionality or field drift: compare the failing method/type/field path in the drift output, then update the Go JSON-tagged struct and TS request/response interface in the same PR. +- Public API snapshot drift: treat the diff as an SDK API change. If intentional, update snapshots with `npm run drift:check -- -u` in the affected package and document the API change in the PR body. +- ABI drift: regenerate Foundry artifacts and SDK ABI files with `cd contracts && forge build` and `cd ../sdk/ts && npm run codegen-abi`. If AppRegistry changes, remember it is currently manifest-guarded because the matching artifact is not in this repo. +- Signing hash mismatch: regenerate Go source-of-truth vectors with `go run ./scripts/drift/generate-app-signing-vectors.go`, then inspect whether the change is field order, enum value, amount formatting, nonce/version encoding, or exact session-data bytes. +- Transform fixture failure: update or add raw Nitronode fixtures only for wire shapes the SDK intentionally supports. Do not silently accept missing required fields that would later crash consumers. +- Compat mapping failure: current v1 SDK shapes are primary. Legacy fallbacks must stay explicit in tests; do not add broad best-effort mappers without fixture coverage. +- Runtime setup/startup failure: inspect `runtime-smoke-logs` in CI or the preserved temp log directory locally. `[setup]` points to build/setup, `[startup]` to local Nitronode process exit, `[connection]` to WebSocket readiness, and `[transform]` or `[compat mapping]` to SDK response handling. +- External smoke failure: rerun the manual workflow or local external-node command to confirm it is not shared-environment state. External smoke is release/demo signal, not a PR blocker. + +## CI Policy + +`Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Nitronode deployments. + +`Test (Protocol Drift Runtime)` also runs on PRs and main pushes. It starts an isolated local Nitronode inside the GitHub Actions job and does not use shared external or sandbox endpoints. + +If runtime smoke fails in CI, inspect the `protocol-drift-runtime-smoke-logs` artifact. The smoke command categorizes failures as setup, startup, connection, timeout, transform, or compat mapping failures. + +The runtime job uses read-only repository permissions and no secrets. It builds Nitronode locally instead of pulling or publishing an image, so ordinary PRs do not need package-write permissions. If organization policy restricts forked PR workflows, a maintainer can rerun the same command locally or through an allowed CI rerun. + +External Nitronode checks are manual only through the `Protocol Drift External Smoke` workflow. The workflow requires the caller to provide the WebSocket URL and the repository secret `NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY`, is not PR-blocking, and is not scheduled by default. Team-owned temporary environments can still be useful for release confidence, but they must not become default PR blockers unless their availability and data contract are owned. diff --git a/sdk/ts-compat/package.json b/sdk/ts-compat/package.json index 75b03499c..78d687d40 100644 --- a/sdk/ts-compat/package.json +++ b/sdk/ts-compat/package.json @@ -15,6 +15,7 @@ "build:ci": "tsc", "build:prod": "tsc -p tsconfig.prod.json", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", + "drift:check": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs --runTestsByPath test/unit/client.test.ts test/unit/config.test.ts test/unit/public-api-drift.test.ts", "lint": "eslint src test", "typecheck": "tsc --noEmit", "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"" 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 new file mode 100644 index 000000000..1b4a6dd19 --- /dev/null +++ b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -0,0 +1,1123 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`compat public runtime API drift guard keeps root TypeScript public API signatures intentional 1`] = ` +[ + { + "declaration": "string", + "kind": "type", + "name": "AccountID", + "type": "string", + }, + { + "kind": "interface", + "name": "AccountInfo", + "properties": [ + "balances: LedgerBalance[]", + "channelCount: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Allocation", + "properties": [ + "amount: bigint", + "destination: Address | string", + "token: Address | string", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string): AllowanceError", + ], + "kind": "class", + "name": "AllowanceError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "interface", + "name": "AppLogic", + "properties": [ + "decode: (encoded: Hex): T", + "encode: (data: T): Hex", + "getAdjudicatorAddress: (): Address", + "getAdjudicatorType: (): string", + "isFinal: (state: T): boolean", + "provideProofs: (channel: Channel, state: T, previousStates: State[]): State[]", + "validateTransition: (channel: Channel, prevState: T, nextState: T): boolean", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSession", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: string", + "nonce: number", + "participants: string[]", + "protocol: string", + "quorum: number", + "sessionData: string", + "status: string", + "version: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AuthChallengeResponse", + "properties": [ + "method: RPCMethod.AuthChallenge", + "params: { challengeMessage: string; }", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AuthRequestParams", + "properties": [ + "address: string", + "allowances: { asset: string; amount: string }[]", + "application: string", + "expires_at: bigint", + "scope: string", + "session_key: string", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "blockchainRPCsFromEnv", + "signatures": [ + "(): Record", + ], + }, + { + "kind": "function", + "name": "buildClientOptions", + "signatures": [ + "(config: CompatClientConfig): Option[]", + ], + }, + { + "kind": "interface", + "name": "Channel", + "properties": [ + "adjudicator: Address", + "challenge: number", + "channelId: string", + "nonce: bigint", + "participants: Address[]", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelData", + "properties": [ + "lastValidState: any", + "stateData: Hex", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ClearNodeAsset", + "properties": [ + "chainId: number", + "decimals: number", + "symbol: string", + "token: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CloseAppSessionRequestParams", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: string", + "quorum_sigs: string[]", + "session_data: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CloseChannelResponseParams", + "properties": [ + "channelId: string", + "serverSignature: string", + "state: any", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CompatClientConfig", + "properties": [ + "blockchainRPCs: Record", + "wsURL: string", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string, code: string): CompatError", + ], + "kind": "class", + "name": "CompatError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "interface", + "name": "ContractAddresses", + "properties": [ + "adjudicator: Address | string", + "custody: Address | string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "convertRPCToClientChannel", + "type": "(ch: any) => any", + }, + { + "kind": "const", + "name": "convertRPCToClientState", + "type": "(st: any, _sig?: string) => any", + }, + { + "kind": "interface", + "name": "CreateAppSessionHashParams", + "properties": [ + "application: string", + "nonce: bigint | number", + "participants: CreateAppSessionHashParticipant[]", + "quorum: number", + "sessionData: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CreateAppSessionHashParticipant", + "properties": [ + "signatureWeight: number", + "walletAddress: Address | Hex", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "createAppSessionMessage", + "signatures": [ + "(signer: MessageSigner, params: CreateAppSessionRequestParams, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "interface", + "name": "CreateAppSessionRequestParams", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "definition: RPCAppDefinition", + "owner_sig: string", + "quorum_sigs: string[]", + "session_data: string", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "createAuthRequestMessage", + "signatures": [ + "(params: AuthRequestParams, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createAuthVerifyMessage", + "signatures": [ + "(signer: MessageSigner, challenge: { params: { challengeMessage: string; }; }, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createAuthVerifyMessageWithJWT", + "signatures": [ + "(jwtToken: string, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "interface", + "name": "CreateChannelResponseParams", + "properties": [ + "channel: any", + "serverSignature: string", + "state: any", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "createCloseAppSessionMessage", + "signatures": [ + "(signer: MessageSigner, params: CloseAppSessionRequestParams, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createCloseChannelMessage", + "signatures": [ + "(_signer: MessageSigner, _channelId: string, _fundDestination: Address, _requestId?: number, _timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createCreateChannelMessage", + "signatures": [ + "(_signer: MessageSigner, _params: unknown, _requestId?: number, _timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createECDSAMessageSigner", + "signatures": [ + "(privateKey: Hex): MessageSigner", + ], + }, + { + "kind": "function", + "name": "createEIP712AuthMessageSigner", + "signatures": [ + "(walletClient: any, partialMessage: { scope: string; session_key: \`0x\${string}\`; expires_at: bigint; allowances: { asset: string; amount: string; }[]; }, domain: { name: string; }): MessageSigner", + ], + }, + { + "kind": "function", + "name": "createGetAppDefinitionMessage", + "signatures": [ + "(_signer: MessageSigner, appSessionId: string, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createGetAppSessionsMessage", + "signatures": [ + "(_signer: MessageSigner, participant: Address, status?: RPCChannelStatus, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createGetChannelsMessage", + "signatures": [ + "(_signer: MessageSigner, participant?: Address, status?: RPCChannelStatus, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createGetLedgerBalancesMessage", + "signatures": [ + "(signer: MessageSigner, accountId?: string, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createPingMessage", + "signatures": [ + "(_signer: MessageSigner, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createResizeChannelMessage", + "signatures": [ + "(_signer: MessageSigner, _params: unknown, _requestId?: number, _timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createSubmitAppStateMessage", + "signatures": [ + "(signer: MessageSigner, params: SubmitAppStateRequestParams, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createTransferMessage", + "signatures": [ + "(_signer: MessageSigner, _params: unknown, _requestId?: number, _timestamp?: number): Promise", + ], + }, + { + "kind": "const", + "name": "EIP712AuthTypes", + "type": "{ readonly Policy: readonly [{ readonly name: 'challenge'; readonly type: 'string'; }, { readonly name: 'scope'; readonly type: 'string'; }, { readonly name: 'wallet'; readonly type: 'address'; }, { readonly name: 'session_key'; readonly type: 'address'; }, { readonly name: 'expires_at'; readonly type: 'uint64'; }, { readonly name: 'allowances'; readonly type: 'Allowance[]'; }]; readonly Allowance: readonly [{ readonly name: 'asset'; readonly type: 'string'; }, { readonly name: 'amount'; readonly type: 'string'; }]; }", + }, + { + "constructors": [ + "(client: NitroliteClient, callbacks: EventPollerCallbacks, intervalMs?: number): EventPoller", + ], + "kind": "class", + "name": "EventPoller", + "properties": [ + "setInterval: (ms: number): void", + "start: (): void", + "stop: (): void", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "EventPollerCallbacks", + "properties": [ + "onAssetsUpdate: (assets: ClearNodeAsset[]) => void", + "onBalanceUpdate: (balances: LedgerBalance[]) => void", + "onChannelUpdate: (channels: LedgerChannel[]) => void", + "onError: (error: Error) => void", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "FinalState", + "properties": [ + "allocations: [Allocation, Allocation]", + "channelId: string", + "data: Hex", + "intent: number", + "serverSignature: string", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "GetAppDefinitionResponseParams", + "properties": [ + "challenge: number", + "nonce: number", + "participants: Address[]", + "protocol: string", + "quorum: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "getUserFacingMessage", + "signatures": [ + "(error: unknown): string", + ], + }, + { + "constructors": [ + "(message: string): InsufficientFundsError", + ], + "kind": "class", + "name": "InsufficientFundsError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "declaration": "Address | \`0x\${string}\`", + "kind": "type", + "name": "LedgerAccountType", + "type": "\`0x\${string}\`", + }, + { + "kind": "interface", + "name": "LedgerBalance", + "properties": [ + "amount: string", + "asset: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "LedgerChannel", + "properties": [ + "adjudicator: string", + "amount: bigint", + "chain_id: number", + "challenge: number", + "channel_id: string", + "created_at: string", + "nonce: number", + "participant: string", + "status: string", + "token: string", + "updated_at: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "LedgerEntry", + "properties": [ + "account_id: string", + "account_type: number", + "asset: string", + "created_at: string", + "credit: string", + "debit: string", + "id: number", + "participant: string", + ], + "signatures": [], + }, + { + "declaration": "(payload: MessageSignerPayload) => Promise", + "kind": "type", + "name": "MessageSigner", + "type": "MessageSigner", + }, + { + "constructors": [ + "(client: Client, userAddress: Address, chainId: number, walletClient: WalletClient, blockchainRPCs?: Record): NitroliteClient", + ], + "kind": "class", + "name": "NitroliteClient", + "properties": [ + "acknowledge: (tokenAddress: Address): Promise", + "approveSecurityToken: (chainId: number, amount: bigint): Promise", + "approveTokens: (tokenAddress: Address, amount: bigint): Promise", + "cancelSecurityTokensWithdrawal: (chainId: number): Promise", + "challengeChannel: (params: { state: any; }): Promise", + "checkTokenAllowance: (chainId: number, tokenAddress: Address): Promise", + "checkpointChannel: (_params: unknown): Promise", + "close: (): Promise", + "closeAppSession: (appSessionIdOrParams: string | CloseAppSessionRequestParams, allocations?: RPCAppSessionAllocation[], quorumSigs?: string[]): Promise<{ appSessionId: string; }>", + "closeChannel: (params?: { tokenAddress?: Address | string; } | any): Promise", + "createAppSession: (definitionOrParams: RPCAppDefinition | CreateAppSessionRequestParams, allocations?: RPCAppSessionAllocation[], quorumSigs?: string[], opts?: { ownerSig?: string; }): Promise<{ appSessionId: string; version: string; status: string; }>", + "createChannel: (_respParams?: any): Promise", + "deposit: (tokenAddress: Address, amount: bigint): Promise", + "depositAndCreateChannel: (tokenAddress: Address, amount: bigint, _respParams?: any): Promise", + "findOpenChannel: (tokenAddress: Address | string, chainId?: number): LedgerChannel | null", + "formatAmount: (tokenAddress: Address | string, rawAmount: bigint): Promise", + "getAccountBalance: (_tokenAddress: Address | Address[]): Promise", + "getAccountInfo: (): Promise", + "getActionAllowances: (wallet?: Address): Promise", + "getAppDefinition: (appSessionId: string): Promise", + "getAppSessionsList: (wallet?: Address, status?: string): Promise", + "getApps: (options?: { appId?: string; ownerWallet?: string; page?: number; pageSize?: number; }): Promise<{ apps: AppInfoV1[]; metadata: core.PaginationMetadata; }>", + "getAssetsList: (): Promise", + "getBalances: (wallet?: Address): Promise", + "getBlockchains: (): Promise", + "getChannelBalance: (_channelId: string, _tokenAddress: Address | Address[]): Promise", + "getChannelData: (_channelId: string): Promise", + "getChannels: (): Promise", + "getConfig: (): Promise", + "getEscrowChannel: (escrowChannelId: string): Promise", + "getLastAppSessionsListError: (): string | null", + "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLedgerEntries: (wallet?: Address): Promise", + "getLockedBalance: (chainId: number, wallet?: Address): Promise", + "getOpenChannels: (): Promise", + "getTokenAllowance: (tokenAddress: Address): Promise", + "getTokenBalance: (tokenAddress: Address): Promise", + "getTokenDecimals: (tokenAddress: Address | string): Promise", + "initiateSecurityTokensWithdrawal: (chainId: number): Promise", + "innerClient: Client", + "lockSecurityTokens: (targetWallet: Address, chainId: number, amount: bigint): Promise", + "parseAmount: (tokenAddress: Address | string, humanAmount: string): Promise", + "ping: (): Promise", + "refreshAssets: (): Promise", + "registerApp: (appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise", + "resizeChannel: (params: { allocate_amount: bigint; token: Address; }): Promise", + "resolveAsset: (symbol: string): Promise", + "resolveAssetDisplay: (tokenAddress: Address | string, _chainId?: number): Promise<{ symbol: string; decimals: number; } | null>", + "resolveToken: (tokenAddress: Address | string): Promise", + "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "signSessionKeyState: (state: AppSessionKeyStateV1): Promise", + "submitAppState: (params: SubmitAppStateRequestParams): Promise<{ appSessionId: string; version: number; status: string; }>", + "submitChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "submitSessionKeyState: (state: AppSessionKeyStateV1): Promise", + "transfer: (destination: Address, allocations: TransferAllocation[]): Promise", + "userAddress: Address", + "waitForClose: (): Promise", + "withdrawSecurityTokens: (chainId: number, destination: Address): Promise", + "withdrawal: (tokenAddress: Address, amount: bigint): Promise", + ], + "staticProperties": [ + "classifyError: (error: unknown): Error", + "create: (config: NitroliteClientConfig): Promise", + ], + }, + { + "kind": "interface", + "name": "NitroliteClientConfig", + "properties": [ + "addresses: ContractAddresses", + "blockchainRPCs: Record", + "chainId: number", + "challengeDuration: bigint", + "channelSessionKeySigner: { sessionKeyPrivateKey: Hex; walletAddress: Address; metadataHash: Hex; authSig: Hex; }", + "walletClient: WalletClient", + "wsURL: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NitroliteRPC", + "type": "{ createRequest(opts: { requestId: number; method: string; params: any; timestamp: number; }): NitroliteRPCMessage; signRequestMessage(msg: NitroliteRPCMessage, signer: MessageSigner): Promise; }", + }, + { + "kind": "interface", + "name": "NitroliteRPCMessage", + "properties": [ + "req: NitroliteRPCRequest", + "sig: string", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string): NotInitializedError", + ], + "kind": "class", + "name": "NotInitializedError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "constructors": [ + "(message: string): OngoingStateTransitionError", + ], + "kind": "class", + "name": "OngoingStateTransitionError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "function", + "name": "packCreateAppSessionHash", + "signatures": [ + "(params: CreateAppSessionHashParams): Hex", + ], + }, + { + "kind": "function", + "name": "packSubmitAppStateHash", + "signatures": [ + "(params: SubmitAppStateHashParams): Hex", + ], + }, + { + "kind": "function", + "name": "parseAnyRPCResponse", + "signatures": [ + "(raw: string): RPCResponse", + ], + }, + { + "kind": "const", + "name": "parseCloseAppSessionResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseCloseChannelResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseCreateAppSessionResponse", + "type": "(raw: string) => { params: { appSessionId: string; version: string | number; status: string; }; }", + }, + { + "kind": "const", + "name": "parseCreateChannelResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseGetAppDefinitionResponse", + "type": "(raw: string) => { params: Record; }", + }, + { + "kind": "const", + "name": "parseGetAppSessionsResponse", + "type": "(raw: string) => { params: { appSessions: Array; }; }", + }, + { + "kind": "const", + "name": "parseGetChannelsResponse", + "type": "(raw: string) => { params: { channels: Array; }; }", + }, + { + "kind": "const", + "name": "parseGetLedgerBalancesResponse", + "type": "(raw: string) => { params: { ledgerBalances: Array; }; }", + }, + { + "kind": "const", + "name": "parseGetLedgerEntriesResponse", + "type": "(raw: string) => { params: { ledgerEntries: Array; }; }", + }, + { + "kind": "const", + "name": "parseResizeChannelResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseSubmitAppStateResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "declaration": "number", + "kind": "type", + "name": "RequestID", + "type": "number", + }, + { + "kind": "interface", + "name": "ResizeChannelRequestParams", + "properties": [ + "allocate_amount: bigint", + "channel_id: string", + "funds_destination: Address | string", + "resize_amount: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCAppDefinition", + "properties": [ + "application: string", + "challenge: number", + "nonce: number", + "participants: Hex[]", + "protocol: RPCProtocolVersion", + "quorum: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCAppSession", + "properties": [ + "appSessionId: Hex", + "application: string", + "challenge: number", + "createdAt: Date", + "nonce: number", + "participants: Address[]", + "protocol: RPCProtocolVersion", + "quorum: number", + "sessionData: string", + "status: RPCChannelStatus", + "updatedAt: Date", + "version: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCAppSessionAllocation", + "properties": [ + "amount: string", + "asset: string", + "participant: Address", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Operate = 'operate'", + "Deposit = 'deposit'", + "Withdraw = 'withdraw'", + "Close = 'close'", + ], + "name": "RPCAppStateIntent", + }, + { + "kind": "interface", + "name": "RPCAsset", + "properties": [ + "chainId: number", + "decimals: number", + "symbol: string", + "token: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCBalance", + "properties": [ + "amount: string", + "asset: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Open = 'open'", + "Closed = 'closed'", + "Resizing = 'resizing'", + "Challenged = 'challenged'", + ], + "name": "RPCChannelStatus", + }, + { + "kind": "interface", + "name": "RPCChannelUpdate", + "properties": [ + "adjudicator: string", + "amount: bigint", + "chainId: number", + "challenge: number", + "channelId: string", + "createdAt: string", + "nonce: number", + "participant: string", + "status: string", + "token: string", + "updatedAt: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCLedgerEntry", + "properties": [ + "account_id: string", + "account_type: number", + "asset: string", + "created_at: string", + "credit: string", + "debit: string", + "id: number", + "participant: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Ping = 'ping'", + "GetConfig = 'get_config'", + "GetChannels = 'get_channels'", + "ChannelsUpdate = 'channels_update'", + "ChannelUpdate = 'channel_update'", + "BalanceUpdate = 'balance_update'", + "GetAssets = 'get_assets'", + "Assets = 'assets'", + "GetLedgerBalances = 'get_ledger_balances'", + "GetLedgerEntries = 'get_ledger_entries'", + "GetAppSessions = 'get_app_sessions'", + "CreateChannel = 'create_channel'", + "CloseChannel = 'close_channel'", + "ResizeChannel = 'resize_channel'", + "Transfer = 'transfer'", + "CreateAppSession = 'create_app_session'", + "CloseAppSession = 'close_app_session'", + "SubmitAppState = 'submit_app_state'", + "GetAppDefinition = 'get_app_definition'", + "AuthRequest = 'auth_request'", + "AuthChallenge = 'auth_challenge'", + "AuthVerify = 'auth_verify'", + "Error = 'error'", + "GetLedgerTransactions = 'get_ledger_transactions'", + "TransferNotification = 'tr'", + ], + "name": "RPCMethod", + }, + { + "kind": "enum", + "members": [ + "NitroRPC_0_2 = 'NitroRPC/0.2'", + "NitroRPC_0_4 = 'NitroRPC/0.4'", + ], + "name": "RPCProtocolVersion", + }, + { + "kind": "interface", + "name": "RPCResponse", + "properties": [ + "method: string", + "params: any", + "requestId: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCTransaction", + "properties": [ + "amount: string", + "asset: string", + "createdAt: Date", + "fromAccount: LedgerAccountType", + "fromAccountTag: string", + "id: number", + "toAccount: LedgerAccountType", + "toAccountTag: string", + "txType: RPCTxType", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Transfer = 'transfer'", + "Deposit = 'deposit'", + "Withdrawal = 'withdrawal'", + "AppDeposit = 'app_deposit'", + "AppWithdrawal = 'app_withdrawal'", + "EscrowLock = 'escrow_lock'", + "EscrowUnlock = 'escrow_unlock'", + ], + "name": "RPCTxType", + }, + { + "kind": "interface", + "name": "State", + "properties": [ + "allocations: Allocation[]", + "channelId: string", + "data: Hex", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SubmitAppStateHashAllocation", + "properties": [ + "amount: string", + "asset: string", + "participant: Address | Hex", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SubmitAppStateHashParams", + "properties": [ + "allocations: SubmitAppStateHashAllocation[]", + "appSessionId: Hex | string", + "intent: RPCAppStateIntent | 'close' | number", + "sessionData: string", + "version: bigint | number", + ], + "signatures": [], + }, + { + "declaration": "SubmitAppStateRequestParamsV02 | SubmitAppStateRequestParamsV04", + "kind": "type", + "name": "SubmitAppStateRequestParams", + "type": "SubmitAppStateRequestParams", + }, + { + "kind": "interface", + "name": "SubmitAppStateRequestParamsV02", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: Hex", + "quorum_sigs: string[]", + "session_data: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SubmitAppStateRequestParamsV04", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: Hex", + "intent: RPCAppStateIntent", + "quorum_sigs: string[]", + "session_data: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "toSessionKeyQuorumSignature", + "signatures": [ + "(signature: Hex | string): Hex", + ], + }, + { + "kind": "function", + "name": "toWalletQuorumSignature", + "signatures": [ + "(signature: Hex | string): Hex", + ], + }, + { + "kind": "interface", + "name": "TransferAllocation", + "properties": [ + "amount: string", + "asset: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "TransferNotificationResponseParams", + "properties": [ + "transactions: RPCTransaction[]", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string): UserRejectedError", + ], + "kind": "class", + "name": "UserRejectedError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "constructors": [ + "(walletClient: any): WalletStateSigner", + ], + "kind": "class", + "name": "WalletStateSigner", + "properties": [ + "address: Hex", + "sign: (data: Uint8Array): Promise", + ], + "staticProperties": [], + }, +] +`; + +exports[`compat public runtime API drift guard keeps root runtime exports intentional 1`] = ` +[ + "AllowanceError", + "CompatError", + "EIP712AuthTypes", + "EventPoller", + "InsufficientFundsError", + "NitroliteClient", + "NitroliteRPC", + "NotInitializedError", + "OngoingStateTransitionError", + "RPCAppStateIntent", + "RPCChannelStatus", + "RPCMethod", + "RPCProtocolVersion", + "RPCTxType", + "UserRejectedError", + "WalletStateSigner", + "blockchainRPCsFromEnv", + "buildClientOptions", + "convertRPCToClientChannel", + "convertRPCToClientState", + "createAppSessionMessage", + "createAuthRequestMessage", + "createAuthVerifyMessage", + "createAuthVerifyMessageWithJWT", + "createCloseAppSessionMessage", + "createCloseChannelMessage", + "createCreateChannelMessage", + "createECDSAMessageSigner", + "createEIP712AuthMessageSigner", + "createGetAppDefinitionMessage", + "createGetAppSessionsMessage", + "createGetChannelsMessage", + "createGetLedgerBalancesMessage", + "createPingMessage", + "createResizeChannelMessage", + "createSubmitAppStateMessage", + "createTransferMessage", + "getUserFacingMessage", + "packCreateAppSessionHash", + "packSubmitAppStateHash", + "parseAnyRPCResponse", + "parseCloseAppSessionResponse", + "parseCloseChannelResponse", + "parseCreateAppSessionResponse", + "parseCreateChannelResponse", + "parseGetAppDefinitionResponse", + "parseGetAppSessionsResponse", + "parseGetChannelsResponse", + "parseGetLedgerBalancesResponse", + "parseGetLedgerEntriesResponse", + "parseResizeChannelResponse", + "parseSubmitAppStateResponse", + "toSessionKeyQuorumSignature", + "toWalletQuorumSignature", +] +`; diff --git a/sdk/ts-compat/test/unit/client.test.ts b/sdk/ts-compat/test/unit/client.test.ts index 4d875a87c..9f18c94dd 100644 --- a/sdk/ts-compat/test/unit/client.test.ts +++ b/sdk/ts-compat/test/unit/client.test.ts @@ -11,6 +11,7 @@ function makeClient(sessions: any[]) { client.userAddress = wallet; client.innerClient = { getAppSessions: jest.fn().mockResolvedValue({ sessions }), + getConfig: jest.fn(), }; client.assetsBySymbol = new Map([ ['yusd', { decimals: 6 }], @@ -119,4 +120,54 @@ describe('NitroliteClient getAppSessionsList compat mapping', () => { }, ]); }); + + it('maps an empty app session list without requiring legacy fields', async () => { + const client = makeClient([]); + + await expect(client.getAppSessionsList()).resolves.toEqual([]); + expect(client.innerClient.getAppSessions).toHaveBeenCalledWith({ + wallet: wallet.toLowerCase(), + }); + }); + + it('passes through current SDK camelCase getConfig shape', async () => { + const currentConfig = { + nodeAddress: wallet, + nodeVersion: 'test-node', + supportedSigValidators: [0, 1], + blockchains: [ + { + name: 'Sepolia', + id: 11155111n, + channelHubAddress: '0x3333333333333333333333333333333333333333', + lockingContractAddress: '0x4444444444444444444444444444444444444444', + blockStep: 0n, + }, + ], + }; + const client = makeClient([]); + client.innerClient.getConfig.mockResolvedValue(currentConfig); + + await expect(client.getConfig()).resolves.toBe(currentConfig); + }); + + it('documents snake_case getConfig as pass-through, not normalized compat mapping', async () => { + const rawConfig = { + node_address: wallet, + node_version: 'raw-node', + supported_sig_validators: [0, 1], + blockchains: [ + { + name: 'Sepolia', + blockchain_id: '11155111', + channel_hub_address: '0x3333333333333333333333333333333333333333', + locking_contract_address: '0x4444444444444444444444444444444444444444', + }, + ], + }; + const client = makeClient([]); + client.innerClient.getConfig.mockResolvedValue(rawConfig); + + await expect(client.getConfig()).resolves.toBe(rawConfig); + }); }); diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts new file mode 100644 index 000000000..1e8ccccbb --- /dev/null +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -0,0 +1,269 @@ +import * as publicApi from '../../src/index.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(testDir, '../..'); + +const FORMAT_FLAGS = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType | + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope; + +type PublicApiMember = { + name: string; + kind: string; + signatures?: string[]; + constructors?: string[]; + properties?: string[]; + staticProperties?: string[]; + members?: string[]; + type?: string; + declaration?: string; +}; + +function normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function createPackageProgram() { + const configPath = ts.findConfigFile(packageRoot, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) throw new Error(`tsconfig.json not found under ${packageRoot}`); + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')); + } + + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageRoot); + return ts.createProgram(parsed.fileNames, parsed.options); +} + +function declarationKind(declaration: ts.Declaration): string { + if (ts.isClassDeclaration(declaration)) return 'class'; + if (ts.isInterfaceDeclaration(declaration)) return 'interface'; + if (ts.isFunctionDeclaration(declaration)) return 'function'; + if (ts.isEnumDeclaration(declaration)) return 'enum'; + if (ts.isTypeAliasDeclaration(declaration)) return 'type'; + if (ts.isVariableDeclaration(declaration)) return 'const'; + return ts.SyntaxKind[declaration.kind] ?? 'unknown'; +} + +function signaturesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return type + .getCallSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); +} + +function isPrivateOrProtected(declaration: ts.Declaration): boolean { + const flags = ts.getCombinedModifierFlags(declaration); + return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); +} + +function propertiesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return checker + .getPropertiesOfType(type) + .flatMap((property) => { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] ?? declaration; + if (isPrivateOrProtected(propertyDeclaration)) return []; + + const propertyType = checker.getTypeOfSymbolAtLocation(property, propertyDeclaration); + const signatures = signaturesForType(checker, propertyType, propertyDeclaration); + if (signatures.length > 0) { + return [`${property.getName()}: ${signatures.join(' | ')}`]; + } + if ( + (ts.isPropertySignature(propertyDeclaration) || + ts.isPropertyDeclaration(propertyDeclaration)) && + propertyDeclaration.type + ) { + return [`${property.getName()}: ${normalizeText(propertyDeclaration.type.getText())}`]; + } + return [ + `${property.getName()}: ${checker.typeToString(propertyType, propertyDeclaration, FORMAT_FLAGS)}`, + ]; + }) + .sort(); +} + +function enumMembers(declaration: ts.EnumDeclaration): string[] { + return declaration.members.map((member) => { + const initializer = member.initializer ? normalizeText(member.initializer.getText()) : ''; + return `${member.name.getText()} = ${initializer}`; + }); +} + +function serializePublicApi(): PublicApiMember[] { + const program = createPackageProgram(); + const checker = program.getTypeChecker(); + const entrypoint = program.getSourceFile(path.join(packageRoot, 'src/index.ts')); + if (!entrypoint) throw new Error('src/index.ts not found in program'); + + const moduleSymbol = checker.getSymbolAtLocation(entrypoint); + if (!moduleSymbol) throw new Error('src/index.ts module symbol not found'); + + return checker + .getExportsOfModule(moduleSymbol) + .filter((symbol) => symbol.getName() !== '__esModule') + .map((exportedSymbol) => { + const symbol = + exportedSymbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(exportedSymbol) + : exportedSymbol; + const declaration = symbol.getDeclarations()?.[0]; + if (!declaration) { + return { + name: exportedSymbol.getName(), + kind: 'unknown', + }; + } + + const kind = declarationKind(declaration); + const member: PublicApiMember = { + name: exportedSymbol.getName(), + kind, + }; + + if (ts.isClassDeclaration(declaration)) { + const staticType = checker.getTypeOfSymbolAtLocation(symbol, declaration); + const instanceType = checker.getDeclaredTypeOfSymbol(symbol); + member.constructors = staticType + .getConstructSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); + member.properties = propertiesForType(checker, instanceType, declaration); + member.staticProperties = propertiesForType(checker, staticType, declaration).filter( + (property) => !['length', 'name', 'prototype'].some((skip) => property.startsWith(`${skip}:`)) + ); + } else if (ts.isInterfaceDeclaration(declaration)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + member.properties = propertiesForType(checker, type, declaration); + member.signatures = signaturesForType(checker, type, declaration); + } else if (ts.isFunctionDeclaration(declaration)) { + member.signatures = signaturesForType( + checker, + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration + ); + } else if (ts.isEnumDeclaration(declaration)) { + member.members = enumMembers(declaration); + } else if (ts.isTypeAliasDeclaration(declaration)) { + member.declaration = normalizeText(declaration.type.getText()); + member.type = checker.typeToString( + checker.getTypeFromTypeNode(declaration.type), + declaration, + FORMAT_FLAGS + ); + } else if (ts.isVariableDeclaration(declaration)) { + member.type = checker.typeToString( + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration, + FORMAT_FLAGS + ); + } + + return member; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +describe('compat public runtime API drift guard', () => { + it('keeps root runtime exports intentional', () => { + expect(Object.keys(publicApi).sort()).toMatchSnapshot(); + }); + + it('keeps root TypeScript public API signatures intentional', () => { + expect(serializePublicApi()).toMatchSnapshot(); + }); + + it('keeps session-key compat helpers and client methods public', () => { + expect(Object.keys(publicApi)).toEqual( + expect.arrayContaining(['toSessionKeyQuorumSignature', 'NitroliteClient']) + ); + + const api = serializePublicApi(); + const client = api.find((member) => member.name === 'NitroliteClient'); + expect(client?.properties).toEqual( + expect.arrayContaining([ + expect.stringContaining('signChannelSessionKeyState:'), + expect.stringContaining('submitChannelSessionKeyState:'), + expect.stringContaining('getLastChannelKeyStates:'), + expect.stringContaining('signSessionKeyState:'), + expect.stringContaining('submitSessionKeyState:'), + expect.stringContaining('getLastKeyStates:'), + ]) + ); + + const helper = api.find((member) => member.name === 'toSessionKeyQuorumSignature'); + expect(helper?.signatures?.[0]).toContain('signature: Hex | string'); + }); + + it('keeps NitroliteClient exported', () => { + expect(Object.keys(publicApi)).toContain('NitroliteClient'); + }); + + it('proves adversarial public signature changes are observable', () => { + const api = serializePublicApi(); + const client = api.find((member) => member.name === 'NitroliteClient'); + expect(client?.properties?.some((property) => property.includes('ping:'))).toBe(true); + + const mutated = api.map((member) => + member.name === 'NitroliteClient' + ? { + ...member, + properties: member.properties?.filter((property) => !property.includes('ping:')), + } + : member + ); + const mutatedClient = mutated.find((member) => member.name === 'NitroliteClient'); + + expect(mutatedClient?.properties?.some((property) => property.includes('ping:'))).toBe(false); + }); + + it('proves adversarial type-only export removal is observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === 'NitroliteClientConfig' && member.kind === 'interface')).toBe(true); + + const mutated = api.filter((member) => member.name !== 'NitroliteClientConfig'); + expect(mutated.some((member) => member.name === 'NitroliteClientConfig')).toBe(false); + }); + + it('proves adversarial function parameter changes are observable', () => { + const api = serializePublicApi(); + const builder = api.find((member) => member.name === 'buildClientOptions'); + const original = builder?.signatures?.[0] ?? ''; + expect(original).toContain('config: CompatClientConfig'); + + const mutated = original.replace('config: CompatClientConfig', 'config: unknown'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial enum value changes are observable', () => { + const api = serializePublicApi(); + const method = api.find((member) => member.name === 'RPCMethod'); + const original = method?.members?.join('|') ?? ''; + expect(original).toContain('Ping'); + + const mutated = original.replace('Ping', 'PingChanged'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial public export additions are observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === '__FakeExport')).toBe(false); + + const mutated = [...api, { name: '__FakeExport', kind: 'function' }]; + expect(mutated.some((member) => member.name === '__FakeExport')).toBe(true); + }); +}); diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index 89418b279..ae51a7059 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -34,7 +34,8 @@ "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "ws": "8.18.3" }, "engines": { "node": ">=20.0.0" diff --git a/sdk/ts/package.json b/sdk/ts/package.json index 146deff77..9dba7c92e 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -22,6 +22,7 @@ "lint": "eslint src test --ext .ts", "typecheck": "tsc --noEmit", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", + "drift:check": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs --runTestsByPath test/unit/rpc-drift.test.ts test/unit/rpc-dto-drift.test.ts test/unit/public-api-drift.test.ts test/unit/abi-drift.test.ts test/unit/app-signing-drift.test.ts test/unit/transform-drift.test.ts", "test:types": "npm run validate", "dev": "npm run watch", "dev:docs": "npm run docs:tutorials && npm run watch", @@ -85,6 +86,7 @@ "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "ws": "8.18.3" } } diff --git a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts index c77ec44c2..b6d6c06d7 100644 --- a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts +++ b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts @@ -438,7 +438,7 @@ export const ChannelHubAbi = [ } ], outputs: [], - stateMutability: 'payable' + stateMutability: 'nonpayable' }, { type: 'function', @@ -585,7 +585,7 @@ export const ChannelHubAbi = [ } ], outputs: [], - stateMutability: 'payable' + stateMutability: 'nonpayable' }, { type: 'function', @@ -2585,7 +2585,7 @@ export const ChannelHubAbi = [ } ], outputs: [], - stateMutability: 'payable' + stateMutability: 'nonpayable' }, { type: 'function', @@ -5533,6 +5533,11 @@ export const ChannelHubAbi = [ name: 'IncorrectChannelStatus', inputs: [] }, + { + type: 'error', + name: 'IncorrectMsgSender', + inputs: [] + }, { type: 'error', name: 'IncorrectNode', diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index a3ef68922..8656ceeb5 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -31,6 +31,10 @@ import { transformAppDefinitionFromRPC, transformActionAllowance, } from './utils.js'; +import { + transformChannelSessionKeyState, + transformAppSessionKeyState, +} from './session_key_state_transforms.js'; 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'; @@ -1701,7 +1705,12 @@ export class Client { session_key: sessionKey, }; const resp = await this.rpcClient.channelsV1GetLastKeyStates(req); - return resp.states; + if (!Array.isArray(resp.states)) { + throw new Error('Invalid channel key states response: expected states to be an array'); + } + return resp.states.map((state, index) => + transformChannelSessionKeyState(state, `channel session key state[${index}]`) + ); } // ============================================================================ @@ -1751,7 +1760,12 @@ export class Client { session_key: sessionKey, }; const resp = await this.rpcClient.appSessionsV1GetLastKeyStates(req); - return resp.states; + if (!Array.isArray(resp.states)) { + throw new Error('Invalid app key states response: expected states to be an array'); + } + return resp.states.map((state, index) => + transformAppSessionKeyState(state, `app session key state[${index}]`) + ); } // ============================================================================ diff --git a/sdk/ts/src/rpc/api.ts b/sdk/ts/src/rpc/api.ts index 38ffbbe99..88a4b2112 100644 --- a/sdk/ts/src/rpc/api.ts +++ b/sdk/ts/src/rpc/api.ts @@ -134,11 +134,15 @@ export interface ChannelsV1GetLastKeyStatesRequest { user_address: string; /** Optionally filter by session key address */ session_key?: string; + /** 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 +246,15 @@ export interface AppSessionsV1GetLastKeyStatesRequest { user_address: string; /** Optionally filter by session key address */ session_key?: string; + /** 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/session_key_state_transforms.ts b/sdk/ts/src/session_key_state_transforms.ts new file mode 100644 index 000000000..2a1a9fe3a --- /dev/null +++ b/sdk/ts/src/session_key_state_transforms.ts @@ -0,0 +1,56 @@ +import type { AppSessionKeyStateV1 } from './app/types.js'; +import type { ChannelSessionKeyStateV1 } from './rpc/types.js'; + +function asRecord(raw: unknown, context: string): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error(`Invalid ${context}: expected object`); + } + return raw as Record; +} + +function requireStringField(raw: unknown, context: string, field: string): string { + const record = asRecord(raw, context); + const value = record[field]; + if (typeof value !== 'string') { + throw new Error(`Invalid ${context}: missing required string field ${field}`); + } + return value; +} + +function requireStringArrayField(raw: unknown, context: string, field: string): string[] { + const record = asRecord(raw, context); + const value = record[field]; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`Invalid ${context}: expected ${field} to be string[]`); + } + return value; +} + +export function transformChannelSessionKeyState( + raw: unknown, + context = 'channel session key state' +): ChannelSessionKeyStateV1 { + return { + user_address: requireStringField(raw, context, 'user_address'), + session_key: requireStringField(raw, context, 'session_key'), + version: requireStringField(raw, context, 'version'), + assets: requireStringArrayField(raw, context, 'assets'), + expires_at: requireStringField(raw, context, 'expires_at'), + user_sig: requireStringField(raw, context, 'user_sig'), + }; +} + +export function transformAppSessionKeyState( + raw: unknown, + context = 'app session key state' +): AppSessionKeyStateV1 { + return { + user_address: requireStringField(raw, context, 'user_address'), + session_key: requireStringField(raw, context, 'session_key'), + version: requireStringField(raw, context, 'version'), + application_ids: requireStringArrayField(raw, context, 'application_ids'), + app_session_ids: requireStringArrayField(raw, context, 'app_session_ids'), + expires_at: requireStringField(raw, context, 'expires_at'), + user_sig: requireStringField(raw, context, 'user_sig'), + }; +} diff --git a/sdk/ts/src/utils.ts b/sdk/ts/src/utils.ts index 387dd0536..d3199ed9b 100644 --- a/sdk/ts/src/utils.ts +++ b/sdk/ts/src/utils.ts @@ -355,17 +355,41 @@ export function transformSignedAppStateUpdateToRPC(signed: SignedAppStateUpdateV * The server returns snake_case JSON that needs conversion to SDK types. */ export function transformAppSessionInfo(raw: any): AppSessionInfoV1 { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid app session: expected object payload'); + } + + const allocations = raw.allocations; + if (!Array.isArray(allocations)) { + throw new Error('Invalid app session allocations: expected allocations to be an array'); + } + return { appSessionId: raw.app_session_id, appDefinition: transformAppDefinitionFromRPC(raw.app_definition), isClosed: raw.status === 'closed', sessionData: raw.session_data || '', version: BigInt(raw.version), - allocations: (raw.allocations || []).map((a: any) => ({ - participant: a.participant as Address, - asset: a.asset, - amount: new Decimal(a.amount), - })), + allocations: allocations.map(transformAppAllocationFromRPC), + }; +} + +function transformAppAllocationFromRPC(raw: any, index: number) { + const context = `app session allocation[${index}]`; + if (!raw || typeof raw.participant !== 'string') { + throw new Error(`Invalid ${context}: missing required string field participant`); + } + if (typeof raw.asset !== 'string') { + throw new Error(`Invalid ${context}: missing required string field asset`); + } + if (typeof raw.amount !== 'string') { + throw new Error(`Invalid ${context}: missing required string field amount`); + } + + return { + participant: raw.participant as Address, + asset: raw.asset, + amount: new Decimal(raw.amount), }; } @@ -374,16 +398,38 @@ export function transformAppSessionInfo(raw: any): AppSessionInfoV1 { * The server returns snake_case JSON that needs conversion to SDK types. */ export function transformAppDefinitionFromRPC(raw: any): AppDefinitionV1 { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid app definition: missing required fields (application_id, nonce)'); + } if (!raw.application_id || raw.nonce === undefined || raw.nonce === null) { throw new Error('Invalid app definition: missing required fields (application_id, nonce)'); } + if (!Array.isArray(raw.participants)) { + throw new Error('Invalid app definition: expected participants to be an array'); + } + if (raw.quorum === undefined || raw.quorum === null) { + throw new Error('Invalid app definition: missing required field quorum'); + } + return { applicationId: raw.application_id, - participants: (raw.participants || []).map((p: any) => ({ - walletAddress: p.wallet_address as Address, - signatureWeight: p.signature_weight, - })), + participants: raw.participants.map(transformAppParticipantFromRPC), quorum: raw.quorum, nonce: BigInt(raw.nonce), }; } + +function transformAppParticipantFromRPC(raw: any, index: number) { + const context = `app definition participant[${index}]`; + if (!raw || typeof raw.wallet_address !== 'string') { + throw new Error(`Invalid ${context}: missing required string field wallet_address`); + } + if (typeof raw.signature_weight !== 'number') { + throw new Error(`Invalid ${context}: missing required numeric field signature_weight`); + } + + return { + walletAddress: raw.wallet_address as Address, + signatureWeight: raw.signature_weight, + }; +} 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 new file mode 100644 index 000000000..60e898137 --- /dev/null +++ b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -0,0 +1,2661 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`SDK public runtime API drift guard keeps root TypeScript public API signatures intentional 1`] = ` +[ + { + "kind": "interface", + "name": "ActionAllowance", + "properties": [ + "allowance: bigint", + "gatedAction: string", + "timeWindow: string", + "used: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ActionAllowanceV1", + "properties": [ + "allowance: string", + "gated_action: string", + "time_window: string", + "used: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppAllocationV1", + "properties": [ + "amount: Decimal", + "asset: string", + "participant: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppDefinitionV1", + "properties": [ + "applicationId: string", + "nonce: bigint", + "participants: AppParticipantV1[]", + "quorum: number", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "appendApplicationIDQueryParam", + "signatures": [ + "(wsURL: string, applicationID?: string): string", + ], + }, + { + "kind": "interface", + "name": "AppInfoV1", + "properties": [ + "created_at: string", + "creation_approval_not_required: boolean", + "id: string", + "metadata: string", + "owner_wallet: string", + "updated_at: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "APPLICATION_ID_QUERY_PARAM", + "type": "'app_id'", + }, + { + "kind": "function", + "name": "applyAcknowledgementTransition", + "signatures": [ + "(state: State): Transition", + ], + }, + { + "kind": "function", + "name": "applyChannelCreation", + "signatures": [ + "(state: State, channelDef: ChannelDefinition, blockchainId: bigint, tokenAddress: Address, nodeAddress: Address): string", + ], + }, + { + "kind": "function", + "name": "applyCommitTransition", + "signatures": [ + "(state: State, accountId: string, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyEscrowDepositTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyEscrowLockTransition", + "signatures": [ + "(state: State, blockchainId: bigint, tokenAddress: Address, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyEscrowWithdrawTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyFinalizeTransition", + "signatures": [ + "(state: State): Transition", + ], + }, + { + "kind": "function", + "name": "applyHomeDepositTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyHomeWithdrawalTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyMigrateTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyMutualLockTransition", + "signatures": [ + "(state: State, blockchainId: bigint, tokenAddress: Address, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyReceiverTransitions", + "signatures": [ + "(state: State, ...transitions: Transition[]): void", + ], + }, + { + "kind": "function", + "name": "applyReleaseTransition", + "signatures": [ + "(state: State, accountId: string, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyTransferReceiveTransition", + "signatures": [ + "(state: State, sender: string, amount: Decimal, txId: string): Transition", + ], + }, + { + "kind": "function", + "name": "applyTransferSendTransition", + "signatures": [ + "(state: State, recipient: string, amount: Decimal): Transition", + ], + }, + { + "kind": "interface", + "name": "AppParticipantV1", + "properties": [ + "signatureWeight: number", + "walletAddress: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionInfoV1", + "properties": [ + "allocations: AppAllocationV1[]", + "appDefinition: AppDefinitionV1", + "appSessionId: string", + "isClosed: boolean", + "sessionData: string", + "version: bigint", + ], + "signatures": [], + }, + { + "constructors": [ + "(inner: StateSigner): AppSessionKeySignerV1", + ], + "kind": "class", + "name": "AppSessionKeySignerV1", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "AppSessionKeyStateV1", + "properties": [ + "app_session_ids: string[]", + "application_ids: string[]", + "expires_at: string", + "session_key: string", + "user_address: string", + "user_sig: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Void = 0", + "Open = 1", + "Closed = 2", + ], + "name": "AppSessionStatus", + }, + { + "kind": "function", + "name": "appSessionStatusToString", + "signatures": [ + "(status: AppSessionStatus): string", + ], + }, + { + "kind": "const", + "name": "AppSessionsV1CreateAppSessionMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1CreateAppSessionRequest", + "properties": [ + "definition: AppDefinitionV1", + "owner_sig: string", + "quorum_sigs: string[]", + "session_data: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1CreateAppSessionResponse", + "properties": [ + "app_session_id: string", + "status: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1GetAppDefinitionMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppDefinitionRequest", + "properties": [ + "app_session_id: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppDefinitionResponse", + "properties": [ + "definition: AppDefinitionV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1GetAppSessionsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppSessionsRequest", + "properties": [ + "app_session_id: string", + "pagination: PaginationParamsV1", + "participant: Address", + "status: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppSessionsResponse", + "properties": [ + "app_sessions: AppSessionInfoV1[]", + "metadata: PaginationMetadataV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1GetLastKeyStatesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1GetLastKeyStatesRequest", + "properties": [ + "pagination: PaginationParamsV1", + "session_key: string", + "user_address: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1GetLastKeyStatesResponse", + "properties": [ + "metadata: PaginationMetadataV1", + "states: AppSessionKeyStateV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1Group", + "type": "string", + }, + { + "kind": "const", + "name": "AppSessionsV1RebalanceAppSessionsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1RebalanceAppSessionsRequest", + "properties": [ + "signed_updates: SignedAppStateUpdateV1[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1RebalanceAppSessionsResponse", + "properties": [ + "batch_id: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1SubmitAppStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitAppStateRequest", + "properties": [ + "app_state_update: AppStateUpdateV1", + "quorum_sigs: string[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitAppStateResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1SubmitDepositStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitDepositStateRequest", + "properties": [ + "app_state_update: AppStateUpdateV1", + "quorum_sigs: string[]", + "user_state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitDepositStateResponse", + "properties": [ + "signature: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1SubmitSessionKeyStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitSessionKeyStateRequest", + "properties": [ + "state: AppSessionKeyStateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitSessionKeyStateResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionV1", + "properties": [ + "application: string", + "createdAt: Date", + "nonce: bigint", + "participants: AppParticipantV1[]", + "quorum: number", + "sessionData: string", + "sessionId: string", + "status: AppSessionStatus", + "updatedAt: Date", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionVersionV1", + "properties": [ + "sessionId: string", + "version: bigint", + ], + "signatures": [], + }, + { + "constructors": [ + "(inner: StateSigner): AppSessionWalletSignerV1", + ], + "kind": "class", + "name": "AppSessionWalletSignerV1", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "enum", + "members": [ + "Operate = 0", + "Deposit = 1", + "Withdraw = 2", + "Close = 3", + "Rebalance = 4", + ], + "name": "AppStateUpdateIntent", + }, + { + "kind": "function", + "name": "appStateUpdateIntentToString", + "signatures": [ + "(intent: AppStateUpdateIntent): string", + ], + }, + { + "kind": "interface", + "name": "AppStateUpdateV1", + "properties": [ + "allocations: AppAllocationV1[]", + "appSessionId: string", + "intent: AppStateUpdateIntent", + "sessionData: string", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppsV1GetAppsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppsV1GetAppsRequest", + "properties": [ + "app_id: string", + "owner_wallet: string", + "pagination: PaginationParamsV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppsV1GetAppsResponse", + "properties": [ + "apps: AppInfoV1[]", + "metadata: PaginationMetadataV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppsV1Group", + "type": "string", + }, + { + "kind": "const", + "name": "AppsV1SubmitAppVersionMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppsV1SubmitAppVersionRequest", + "properties": [ + "app: AppV1", + "owner_sig: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppsV1SubmitAppVersionResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppV1", + "properties": [ + "creation_approval_not_required: boolean", + "id: string", + "metadata: string", + "owner_wallet: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Asset", + "properties": [ + "decimals: number", + "name: string", + "suggestedBlockchainId: bigint", + "symbol: string", + "tokens: Token[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetAllowance", + "properties": [ + "allowance: Decimal", + "asset: string", + "used: Decimal", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetAllowanceV1", + "properties": [ + "allowance: Decimal", + "asset: string", + "used: Decimal", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetStore", + "properties": [ + "getAssetDecimals: (asset: string): Promise", + "getTokenDecimals: (blockchainId: bigint, tokenAddress: Address): Promise", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetV1", + "properties": [ + "decimals: number", + "name: string", + "suggested_blockchain_id: string", + "symbol: string", + "tokens: TokenV1[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BalanceEntry", + "properties": [ + "asset: string", + "balance: Decimal", + "enforced: Decimal", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BalanceEntryV1", + "properties": [ + "amount: string", + "asset: string", + "enforced: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Blockchain", + "properties": [ + "blockStep: bigint", + "channelHubAddress: Address", + "id: bigint", + "lockingContractAddress: Address", + "name: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BlockchainEvent", + "properties": [ + "blockNumber: bigint", + "blockchainId: bigint", + "contractAddress: Address", + "logIndex: number", + "name: string", + "transactionHash: Hex", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BlockchainEventHandler", + "properties": [ + "handleEscrowDepositChallenged: (event: EscrowDepositChallengedEvent): Promise", + "handleEscrowDepositFinalized: (event: EscrowDepositFinalizedEvent): Promise", + "handleEscrowDepositInitiated: (event: EscrowDepositInitiatedEvent): Promise", + "handleEscrowWithdrawalChallenged: (event: EscrowWithdrawalChallengedEvent): Promise", + "handleEscrowWithdrawalFinalized: (event: EscrowWithdrawalFinalizedEvent): Promise", + "handleEscrowWithdrawalInitiated: (event: EscrowWithdrawalInitiatedEvent): Promise", + "handleHomeChannelChallenged: (event: HomeChannelChallengedEvent): Promise", + "handleHomeChannelCheckpointed: (event: HomeChannelCheckpointedEvent): Promise", + "handleHomeChannelClosed: (event: HomeChannelClosedEvent): Promise", + "handleHomeChannelCreated: (event: HomeChannelCreatedEvent): Promise", + "handleHomeChannelMigrated: (event: HomeChannelMigratedEvent): Promise", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BlockchainInfoV1", + "properties": [ + "blockchain_id: string", + "channel_hub_address: Address", + "locking_contract_address: Address", + "name: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Channel", + "properties": [ + "approvedSigValidators: string", + "asset: string", + "blockchainId: bigint", + "challengeDuration: number", + "challengeExpiresAt: Date", + "channelId: string", + "nonce: bigint", + "stateVersion: bigint", + "status: ChannelStatus", + "tokenAddress: Address", + "type: ChannelType", + "userWallet: Address", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "CHANNEL_HUB_VERSION", + "type": "1", + }, + { + "kind": "interface", + "name": "ChannelChallengedEvent", + "properties": [ + "challengeExpiry: bigint", + "channelId: string", + "stateVersion: bigint", + ], + "signatures": [], + }, + { + "constructors": [ + "(inner: StateSigner): ChannelDefaultSigner", + ], + "kind": "class", + "name": "ChannelDefaultSigner", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "ChannelDefinition", + "properties": [ + "approvedSigValidators: string", + "challenge: number", + "nonce: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelDefinitionV1", + "properties": [ + "approved_sig_validators: string", + "challenge: number", + "nonce: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelEvent", + "properties": [ + "channelId: string", + "stateVersion: bigint", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "User = 0", + "Node = 1", + ], + "name": "ChannelParticipant", + }, + { + "constructors": [ + "(sessionKeyPrivateKey: Hex, walletAddress: Address, metadataHash: Hex, authSignature: Hex): ChannelSessionKeyStateSigner", + ], + "kind": "class", + "name": "ChannelSessionKeyStateSigner", + "properties": [ + "getAddress: (): Address", + "getSessionKeyAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "ChannelSessionKeyStateV1", + "properties": [ + "assets: string[]", + "expires_at: string", + "session_key: string", + "user_address: string", + "user_sig: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Default = 0x00", + "SessionKey = 0x01", + ], + "name": "ChannelSignerType", + }, + { + "kind": "enum", + "members": [ + "Void = 0", + "Open = 1", + "Challenged = 2", + "Closed = 3", + ], + "name": "ChannelStatus", + }, + { + "kind": "const", + "name": "ChannelsV1GetChannelsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetChannelsRequest", + "properties": [ + "asset: string", + "channel_type: string", + "pagination: PaginationParamsV1", + "status: string", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetChannelsResponse", + "properties": [ + "channels: ChannelV1[]", + "metadata: PaginationMetadataV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetEscrowChannelMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetEscrowChannelRequest", + "properties": [ + "escrow_channel_id: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetEscrowChannelResponse", + "properties": [ + "channel: ChannelV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetHomeChannelMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetHomeChannelRequest", + "properties": [ + "asset: string", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetHomeChannelResponse", + "properties": [ + "channel: ChannelV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetLastKeyStatesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetLastKeyStatesRequest", + "properties": [ + "pagination: PaginationParamsV1", + "session_key: string", + "user_address: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetLastKeyStatesResponse", + "properties": [ + "metadata: PaginationMetadataV1", + "states: ChannelSessionKeyStateV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetLatestStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetLatestStateRequest", + "properties": [ + "asset: string", + "only_signed: boolean", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetLatestStateResponse", + "properties": [ + "state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1HomeChannelCreatedEvent", + "properties": [ + "channel: ChannelV1", + "initial_state: StateV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1RequestCreationMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1RequestCreationRequest", + "properties": [ + "channel_definition: ChannelDefinitionV1", + "state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1RequestCreationResponse", + "properties": [ + "signature: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1SubmitSessionKeyStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitSessionKeyStateRequest", + "properties": [ + "state: ChannelSessionKeyStateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitSessionKeyStateResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1SubmitStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitStateRequest", + "properties": [ + "state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitStateResponse", + "properties": [ + "signature: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Home = 1", + "Escrow = 2", + ], + "name": "ChannelType", + }, + { + "kind": "interface", + "name": "ChannelV1", + "properties": [ + "approved_sig_validators: string", + "asset: string", + "blockchain_id: string", + "challenge_duration: number", + "challenge_expires_at: string", + "channel_id: string", + "nonce: string", + "state_version: string", + "status: string", + "token_address: Address", + "type: string", + "user_wallet: Address", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelV1Group", + "type": "string", + }, + { + "constructors": [ + "(rpcClient: RPCClient, config: Config, stateSigner: StateSigner, txSigner: TransactionSigner, assetStore: ClientAssetStore): Client", + ], + "kind": "class", + "name": "Client", + "properties": [ + "acknowledge: (asset: string): Promise", + "approveSecurityToken: (chainId: bigint, amount: Decimal): Promise", + "approveToken: (chainId: bigint, asset: string, amount: Decimal): Promise", + "cancelSecurityTokensWithdrawal: (blockchainId: bigint): Promise", + "challenge: (state: core.State): Promise", + "checkTokenAllowance: (chainId: bigint, tokenAddress: string, owner: string): Promise", + "checkpoint: (asset: string): Promise", + "close: (): Promise", + "closeHomeChannel: (asset: string): Promise", + "createAppSession: (definition: app.AppDefinitionV1, sessionData: string, quorumSigs: string[], opts?: { ownerSig?: string; }): Promise<{ appSessionId: string; version: string; status: string; }>", + "deposit: (blockchainId: bigint, asset: string, amount: Decimal): Promise", + "escrowSecurityTokens: (targetWalletAddress: string, blockchainId: bigint, amount: Decimal): Promise", + "getActionAllowances: (wallet: Address): Promise", + "getAppDefinition: (appSessionId: string): Promise", + "getAppSessions: (options?: { appSessionId?: string; wallet?: Address; status?: string; page?: number; pageSize?: number; }): Promise<{ sessions: app.AppSessionInfoV1[]; metadata: core.PaginationMetadata; }>", + "getApps: (options?: { appId?: string; ownerWallet?: string; page?: number; pageSize?: number; }): Promise<{ apps: AppInfoV1[]; metadata: core.PaginationMetadata; }>", + "getAssets: (blockchainId?: bigint): Promise", + "getBalances: (wallet: Address): Promise", + "getBlockchains: (): Promise", + "getChannels: (wallet: Address, options?: { status?: string; asset?: string; channelType?: string; pagination?: core.PaginationParams; }): Promise<{ channels: core.Channel[]; metadata: core.PaginationMetadata; }>", + "getConfig: (): Promise", + "getEscrowChannel: (escrowChannelId: string): Promise", + "getHomeChannel: (wallet: Address, asset: string): Promise", + "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLatestState: (wallet: Address, asset: string, onlySigned: boolean): Promise", + "getLockedBalance: (chainId: bigint, wallet: string): Promise", + "getOnChainBalance: (chainId: bigint, asset: string, wallet: Address): Promise", + "getTransactions: (wallet: Address, options?: { asset?: string; txType?: core.TransactionType; fromTime?: bigint; toTime?: bigint; page?: number; pageSize?: number; }): Promise<{ transactions: core.Transaction[]; metadata: core.PaginationMetadata; }>", + "getUserAddress: (): Address", + "initiateSecurityTokensWithdrawal: (blockchainId: bigint): Promise", + "ping: (): Promise", + "rebalanceAppSessions: (signedUpdates: app.SignedAppStateUpdateV1[]): Promise", + "registerApp: (appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise", + "setHomeBlockchain: (asset: string, blockchainId: bigint): Promise", + "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "signSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", + "signState: (state: core.State): Promise", + "submitAppSessionDeposit: (appStateUpdate: app.AppStateUpdateV1, quorumSigs: string[], asset: string, depositAmount: Decimal): Promise", + "submitAppState: (appStateUpdate: app.AppStateUpdateV1, quorumSigs: string[]): Promise", + "submitChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "submitSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", + "transfer: (recipientWallet: string, asset: string, amount: Decimal): Promise", + "validateAndSignState: (currentState: core.State, proposedState: core.State): Promise", + "waitForClose: (): Promise", + "withdraw: (blockchainId: bigint, asset: string, amount: Decimal): Promise", + "withdrawSecurityTokens: (blockchainId: bigint, destinationWalletAddress: string): Promise", + ], + "staticProperties": [ + "create: (wsURL: string, stateSigner: StateSigner, txSigner: TransactionSigner, ...opts: Option[]): Promise", + ], + }, + { + "constructors": [ + "(getAssetsFn: () => Promise): ClientAssetStore", + ], + "kind": "class", + "name": "ClientAssetStore", + "properties": [ + "assetExistsOnBlockchain: (blockchainId: bigint, asset: string): Promise", + "clearCache: (): void", + "getAssetDecimals: (asset: string): Promise", + "getSuggestedBlockchainId: (asset: string): Promise", + "getTokenAddress: (asset: string, blockchainId: bigint): Promise
", + "getTokenDecimals: (blockchainId: bigint, tokenAddress: string): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "Config", + "properties": [ + "applicationID: string", + "blockchainRPCs: Map", + "errorHandler: (error: Error) => void", + "handshakeTimeout: number", + "pingInterval: number", + "url: string", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "createSigners", + "signatures": [ + "(privateKey: Hex): { stateSigner: StateSigner; txSigner: TransactionSigner; }", + ], + }, + { + "kind": "function", + "name": "decimalToBigInt", + "signatures": [ + "(amount: Decimal, decimals: number): bigint", + ], + }, + { + "kind": "const", + "name": "DEFAULT_CHALLENGE_PERIOD", + "type": "86400", + }, + { + "kind": "const", + "name": "DefaultConfig", + "type": "Partial", + }, + { + "kind": "const", + "name": "DefaultWebsocketDialerConfig", + "type": "WebsocketDialerConfig", + }, + { + "kind": "interface", + "name": "Dialer", + "properties": [ + "call: (req: Message, signal?: AbortSignal): Promise", + "close: (): Promise", + "dial: (url: string, handleClosure: (err?: Error) => void): Promise", + "eventChannel: (): AsyncIterable", + "isConnected: (): boolean", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ErrAlreadyConnected", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrConnectionTimeout", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrDialingWebsocket", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrInvalidRequestMethod", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrMarshalingRequest", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrNilRequest", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrNoResponse", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrNotConnected", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ERROR_PARAM_KEY", + "type": "'error'", + }, + { + "kind": "function", + "name": "errorf", + "signatures": [ + "(format: string, ...args: unknown[]): RPCError", + ], + }, + { + "kind": "const", + "name": "ErrReadingMessage", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrSendingPing", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrSendingRequest", + "type": "RPCError", + }, + { + "declaration": "ChannelChallengedEvent", + "kind": "type", + "name": "EscrowDepositChallengedEvent", + "type": "ChannelChallengedEvent", + }, + { + "kind": "interface", + "name": "EscrowDepositDataResponse", + "properties": [ + "challengeExpiry: bigint", + "escrowChannelId: string", + "lastState: State", + "node: Address", + "unlockExpiry: bigint", + ], + "signatures": [], + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowDepositFinalizedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowDepositInitiatedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelChallengedEvent", + "kind": "type", + "name": "EscrowWithdrawalChallengedEvent", + "type": "ChannelChallengedEvent", + }, + { + "kind": "interface", + "name": "EscrowWithdrawalDataResponse", + "properties": [ + "escrowChannelId: string", + "lastState: State", + "node: Address", + ], + "signatures": [], + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowWithdrawalFinalizedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowWithdrawalInitiatedEvent", + "type": "ChannelEvent", + }, + { + "constructors": [ + "(privateKeyOrAccount: Hex | ReturnType): EthereumMsgSigner", + ], + "kind": "class", + "name": "EthereumMsgSigner", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "constructors": [ + "(privateKeyOrAccount: Hex | ReturnType): EthereumRawSigner", + ], + "kind": "class", + "name": "EthereumRawSigner", + "properties": [ + "getAccount: (): ReturnType", + "getAddress: (): Address", + "sendTransaction: (tx: any): Promise", + "signMessage: (message: { raw: Hex; }): Promise", + "signPersonalMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "declaration": "string", + "kind": "type", + "name": "Event", + "type": "string", + }, + { + "kind": "SourceFile", + "name": "evm", + }, + { + "kind": "function", + "name": "generateAppSessionIDV1", + "signatures": [ + "(definition: AppDefinitionV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "generateChannelMetadata", + "signatures": [ + "(asset: string): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "generateNonce", + "signatures": [ + "(): bigint", + ], + }, + { + "kind": "function", + "name": "generateRebalanceBatchIDV1", + "signatures": [ + "(sessionVersions: AppSessionVersionV1[]): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "generateRebalanceTransactionIDV1", + "signatures": [ + "(batchId: string, sessionId: string, asset: string): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "getChannelSessionKeyAuthMetadataHashV1", + "signatures": [ + "(version: bigint, assets: string[], expiresAt: bigint): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "getEscrowChannelId", + "signatures": [ + "(homeChannelId: string, stateVersion: bigint): string", + ], + }, + { + "kind": "function", + "name": "getHomeChannelId", + "signatures": [ + "(node: Address, user: Address, asset: string, nonce: bigint, challengeDuration: number, approvedSigValidators?: string): string", + ], + }, + { + "kind": "function", + "name": "getLastTransition", + "signatures": [ + "(state: State): Transition | null", + ], + }, + { + "kind": "function", + "name": "getOffsetAndLimit", + "signatures": [ + "(params: PaginationParams | undefined, defaultLimit: number, maxLimit: number): { offset: number; limit: number; }", + ], + }, + { + "kind": "function", + "name": "getReceiverTransactionId", + "signatures": [ + "(fromAccount: string, receiverNewStateId: string): string", + ], + }, + { + "kind": "function", + "name": "getSenderTransactionId", + "signatures": [ + "(toAccount: string, senderNewStateId: string): string", + ], + }, + { + "kind": "function", + "name": "getStateId", + "signatures": [ + "(userWallet: Address, asset: string, epoch: bigint, version: bigint): string", + ], + }, + { + "kind": "function", + "name": "getStateTransitionHash", + "signatures": [ + "(transition: Transition): string", + ], + }, + { + "declaration": "string", + "kind": "type", + "name": "Group", + "type": "string", + }, + { + "declaration": "ChannelChallengedEvent", + "kind": "type", + "name": "HomeChannelChallengedEvent", + "type": "ChannelChallengedEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelCheckpointedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelClosedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelCreatedEvent", + "type": "ChannelEvent", + }, + { + "kind": "interface", + "name": "HomeChannelDataResponse", + "properties": [ + "challengeExpiry: bigint", + "definition: ChannelDefinition", + "lastState: State", + "node: Address", + ], + "signatures": [], + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelMigratedEvent", + "type": "ChannelEvent", + }, + { + "kind": "const", + "name": "INTENT_CLOSE", + "type": "1", + }, + { + "kind": "const", + "name": "INTENT_DEPOSIT", + "type": "2", + }, + { + "kind": "const", + "name": "INTENT_FINALIZE_ESCROW_DEPOSIT", + "type": "5", + }, + { + "kind": "const", + "name": "INTENT_FINALIZE_ESCROW_WITHDRAWAL", + "type": "7", + }, + { + "kind": "const", + "name": "INTENT_FINALIZE_MIGRATION", + "type": "9", + }, + { + "kind": "const", + "name": "INTENT_INITIATE_ESCROW_DEPOSIT", + "type": "4", + }, + { + "kind": "const", + "name": "INTENT_INITIATE_ESCROW_WITHDRAWAL", + "type": "6", + }, + { + "kind": "const", + "name": "INTENT_INITIATE_MIGRATION", + "type": "8", + }, + { + "kind": "const", + "name": "INTENT_OPERATE", + "type": "0", + }, + { + "kind": "const", + "name": "INTENT_WITHDRAW", + "type": "3", + }, + { + "kind": "function", + "name": "isFinal", + "signatures": [ + "(state: State): boolean", + ], + }, + { + "kind": "interface", + "name": "Ledger", + "properties": [ + "blockchainId: bigint", + "nodeBalance: Decimal", + "nodeNetFlow: Decimal", + "tokenAddress: Address", + "userBalance: Decimal", + "userNetFlow: Decimal", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "ledgerEqual", + "signatures": [ + "(a: Ledger, b: Ledger): string | null", + ], + }, + { + "kind": "interface", + "name": "LedgerV1", + "properties": [ + "blockchain_id: string", + "node_balance: string", + "node_net_flow: string", + "token_address: Address", + "user_balance: string", + "user_net_flow: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Listener", + "properties": [ + "listen: (): Promise", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "marshalMessage", + "signatures": [ + "(message: Message): string", + ], + }, + { + "kind": "interface", + "name": "Message", + "properties": [ + "method: string", + "payload: Payload", + "requestId: number", + "timestamp: number", + "type: MsgType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "messageError", + "signatures": [ + "(message: Message): Error | null", + ], + }, + { + "declaration": "string", + "kind": "type", + "name": "Method", + "type": "string", + }, + { + "kind": "enum", + "members": [ + "Req = 1", + "Resp = 2", + "Event = 3", + "RespErr = 4", + ], + "name": "MsgType", + }, + { + "kind": "function", + "name": "newChannel", + "signatures": [ + "(channelId: string, userWallet: Address, asset: string, type: ChannelType, blockchainId: bigint, tokenAddress: Address, nonce: bigint, challenge: number, approvedSigValidators?: string): Channel", + ], + }, + { + "kind": "function", + "name": "newErrorPayload", + "signatures": [ + "(errMsg: string): Payload", + ], + }, + { + "kind": "function", + "name": "newErrorResponse", + "signatures": [ + "(requestId: number, method: string, errMsg: string): Message", + ], + }, + { + "kind": "function", + "name": "newEvent", + "signatures": [ + "(requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newMessage", + "signatures": [ + "(type: MsgType, requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newPayload", + "signatures": [ + "(v: unknown): Payload", + ], + }, + { + "kind": "function", + "name": "newRequest", + "signatures": [ + "(requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newResponse", + "signatures": [ + "(requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newRPCClient", + "signatures": [ + "(dialer: Dialer): RPCClient", + ], + }, + { + "kind": "function", + "name": "newStatePackerV1", + "signatures": [ + "(assetStore: AssetStore): StatePackerV1", + ], + }, + { + "kind": "function", + "name": "newTransaction", + "signatures": [ + "(id: string, asset: string, txType: TransactionType, fromAccount: Address, toAccount: Address, amount: Decimal): Transaction", + ], + }, + { + "kind": "function", + "name": "newTransition", + "signatures": [ + "(type: TransitionType, txId: string, accountId: string, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "newVoidState", + "signatures": [ + "(asset: string, userWallet: Address): State", + ], + }, + { + "kind": "function", + "name": "newWebsocketDialer", + "signatures": [ + "(config?: WebsocketDialerConfig): WebsocketDialer", + ], + }, + { + "kind": "function", + "name": "nextState", + "signatures": [ + "(state: State): State", + ], + }, + { + "kind": "interface", + "name": "NodeConfig", + "properties": [ + "blockchains: Blockchain[]", + "nodeAddress: Address", + "nodeVersion: string", + "supportedSigValidators: number[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NodeV1GetAssetsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "NodeV1GetAssetsRequest", + "properties": [ + "blockchain_id: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "NodeV1GetAssetsResponse", + "properties": [ + "assets: AssetV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NodeV1GetConfigMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "NodeV1GetConfigRequest", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "NodeV1GetConfigResponse", + "properties": [ + "blockchains: BlockchainInfoV1[]", + "node_address: Address", + "node_version: string", + "supported_sig_validators: number[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NodeV1Group", + "type": "string", + }, + { + "kind": "const", + "name": "NodeV1PingMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "NodeV1PingRequest", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "NodeV1PingResponse", + "properties": [], + "signatures": [], + }, + { + "declaration": "(config: Config) => void", + "kind": "type", + "name": "Option", + "type": "Option", + }, + { + "kind": "function", + "name": "packAppSessionKeyStateV1", + "signatures": [ + "(state: AppSessionKeyStateV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packAppStateUpdateV1", + "signatures": [ + "(stateUpdate: AppStateUpdateV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packAppV1", + "signatures": [ + "(app: AppV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packChallengeState", + "signatures": [ + "(state: State, assetStore: AssetStore): Promise<\`0x\${string}\`>", + ], + }, + { + "kind": "function", + "name": "packChannelKeyStateV1", + "signatures": [ + "(sessionKey: Address, metadataHash: \`0x\${string}\`): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packCreateAppSessionRequestV1", + "signatures": [ + "(definition: AppDefinitionV1, sessionData: string): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packState", + "signatures": [ + "(state: State, assetStore: AssetStore): Promise<\`0x\${string}\`>", + ], + }, + { + "kind": "interface", + "name": "PaginationMetadata", + "properties": [ + "page: number", + "pageCount: number", + "perPage: number", + "totalCount: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "PaginationMetadataV1", + "properties": [ + "page: number", + "page_count: number", + "per_page: number", + "total_count: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "PaginationParams", + "properties": [ + "limit: number", + "offset: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "PaginationParamsV1", + "properties": [ + "limit: number", + "offset: number", + ], + "signatures": [], + }, + { + "declaration": "Record", + "kind": "type", + "name": "Payload", + "type": "Payload", + }, + { + "kind": "function", + "name": "payloadError", + "signatures": [ + "(payload: Payload): Error | null", + ], + }, + { + "constructors": [ + "(dialer: Dialer): RPCClient", + ], + "kind": "class", + "name": "RPCClient", + "properties": [ + "appSessionsV1CreateAppSession: (req: API.AppSessionsV1CreateAppSessionRequest, signal?: AbortSignal): Promise", + "appSessionsV1GetAppDefinition: (req: API.AppSessionsV1GetAppDefinitionRequest, signal?: AbortSignal): Promise", + "appSessionsV1GetAppSessions: (req: API.AppSessionsV1GetAppSessionsRequest, signal?: AbortSignal): Promise", + "appSessionsV1GetLastKeyStates: (req: API.AppSessionsV1GetLastKeyStatesRequest, signal?: AbortSignal): Promise", + "appSessionsV1RebalanceAppSessions: (req: API.AppSessionsV1RebalanceAppSessionsRequest, signal?: AbortSignal): Promise", + "appSessionsV1SubmitAppState: (req: API.AppSessionsV1SubmitAppStateRequest, signal?: AbortSignal): Promise", + "appSessionsV1SubmitDepositState: (req: API.AppSessionsV1SubmitDepositStateRequest, signal?: AbortSignal): Promise", + "appSessionsV1SubmitSessionKeyState: (req: API.AppSessionsV1SubmitSessionKeyStateRequest, signal?: AbortSignal): Promise", + "appsV1GetApps: (req: API.AppsV1GetAppsRequest, signal?: AbortSignal): Promise", + "appsV1SubmitAppVersion: (req: API.AppsV1SubmitAppVersionRequest, signal?: AbortSignal): Promise", + "channelsV1GetChannels: (req: API.ChannelsV1GetChannelsRequest, signal?: AbortSignal): Promise", + "channelsV1GetEscrowChannel: (req: API.ChannelsV1GetEscrowChannelRequest, signal?: AbortSignal): Promise", + "channelsV1GetHomeChannel: (req: API.ChannelsV1GetHomeChannelRequest, signal?: AbortSignal): Promise", + "channelsV1GetLastKeyStates: (req: API.ChannelsV1GetLastKeyStatesRequest, signal?: AbortSignal): Promise", + "channelsV1GetLatestState: (req: API.ChannelsV1GetLatestStateRequest, signal?: AbortSignal): Promise", + "channelsV1RequestCreation: (req: API.ChannelsV1RequestCreationRequest, signal?: AbortSignal): Promise", + "channelsV1SubmitSessionKeyState: (req: API.ChannelsV1SubmitSessionKeyStateRequest, signal?: AbortSignal): Promise", + "channelsV1SubmitState: (req: API.ChannelsV1SubmitStateRequest, signal?: AbortSignal): Promise", + "close: (): Promise", + "eventChannel: (): AsyncIterable", + "isConnected: (): boolean", + "nodeV1GetAssets: (req: API.NodeV1GetAssetsRequest, signal?: AbortSignal): Promise", + "nodeV1GetConfig: (signal?: AbortSignal): Promise", + "nodeV1Ping: (signal?: AbortSignal): Promise", + "start: (url: string, handleClosure: (err?: Error) => void): Promise", + "userV1GetActionAllowances: (req: API.UserV1GetActionAllowancesRequest, signal?: AbortSignal): Promise", + "userV1GetBalances: (req: API.UserV1GetBalancesRequest, signal?: AbortSignal): Promise", + "userV1GetTransactions: (req: API.UserV1GetTransactionsRequest, signal?: AbortSignal): Promise", + ], + "staticProperties": [], + }, + { + "constructors": [ + "(message: string): RPCError", + ], + "kind": "class", + "name": "RPCError", + "properties": [ + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "interface", + "name": "SessionKey", + "properties": [ + "allowances: AssetAllowance[]", + "application: string", + "createdAt: string", + "expiresAt: string", + "id: bigint", + "scope: string", + "sessionKey: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SessionKeyV1", + "properties": [ + "allowances: AssetAllowanceV1[]", + "application: string", + "createdAt: Date", + "expiresAt: Date", + "id: bigint", + "scope: string", + "sessionKey: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SignedAppStateUpdateV1", + "properties": [ + "appStateUpdate: AppStateUpdateV1", + "quorumSigs: string[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "State", + "properties": [ + "asset: string", + "epoch: bigint", + "escrowChannelId: string", + "escrowLedger: Ledger", + "homeChannelId: string", + "homeLedger: Ledger", + "id: string", + "nodeSig: Hex", + "transition: Transition", + "userSig: Hex", + "userWallet: Address", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "StateAdvancer", + "properties": [ + "validateAdvancement: (currentState: State, proposedState: State): Promise", + ], + "signatures": [], + }, + { + "constructors": [ + "(assetStore: AssetStore): StateAdvancerV1", + ], + "kind": "class", + "name": "StateAdvancerV1", + "properties": [ + "validateAdvancement: (currentState: State, proposedState: State): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "StatePacker", + "properties": [ + "packState: (state: State): Promise<\`0x\${string}\`>", + ], + "signatures": [], + }, + { + "constructors": [ + "(assetStore: AssetStore): StatePackerV1", + ], + "kind": "class", + "name": "StatePackerV1", + "properties": [ + "packChallengeState: (state: State): Promise<\`0x\${string}\`>", + "packState: (state: State): Promise<\`0x\${string}\`>", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "StateSigner", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "StateV1", + "properties": [ + "asset: string", + "epoch: string", + "escrow_channel_id: string", + "escrow_ledger: LedgerV1", + "home_channel_id: string", + "home_ledger: LedgerV1", + "id: string", + "node_sig: string", + "transition: TransitionV1", + "user_sig: string", + "user_wallet: Address", + "version: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Token", + "properties": [ + "address: Address", + "blockchainId: bigint", + "decimals: number", + "name: string", + "symbol: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "TokenV1", + "properties": [ + "address: Address", + "blockchain_id: string", + "decimals: number", + "name: string", + "symbol: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Transaction", + "properties": [ + "amount: Decimal", + "asset: string", + "createdAt: Date", + "fromAccount: Address", + "id: string", + "receiverNewStateId: string", + "senderNewStateId: string", + "toAccount: Address", + "txType: TransactionType", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "TransactionSigner", + "properties": [ + "getAccount: (() => ReturnType) | undefined", + "getAddress: (): Address", + "sendTransaction: (tx: any): Promise", + "signMessage: (message: { raw: Hex; }): Promise", + "signPersonalMessage: ((hash: Hex) => Promise) | undefined", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "HomeDeposit = 10", + "HomeWithdrawal = 11", + "EscrowDeposit = 20", + "EscrowWithdraw = 21", + "Transfer = 30", + "Commit = 40", + "Release = 41", + "Rebalance = 42", + "Migrate = 100", + "EscrowLock = 110", + "MutualLock = 120", + "Finalize = 200", + ], + "name": "TransactionType", + }, + { + "kind": "interface", + "name": "TransactionV1", + "properties": [ + "amount: string", + "asset: string", + "created_at: string", + "from_account: Address", + "id: string", + "receiver_new_state_id: string", + "sender_new_state_id: string", + "to_account: Address", + "tx_type: TransactionType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "transformActionAllowance", + "signatures": [ + "(a: ActionAllowanceV1): core.ActionAllowance", + ], + }, + { + "kind": "function", + "name": "transformAppDefinitionFromRPC", + "signatures": [ + "(raw: any): AppDefinitionV1", + ], + }, + { + "kind": "function", + "name": "transformAppDefinitionToRPC", + "signatures": [ + "(def: AppDefinitionV1): any", + ], + }, + { + "kind": "function", + "name": "transformAppSessionInfo", + "signatures": [ + "(raw: any): AppSessionInfoV1", + ], + }, + { + "kind": "function", + "name": "transformAppStateUpdateToRPC", + "signatures": [ + "(update: AppStateUpdateV1): { app_session_id: string; intent: AppStateUpdateIntent; version: string; allocations: Array<{ participant: \`0x\${string}\`; asset: string; amount: string; }>; session_data: string; }", + ], + }, + { + "kind": "function", + "name": "transformAssets", + "signatures": [ + "(assets: AssetV1[]): core.Asset[]", + ], + }, + { + "kind": "function", + "name": "transformBalances", + "signatures": [ + "(balances: BalanceEntryV1[]): core.BalanceEntry[]", + ], + }, + { + "kind": "function", + "name": "transformChannel", + "signatures": [ + "(channel: ChannelV1): core.Channel", + ], + }, + { + "kind": "function", + "name": "transformLedger", + "signatures": [ + "(ledger: LedgerV1): core.Ledger", + ], + }, + { + "kind": "function", + "name": "transformNodeConfig", + "signatures": [ + "(resp: API.NodeV1GetConfigResponse): core.NodeConfig", + ], + }, + { + "kind": "function", + "name": "transformPaginationMetadata", + "signatures": [ + "(metadata: PaginationMetadataV1): core.PaginationMetadata", + ], + }, + { + "kind": "function", + "name": "transformSignedAppStateUpdateToRPC", + "signatures": [ + "(signed: SignedAppStateUpdateV1): { app_state_update: { app_session_id: string; intent: AppStateUpdateIntent; version: string; allocations: Array<{ participant: \`0x\${string}\`; asset: string; amount: string; }>; session_data: string; }; quorum_sigs: Array; }", + ], + }, + { + "kind": "function", + "name": "transformState", + "signatures": [ + "(state: StateV1): core.State", + ], + }, + { + "kind": "function", + "name": "transformTransaction", + "signatures": [ + "(tx: TransactionV1): core.Transaction", + ], + }, + { + "kind": "function", + "name": "transformTransition", + "signatures": [ + "(transition: TransitionV1): core.Transition", + ], + }, + { + "kind": "interface", + "name": "Transition", + "properties": [ + "accountId: string", + "amount: Decimal", + "txId: string", + "type: TransitionType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "transitionRequiresOpenChannel", + "signatures": [ + "(type: TransitionType): boolean", + ], + }, + { + "kind": "function", + "name": "transitionsEqual", + "signatures": [ + "(a: Transition, b: Transition): string | null", + ], + }, + { + "kind": "function", + "name": "transitionToIntent", + "signatures": [ + "(transition: Transition): number", + ], + }, + { + "kind": "function", + "name": "transitionToString", + "signatures": [ + "(type: TransitionType): string", + ], + }, + { + "kind": "enum", + "members": [ + "Void = 0", + "Acknowledgement = 1", + "HomeDeposit = 10", + "HomeWithdrawal = 11", + "EscrowDeposit = 20", + "EscrowWithdraw = 21", + "TransferSend = 30", + "TransferReceive = 31", + "Commit = 40", + "Release = 41", + "Migrate = 100", + "EscrowLock = 110", + "MutualLock = 120", + "Finalize = 200", + ], + "name": "TransitionType", + }, + { + "kind": "interface", + "name": "TransitionV1", + "properties": [ + "account_id: string", + "amount: string", + "tx_id: string", + "type: TransitionType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "translatePayload", + "signatures": [ + "(payload: Payload): T", + ], + }, + { + "kind": "function", + "name": "unmarshalMessage", + "signatures": [ + "(data: string): Message", + ], + }, + { + "kind": "const", + "name": "UserV1GetActionAllowancesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "UserV1GetActionAllowancesRequest", + "properties": [ + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "UserV1GetActionAllowancesResponse", + "properties": [ + "allowances: ActionAllowanceV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "UserV1GetBalancesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "UserV1GetBalancesRequest", + "properties": [ + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "UserV1GetBalancesResponse", + "properties": [ + "balances: BalanceEntryV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "UserV1GetTransactionsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "UserV1GetTransactionsRequest", + "properties": [ + "asset: string", + "from_time: bigint", + "pagination: PaginationParamsV1", + "to_time: bigint", + "tx_type: TransactionType", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "UserV1GetTransactionsResponse", + "properties": [ + "metadata: PaginationMetadataV1", + "transactions: TransactionV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "UserV1Group", + "type": "string", + }, + { + "kind": "function", + "name": "validateDecimalPrecision", + "signatures": [ + "(amount: Decimal, maxDecimals: number): void", + ], + }, + { + "kind": "function", + "name": "validateLedger", + "signatures": [ + "(ledger: Ledger): void", + ], + }, + { + "constructors": [ + "(config?: WebsocketDialerConfig): WebsocketDialer", + ], + "kind": "class", + "name": "WebsocketDialer", + "properties": [ + "call: (req: Message, signal?: AbortSignal): Promise", + "close: (): Promise", + "dial: (url: string, handleClosure: (err?: Error) => void): Promise", + "eventChannel: (): AsyncIterable", + "isConnected: (): boolean", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "WebsocketDialerConfig", + "properties": [ + "eventChanSize: number", + "handshakeTimeout: number", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "withApplicationID", + "signatures": [ + "(appID: string): Option", + ], + }, + { + "kind": "function", + "name": "withBlockchainRPC", + "signatures": [ + "(chainId: bigint, rpcUrl: string): Option", + ], + }, + { + "kind": "function", + "name": "withErrorHandler", + "signatures": [ + "(handler: (error: Error) => void): Option", + ], + }, + { + "kind": "function", + "name": "withHandshakeTimeout", + "signatures": [ + "(timeout: number): Option", + ], + }, +] +`; + +exports[`SDK public runtime API drift guard keeps root runtime exports intentional 1`] = ` +[ + "APPLICATION_ID_QUERY_PARAM", + "AppSessionKeySignerV1", + "AppSessionStatus", + "AppSessionWalletSignerV1", + "AppSessionsV1CreateAppSessionMethod", + "AppSessionsV1GetAppDefinitionMethod", + "AppSessionsV1GetAppSessionsMethod", + "AppSessionsV1GetLastKeyStatesMethod", + "AppSessionsV1Group", + "AppSessionsV1RebalanceAppSessionsMethod", + "AppSessionsV1SubmitAppStateMethod", + "AppSessionsV1SubmitDepositStateMethod", + "AppSessionsV1SubmitSessionKeyStateMethod", + "AppStateUpdateIntent", + "AppsV1GetAppsMethod", + "AppsV1Group", + "AppsV1SubmitAppVersionMethod", + "CHANNEL_HUB_VERSION", + "ChannelDefaultSigner", + "ChannelParticipant", + "ChannelSessionKeyStateSigner", + "ChannelSignerType", + "ChannelStatus", + "ChannelType", + "ChannelV1Group", + "ChannelsV1GetChannelsMethod", + "ChannelsV1GetEscrowChannelMethod", + "ChannelsV1GetHomeChannelMethod", + "ChannelsV1GetLastKeyStatesMethod", + "ChannelsV1GetLatestStateMethod", + "ChannelsV1RequestCreationMethod", + "ChannelsV1SubmitSessionKeyStateMethod", + "ChannelsV1SubmitStateMethod", + "Client", + "ClientAssetStore", + "DEFAULT_CHALLENGE_PERIOD", + "DefaultConfig", + "DefaultWebsocketDialerConfig", + "ERROR_PARAM_KEY", + "ErrAlreadyConnected", + "ErrConnectionTimeout", + "ErrDialingWebsocket", + "ErrInvalidRequestMethod", + "ErrMarshalingRequest", + "ErrNilRequest", + "ErrNoResponse", + "ErrNotConnected", + "ErrReadingMessage", + "ErrSendingPing", + "ErrSendingRequest", + "EthereumMsgSigner", + "EthereumRawSigner", + "INTENT_CLOSE", + "INTENT_DEPOSIT", + "INTENT_FINALIZE_ESCROW_DEPOSIT", + "INTENT_FINALIZE_ESCROW_WITHDRAWAL", + "INTENT_FINALIZE_MIGRATION", + "INTENT_INITIATE_ESCROW_DEPOSIT", + "INTENT_INITIATE_ESCROW_WITHDRAWAL", + "INTENT_INITIATE_MIGRATION", + "INTENT_OPERATE", + "INTENT_WITHDRAW", + "MsgType", + "NodeV1GetAssetsMethod", + "NodeV1GetConfigMethod", + "NodeV1Group", + "NodeV1PingMethod", + "RPCClient", + "RPCError", + "StateAdvancerV1", + "StatePackerV1", + "TransactionType", + "TransitionType", + "UserV1GetActionAllowancesMethod", + "UserV1GetBalancesMethod", + "UserV1GetTransactionsMethod", + "UserV1Group", + "WebsocketDialer", + "appSessionStatusToString", + "appStateUpdateIntentToString", + "appendApplicationIDQueryParam", + "applyAcknowledgementTransition", + "applyChannelCreation", + "applyCommitTransition", + "applyEscrowDepositTransition", + "applyEscrowLockTransition", + "applyEscrowWithdrawTransition", + "applyFinalizeTransition", + "applyHomeDepositTransition", + "applyHomeWithdrawalTransition", + "applyMigrateTransition", + "applyMutualLockTransition", + "applyReceiverTransitions", + "applyReleaseTransition", + "applyTransferReceiveTransition", + "applyTransferSendTransition", + "createSigners", + "decimalToBigInt", + "errorf", + "evm", + "generateAppSessionIDV1", + "generateChannelMetadata", + "generateNonce", + "generateRebalanceBatchIDV1", + "generateRebalanceTransactionIDV1", + "getChannelSessionKeyAuthMetadataHashV1", + "getEscrowChannelId", + "getHomeChannelId", + "getLastTransition", + "getOffsetAndLimit", + "getReceiverTransactionId", + "getSenderTransactionId", + "getStateId", + "getStateTransitionHash", + "isFinal", + "ledgerEqual", + "marshalMessage", + "messageError", + "newChannel", + "newErrorPayload", + "newErrorResponse", + "newEvent", + "newMessage", + "newPayload", + "newRPCClient", + "newRequest", + "newResponse", + "newStatePackerV1", + "newTransaction", + "newTransition", + "newVoidState", + "newWebsocketDialer", + "nextState", + "packAppSessionKeyStateV1", + "packAppStateUpdateV1", + "packAppV1", + "packChallengeState", + "packChannelKeyStateV1", + "packCreateAppSessionRequestV1", + "packState", + "payloadError", + "transformActionAllowance", + "transformAppDefinitionFromRPC", + "transformAppDefinitionToRPC", + "transformAppSessionInfo", + "transformAppStateUpdateToRPC", + "transformAssets", + "transformBalances", + "transformChannel", + "transformLedger", + "transformNodeConfig", + "transformPaginationMetadata", + "transformSignedAppStateUpdateToRPC", + "transformState", + "transformTransaction", + "transformTransition", + "transitionRequiresOpenChannel", + "transitionToIntent", + "transitionToString", + "transitionsEqual", + "translatePayload", + "unmarshalMessage", + "validateDecimalPrecision", + "validateLedger", + "withApplicationID", + "withBlockchainRPC", + "withErrorHandler", + "withHandshakeTimeout", +] +`; diff --git a/sdk/ts/test/unit/abi-drift.test.ts b/sdk/ts/test/unit/abi-drift.test.ts new file mode 100644 index 000000000..eb0b18574 --- /dev/null +++ b/sdk/ts/test/unit/abi-drift.test.ts @@ -0,0 +1,278 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { AppRegistryAbi } from '../../src/blockchain/evm/app_registry_abi.js'; +import { ChannelHubAbi } from '../../src/blockchain/evm/channel_hub_abi.js'; +import { Erc20Abi } from '../../src/blockchain/evm/erc20_abi.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../../..'); + +type AbiEntry = { + type: string; + name?: string; + inputs?: AbiParam[]; + outputs?: AbiParam[]; + stateMutability?: string; +}; + +type AbiParam = { + type: string; + components?: AbiParam[]; +}; + +type FunctionDiff = { + contract: string; + name: string; + artifact?: string; + sdk?: string; +}; + +function canonicalType(param: AbiParam): string { + if (!param.components?.length) return param.type; + return `${param.type}<${param.components.map(canonicalType).join(',')}>`; +} + +function signature(entry: AbiEntry): string { + const inputs = (entry.inputs ?? []).map(canonicalType).join(','); + const outputs = (entry.outputs ?? []).map(canonicalType).join(','); + const mutability = entry.stateMutability ? ` ${entry.stateMutability}` : ''; + return `${entry.name}(${inputs}) -> (${outputs})${mutability}`; +} + +function functionSignatures(abi: readonly AbiEntry[]): Map { + const signaturesByName = new Map(); + + for (const entry of abi) { + if (entry.type !== 'function' || !entry.name) continue; + + const signatures = signaturesByName.get(entry.name) ?? []; + signatures.push(signature(entry)); + signaturesByName.set(entry.name, signatures); + } + + return new Map( + [...signaturesByName].map(([name, signatures]) => [name, signatures.sort().join('\n')]) + ); +} + +function loadArtifact(relativePath: string): readonly AbiEntry[] { + const artifactPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(artifactPath)) { + throw new Error(`ABI artifact not found: ${relativePath}. Run: cd contracts && forge build`); + } + const artifact = JSON.parse(fs.readFileSync(artifactPath, 'utf8')); + return artifact.abi; +} + +function sortedSignatureEntries(signatures: ReadonlyMap): [string, string][] { + return [...signatures].sort(([left], [right]) => left.localeCompare(right)); +} + +function diffConsumedFunctions( + contract: string, + artifactAbi: readonly AbiEntry[], + sdkAbi: readonly AbiEntry[], + consumedFunctions: readonly string[] +): FunctionDiff[] { + const artifactSigs = functionSignatures(artifactAbi); + const sdkSigs = functionSignatures(sdkAbi); + + return consumedFunctions + .map((name) => ({ + contract, + name, + artifact: artifactSigs.get(name), + sdk: sdkSigs.get(name), + })) + .filter( + ({ artifact: artifactSig, sdk: sdkSig }) => + artifactSig !== sdkSig || artifactSig === undefined || sdkSig === undefined + ); +} + +function diffSdkSubsetAgainstManifest( + contract: string, + expectedSignatures: ReadonlyMap, + sdkAbi: readonly AbiEntry[] +): FunctionDiff[] { + const sdkSigs = functionSignatures(sdkAbi); + + return [...expectedSignatures] + .map(([name, expected]) => ({ + contract, + name, + artifact: expected, + sdk: sdkSigs.get(name), + })) + .filter(({ artifact: expected, sdk }) => expected !== sdk); +} + +describe('contract ABI drift guards', () => { + it('keeps checked-in ChannelHub ABI aligned with Foundry artifact for every artifact function', () => { + const artifactSigs = functionSignatures( + loadArtifact('contracts/out/ChannelHub.sol/ChannelHub.json') + ); + const sdkSigs = functionSignatures(ChannelHubAbi as readonly AbiEntry[]); + + expect(sortedSignatureEntries(sdkSigs)).toEqual(sortedSignatureEntries(artifactSigs)); + }); + + it('keeps SDK-consumed ChannelHub functions aligned with Foundry artifact', () => { + const consumedFunctions = [ + 'VERSION', + 'createChannel', + 'depositToNode', + 'withdrawFromNode', + 'depositToChannel', + 'withdrawFromChannel', + 'checkpointChannel', + 'challengeChannel', + 'closeChannel', + 'getChannelData', + 'getNodeBalance', + 'getNodeValidator', + 'getOpenChannels', + ]; + + expect( + diffConsumedFunctions( + 'ChannelHub', + loadArtifact('contracts/out/ChannelHub.sol/ChannelHub.json'), + ChannelHubAbi as readonly AbiEntry[], + consumedFunctions + ) + ).toEqual([]); + }); + + it('keeps checked-in ERC20 ABI aligned with the Foundry artifact for SDK-consumed functions', () => { + const consumedFunctions = [ + 'allowance', + 'approve', + 'balanceOf', + 'decimals', + 'name', + 'symbol', + 'totalSupply', + 'transfer', + 'transferFrom', + ]; + + expect( + diffConsumedFunctions( + 'ERC20', + loadArtifact('contracts/out/ERC20.sol/ERC20.default.json'), + Erc20Abi as readonly AbiEntry[], + consumedFunctions + ) + ).toEqual([]); + }); + + it('keeps manually checked-in AppRegistry ABI aligned with SDK-consumed function manifest', () => { + // There is currently no AppRegistry/NonSlashableAppRegistry Foundry artifact in this repo. + // Until that source/artifact exists, guard the SDK-consumed ABI surface explicitly. + const expected = new Map([ + ['UNLOCK_PERIOD', 'UNLOCK_PERIOD() -> (uint256) view'], + ['asset', 'asset() -> (address) view'], + ['balanceOf', 'balanceOf(address) -> (uint256) view'], + ['lock', 'lock(address,uint256) -> () nonpayable'], + ['lockStateOf', 'lockStateOf(address) -> (uint8) view'], + ['relock', 'relock() -> () nonpayable'], + ['unlock', 'unlock() -> () nonpayable'], + ['unlockTimestampOf', 'unlockTimestampOf(address) -> (uint256) view'], + ['withdraw', 'withdraw(address) -> () nonpayable'], + ]); + + expect( + diffSdkSubsetAgainstManifest( + 'AppRegistry', + expected, + AppRegistryAbi as readonly AbiEntry[] + ) + ).toEqual([]); + }); + + it('reports adversarial ChannelHub function signature changes with contract and function names', () => { + expect( + diffConsumedFunctions( + 'ChannelHub', + [ + { + type: 'function', + name: 'getNodeValidator', + inputs: [{ type: 'address' }, { type: 'uint8' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, + ], + [ + { + type: 'function', + name: 'getNodeValidator', + inputs: [{ type: 'uint8' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, + ], + ['getNodeValidator'] + ) + ).toEqual([ + { + contract: 'ChannelHub', + name: 'getNodeValidator', + artifact: 'getNodeValidator(address,uint8) -> (address) view', + sdk: 'getNodeValidator(uint8) -> (address) view', + }, + ]); + }); + + it('reports adversarial ERC20 missing consumed functions', () => { + expect( + diffConsumedFunctions( + 'ERC20', + [ + { + type: 'function', + name: 'approve', + inputs: [{ type: 'address' }, { type: 'uint256' }], + outputs: [{ type: 'bool' }], + stateMutability: 'nonpayable', + }, + ], + [], + ['approve'] + ) + ).toEqual([ + { + contract: 'ERC20', + name: 'approve', + artifact: 'approve(address,uint256) -> (bool) nonpayable', + sdk: undefined, + }, + ]); + }); + + it('reports adversarial AppRegistry manifest signature changes', () => { + const expected = new Map([['lock', 'lock(address,uint256) -> () nonpayable']]); + + expect( + diffSdkSubsetAgainstManifest('AppRegistry', expected, [ + { + type: 'function', + name: 'lock', + inputs: [{ type: 'address' }, { type: 'uint256' }], + outputs: [], + stateMutability: 'view', + }, + ]) + ).toEqual([ + { + contract: 'AppRegistry', + name: 'lock', + artifact: 'lock(address,uint256) -> () nonpayable', + sdk: 'lock(address,uint256) -> () view', + }, + ]); + }); +}); diff --git a/sdk/ts/test/unit/app-signing-drift.test.ts b/sdk/ts/test/unit/app-signing-drift.test.ts new file mode 100644 index 000000000..01faa412a --- /dev/null +++ b/sdk/ts/test/unit/app-signing-drift.test.ts @@ -0,0 +1,251 @@ +import { Decimal } from 'decimal.js'; + +import { + AppStateUpdateIntent, + generateAppSessionIDV1, + packAppStateUpdateV1, + packAppSessionKeyStateV1, + packCreateAppSessionRequestV1, + type AppDefinitionV1, + type AppSessionKeyStateV1, +} from '../../src/app/index.js'; + +// Regenerate expected hashes with: +// go run ./scripts/drift/generate-app-signing-vectors.go + +const user = '0x1111111111111111111111111111111111111111'; +const app = '0x2222222222222222222222222222222222222222'; +const maxUint64 = 18446744073709551615n; + +const definition: AppDefinitionV1 = { + applicationId: 'store-v1', + participants: [ + { walletAddress: user, signatureWeight: 1 }, + { walletAddress: app, signatureWeight: 1 }, + ], + quorum: 2, + nonce: 123456789n, +}; + +const sessionKeyState: AppSessionKeyStateV1 = { + user_address: user, + session_key: app, + version: '1', + application_ids: ['0x00000000000000000000000000000000000000000000000000000000000000a1'], + app_session_ids: ['0x00000000000000000000000000000000000000000000000000000000000000b1'], + expires_at: '1739812234', + user_sig: '0xSig', +}; + +describe('Go/TS app signing drift vectors', () => { + it('matches Go PackCreateAppSessionRequestV1 and GenerateAppSessionIDV1 vectors', () => { + expect(packCreateAppSessionRequestV1(definition, '{"cart":"demo"}')).toBe( + '0x405d15a85c16ac1e555b3319de58acf7b4b86ebe2ccaf6af802d61e450b88632' + ); + expect(generateAppSessionIDV1(definition)).toBe( + '0x9b88181fc2ee0bc03abad5c4c9ea421c6748919882d4053204d95fbc79a175eb' + ); + }); + + it('matches Go PackCreateAppSessionRequestV1 uint64 nonce boundary vector', () => { + expect( + packCreateAppSessionRequestV1( + { + ...definition, + nonce: maxUint64, + }, + '{"cart":"max-nonce"}' + ) + ).toBe('0xf15b0c1bc732b62d840e3c026e125cb5dec7da2b658c36355835ae56802c781c'); + }); + + it('matches Go PackAppStateUpdateV1 deposit, withdraw, operate, fractional, and uint64 vectors', () => { + const appSessionId = generateAppSessionIDV1(definition); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 2n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.25') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit"}', + }) + ).toBe('0x65e0856b8de315f40db44b9cc4165fa7e590169b3325e500a03aa380954c393d'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Operate, + version: 3n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.35') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"purchase","item_id":1,"item_price":"0.90"}', + }) + ).toBe('0xe44d77fa3eda431b1bc088e6f89e114b2191ef5ce03cc6851c702d01bdbf3457'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Withdraw, + version: 4n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.10') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"withdraw"}', + }) + ).toBe('0x4290525a204a34e5fc4d37427f1b0b1e2d375ed09ed0ac3b23d14dbc481c7d71'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 5n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.23456789') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit","note":"fractional"}', + }) + ).toBe('0x626e03a0850b83f3bac66dc7bd27b1e2d882fd88b54a61fd76d9fbaa35703098'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Withdraw, + version: maxUint64, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0') }, + { participant: app, asset: 'YUSD', amount: new Decimal('1.25') }, + ], + sessionData: '{"intent":"withdraw","boundary":"max_uint64_version"}', + }) + ).toBe('0x6460b0c93c88da7fa34bfbf3893be74362e35987b525c7b34b4749e66fef8862'); + }); + + it('matches Go PackAppSessionKeyStateV1 vector', () => { + expect(packAppSessionKeyStateV1(sessionKeyState)).toBe( + '0x9fedfbcd577c5e677b95b1273e38f52ffdeee096e98f731c5455e4c73e0274aa' + ); + }); + + it('proves adversarial allocation ordering changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 2n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.25') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit"}', + }); + const mutated = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 2n, + allocations: [ + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + { participant: user, asset: 'YUSD', amount: new Decimal('1.25') }, + ], + sessionData: '{"intent":"deposit"}', + }); + + expect(mutated).not.toBe(canonical); + }); + + it('proves adversarial amount rounding changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 5n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.23456789') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit","note":"fractional"}', + }); + const rounded = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 5n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.234568') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit","note":"fractional"}', + }); + + expect(rounded).not.toBe(canonical); + }); + + it('proves adversarial intent enum changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Withdraw, + version: 4n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.10') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"withdraw"}', + }); + const wrongIntent = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 4n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.10') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"withdraw"}', + }); + + expect(wrongIntent).not.toBe(canonical); + }); + + it('proves adversarial session data normalization changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Operate, + version: 3n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.35') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"purchase","item_id":1,"item_price":"0.90"}', + }); + const normalized = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Operate, + version: 3n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.35') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"item_id":1,"item_price":"0.90","intent":"purchase"}', + }); + + expect(normalized).not.toBe(canonical); + }); + + it('proves adversarial session-key ID placement changes the signed hash', () => { + const canonical = packAppSessionKeyStateV1(sessionKeyState); + const swappedIds = packAppSessionKeyStateV1({ + ...sessionKeyState, + application_ids: sessionKeyState.app_session_ids, + app_session_ids: sessionKeyState.application_ids, + }); + + expect(swappedIds).not.toBe(canonical); + }); +}); diff --git a/sdk/ts/test/unit/public-api-drift.test.ts b/sdk/ts/test/unit/public-api-drift.test.ts new file mode 100644 index 000000000..2b17f6154 --- /dev/null +++ b/sdk/ts/test/unit/public-api-drift.test.ts @@ -0,0 +1,247 @@ +import * as publicApi from '../../src/index.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(testDir, '../..'); + +const FORMAT_FLAGS = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType | + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope; + +type PublicApiMember = { + name: string; + kind: string; + signatures?: string[]; + constructors?: string[]; + properties?: string[]; + staticProperties?: string[]; + members?: string[]; + type?: string; + declaration?: string; +}; + +function normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function createPackageProgram() { + const configPath = ts.findConfigFile(packageRoot, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) throw new Error(`tsconfig.json not found under ${packageRoot}`); + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')); + } + + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageRoot); + return ts.createProgram(parsed.fileNames, parsed.options); +} + +function declarationKind(declaration: ts.Declaration): string { + if (ts.isClassDeclaration(declaration)) return 'class'; + if (ts.isInterfaceDeclaration(declaration)) return 'interface'; + if (ts.isFunctionDeclaration(declaration)) return 'function'; + if (ts.isEnumDeclaration(declaration)) return 'enum'; + if (ts.isTypeAliasDeclaration(declaration)) return 'type'; + if (ts.isVariableDeclaration(declaration)) return 'const'; + return ts.SyntaxKind[declaration.kind] ?? 'unknown'; +} + +function signaturesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return type + .getCallSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); +} + +function isPrivateOrProtected(declaration: ts.Declaration): boolean { + const flags = ts.getCombinedModifierFlags(declaration); + return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); +} + +function propertiesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return checker + .getPropertiesOfType(type) + .flatMap((property) => { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] ?? declaration; + if (isPrivateOrProtected(propertyDeclaration)) return []; + + const propertyType = checker.getTypeOfSymbolAtLocation(property, propertyDeclaration); + const signatures = signaturesForType(checker, propertyType, propertyDeclaration); + if (signatures.length > 0) { + return [`${property.getName()}: ${signatures.join(' | ')}`]; + } + if ( + (ts.isPropertySignature(propertyDeclaration) || + ts.isPropertyDeclaration(propertyDeclaration)) && + propertyDeclaration.type + ) { + return [`${property.getName()}: ${normalizeText(propertyDeclaration.type.getText())}`]; + } + return [ + `${property.getName()}: ${checker.typeToString(propertyType, propertyDeclaration, FORMAT_FLAGS)}`, + ]; + }) + .sort(); +} + +function enumMembers(declaration: ts.EnumDeclaration): string[] { + return declaration.members.map((member) => { + const initializer = member.initializer ? normalizeText(member.initializer.getText()) : ''; + return `${member.name.getText()} = ${initializer}`; + }); +} + +function serializePublicApi(): PublicApiMember[] { + const program = createPackageProgram(); + const checker = program.getTypeChecker(); + const entrypoint = program.getSourceFile(path.join(packageRoot, 'src/index.ts')); + if (!entrypoint) throw new Error('src/index.ts not found in program'); + + const moduleSymbol = checker.getSymbolAtLocation(entrypoint); + if (!moduleSymbol) throw new Error('src/index.ts module symbol not found'); + + return checker + .getExportsOfModule(moduleSymbol) + .filter((symbol) => symbol.getName() !== '__esModule') + .map((exportedSymbol) => { + const symbol = + exportedSymbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(exportedSymbol) + : exportedSymbol; + const declaration = symbol.getDeclarations()?.[0]; + if (!declaration) { + return { + name: exportedSymbol.getName(), + kind: 'unknown', + }; + } + + const kind = declarationKind(declaration); + const member: PublicApiMember = { + name: exportedSymbol.getName(), + kind, + }; + + if (ts.isClassDeclaration(declaration)) { + const staticType = checker.getTypeOfSymbolAtLocation(symbol, declaration); + const instanceType = checker.getDeclaredTypeOfSymbol(symbol); + member.constructors = staticType + .getConstructSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); + member.properties = propertiesForType(checker, instanceType, declaration); + member.staticProperties = propertiesForType(checker, staticType, declaration).filter( + (property) => !['length', 'name', 'prototype'].some((skip) => property.startsWith(`${skip}:`)) + ); + } else if (ts.isInterfaceDeclaration(declaration)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + member.properties = propertiesForType(checker, type, declaration); + member.signatures = signaturesForType(checker, type, declaration); + } else if (ts.isFunctionDeclaration(declaration)) { + member.signatures = signaturesForType( + checker, + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration + ); + } else if (ts.isEnumDeclaration(declaration)) { + member.members = enumMembers(declaration); + } else if (ts.isTypeAliasDeclaration(declaration)) { + member.declaration = normalizeText(declaration.type.getText()); + member.type = checker.typeToString( + checker.getTypeFromTypeNode(declaration.type), + declaration, + FORMAT_FLAGS + ); + } else if (ts.isVariableDeclaration(declaration)) { + member.type = checker.typeToString( + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration, + FORMAT_FLAGS + ); + } + + return member; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +describe('SDK public runtime API drift guard', () => { + it('keeps root runtime exports intentional', () => { + expect(Object.keys(publicApi).sort()).toMatchSnapshot(); + }); + + it('keeps root TypeScript public API signatures intentional', () => { + expect(serializePublicApi()).toMatchSnapshot(); + }); + + it('keeps Client exported', () => { + expect(Object.keys(publicApi)).toContain('Client'); + }); + + it('proves adversarial public signature changes are observable', () => { + const api = serializePublicApi(); + const client = api.find((member) => member.name === 'Client'); + expect(client?.properties?.some((property) => property.includes('ping:'))).toBe(true); + + const mutated = api.map((member) => + member.name === 'Client' + ? { + ...member, + properties: member.properties?.filter((property) => !property.includes('ping:')), + } + : member + ); + const mutatedClient = mutated.find((member) => member.name === 'Client'); + + expect(mutatedClient?.properties?.some((property) => property.includes('ping:'))).toBe(false); + }); + + it('proves adversarial type-only export removal is observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === 'Config' && member.kind === 'interface')).toBe(true); + + const mutated = api.filter((member) => member.name !== 'Config'); + expect(mutated.some((member) => member.name === 'Config')).toBe(false); + }); + + it('proves adversarial function parameter changes are observable', () => { + const api = serializePublicApi(); + const packer = api.find((member) => member.name === 'packAppStateUpdateV1'); + const original = packer?.signatures?.[0] ?? ''; + expect(original).toContain('stateUpdate: AppStateUpdateV1'); + + const mutated = original.replace('stateUpdate: AppStateUpdateV1', 'stateUpdate: unknown'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial enum value changes are observable', () => { + const api = serializePublicApi(); + const intent = api.find((member) => member.name === 'AppStateUpdateIntent'); + const original = intent?.members?.join('|') ?? ''; + expect(original).toContain('Deposit'); + + const mutated = original.replace('Deposit', 'DepositChanged'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial public export additions are observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === '__FakeExport')).toBe(false); + + const mutated = [...api, { name: '__FakeExport', kind: 'function' }]; + expect(mutated.some((member) => member.name === '__FakeExport')).toBe(true); + }); +}); diff --git a/sdk/ts/test/unit/rpc-drift.test.ts b/sdk/ts/test/unit/rpc-drift.test.ts index 79124bb93..d88898c9c 100644 --- a/sdk/ts/test/unit/rpc-drift.test.ts +++ b/sdk/ts/test/unit/rpc-drift.test.ts @@ -46,6 +46,11 @@ function extractRouterHandlers(source: string): Set { return new Set(methodNames.map((name) => namedLiterals.get(name) as string)); } +function extractTsClientMethods(source: string): Set { + const matches = source.matchAll(/^\s{2}(?:static\s+)?(?:async\s+)?([A-Za-z0-9_]+)\(/gm); + return new Set(Array.from(matches, ([, method]) => method)); +} + function sorted(values: Set): string[] { return Array.from(values).sort(); } @@ -58,6 +63,36 @@ function diff(left: Set, right: Set): { missing: string[]; extra } describe('TS RPC drift guards', () => { + const publicClientMethodsByRPCMethod = new Map([ + ['node.v1.ping', 'ping'], + ['node.v1.get_config', 'getConfig'], + ['node.v1.get_assets', 'getAssets'], + ['user.v1.get_balances', 'getBalances'], + ['user.v1.get_transactions', 'getTransactions'], + ['user.v1.get_action_allowances', 'getActionAllowances'], + ['channels.v1.get_home_channel', 'getHomeChannel'], + ['channels.v1.get_escrow_channel', 'getEscrowChannel'], + ['channels.v1.get_channels', 'getChannels'], + ['channels.v1.get_latest_state', 'getLatestState'], + ['channels.v1.submit_session_key_state', 'submitChannelSessionKeyState'], + ['channels.v1.get_last_key_states', 'getLastChannelKeyStates'], + ['app_sessions.v1.submit_deposit_state', 'submitAppSessionDeposit'], + ['app_sessions.v1.submit_app_state', 'submitAppState'], + ['app_sessions.v1.rebalance_app_sessions', 'rebalanceAppSessions'], + ['app_sessions.v1.get_app_definition', 'getAppDefinition'], + ['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'], + ['apps.v1.get_apps', 'getApps'], + ['apps.v1.submit_app_version', 'registerApp'], + ]); + + const intentionallyRawOnlyMethods = new Set([ + 'channels.v1.request_creation', + 'channels.v1.submit_state', + ]); + it('keeps the TS raw RPC method surface aligned with pkg/rpc', () => { const goMethods = extractGoMethodLiterals( fs.readFileSync(path.join(repoRoot, 'pkg/rpc/methods.go'), 'utf8') @@ -83,4 +118,56 @@ describe('TS RPC drift guards', () => { expect({ missing, extra }).toEqual({ missing: [], extra: [] }); }); + + it('keeps public Client wrappers aligned with public RPC methods', () => { + const routerMethods = extractRouterHandlers( + fs.readFileSync(path.join(repoRoot, 'nitronode/api/rpc_router.go'), 'utf8') + ); + const clientMethods = extractTsClientMethods( + fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/client.ts'), 'utf8') + ); + // The client extractor depends on class-method indentation. Fail loudly if that parser breaks. + expect(clientMethods.size).toBeGreaterThan(20); + + const coveredMethods = new Set([ + ...publicClientMethodsByRPCMethod.keys(), + ...intentionallyRawOnlyMethods, + ]); + const uncoveredRouterMethods = sorted( + new Set(Array.from(routerMethods).filter((method) => !coveredMethods.has(method))) + ); + const missingClientMethods = Array.from(publicClientMethodsByRPCMethod) + .filter(([method]) => routerMethods.has(method)) + .filter(([, clientMethod]) => !clientMethods.has(clientMethod)) + .map(([method, clientMethod]) => `${method} -> Client.${clientMethod}()`) + .sort(); + + expect({ uncoveredRouterMethods, missingClientMethods }).toEqual({ + uncoveredRouterMethods: [], + missingClientMethods: [], + }); + }); + + it('reports adversarial method additions as missing TS methods', () => { + const tsMethods = new Set(['node.v1.ping']); + const goMethods = new Set(['node.v1.ping', 'node.v1.fake_method']); + + expect(diff(tsMethods, goMethods)).toEqual({ + missing: ['node.v1.fake_method'], + extra: [], + }); + }); + + it('reports adversarial TS method removals as missing public wrappers', () => { + const routerMethods = new Set(['node.v1.ping']); + const clientMethods = new Set(); + const mapping = new Map([['node.v1.ping', 'ping']]); + + const missingClientMethods = Array.from(mapping) + .filter(([method]) => routerMethods.has(method)) + .filter(([, clientMethod]) => !clientMethods.has(clientMethod)) + .map(([method, clientMethod]) => `${method} -> Client.${clientMethod}()`); + + expect(missingClientMethods).toEqual(['node.v1.ping -> Client.ping()']); + }); }); diff --git a/sdk/ts/test/unit/rpc-dto-drift.test.ts b/sdk/ts/test/unit/rpc-dto-drift.test.ts new file mode 100644 index 000000000..3eb5833a9 --- /dev/null +++ b/sdk/ts/test/unit/rpc-dto-drift.test.ts @@ -0,0 +1,173 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../../..'); + +type FieldShape = { + optional: boolean; + container: 'array' | 'scalar'; +}; + +type DTOShape = Record>; + +function normalizeGoContainer(typeText: string): FieldShape['container'] { + return typeText.replace(/^\*/, '').trim().startsWith('[]') ? 'array' : 'scalar'; +} + +function normalizeTsContainer(typeText: string): FieldShape['container'] { + return typeText.trim().endsWith('[]') || typeText.includes('Array<') ? 'array' : 'scalar'; +} + +function extractGoDTOShapes(source: string): DTOShape { + const shapes: DTOShape = {}; + const emptyStructs = source.matchAll( + /^type\s+([A-Za-z0-9]+(?:Request|Response))\s+struct\s*\{\s*\}$/gm + ); + for (const [, typeName] of emptyStructs) { + shapes[typeName] = {}; + } + + const structs = source.matchAll( + /^type\s+([A-Za-z0-9]+(?:Request|Response))\s+struct\s*\{\n([\s\S]*?)^\}/gm + ); + + for (const [, typeName, body] of structs) { + const fields: Record = {}; + for (const line of body.split('\n')) { + const tag = line.match(/`json:"([^"]+)"`/); + if (!tag) continue; + + const [wireName, ...tagOptions] = tag[1].split(','); + if (wireName === '-') continue; + + const beforeTag = line.slice(0, line.indexOf('`')).trim(); + const parts = beforeTag.split(/\s+/); + const typeText = parts.slice(1).join(' '); + + fields[wireName] = { + optional: typeText.startsWith('*') || tagOptions.includes('omitempty'), + container: normalizeGoContainer(typeText), + }; + } + shapes[typeName] = fields; + } + + return shapes; +} + +function extractTsDTOShapes(source: string): DTOShape { + const shapes: DTOShape = {}; + const emptyInterfaces = source.matchAll( + /^export\s+interface\s+([A-Za-z0-9]+(?:Request|Response))\s*\{\s*\}$/gm + ); + for (const [, typeName] of emptyInterfaces) { + shapes[typeName] = {}; + } + + const interfaces = source.matchAll( + /^export\s+interface\s+([A-Za-z0-9]+(?:Request|Response))\s*\{\n([\s\S]*?)^\}/gm + ); + + for (const [, typeName, body] of interfaces) { + const fields: Record = {}; + for (const line of body.split('\n')) { + const field = line.match(/^\s*([A-Za-z0-9_]+)(\?)?:\s*([^;]+);/); + if (!field) continue; + + const [, wireName, optionalMarker, typeText] = field; + fields[wireName] = { + optional: optionalMarker === '?', + container: normalizeTsContainer(typeText), + }; + } + shapes[typeName] = fields; + } + + return shapes; +} + +function diffDTOShapes(tsShapes: DTOShape, goShapes: DTOShape) { + const missingTypes = Object.keys(goShapes).filter((typeName) => !(typeName in tsShapes)).sort(); + const extraTypes = Object.keys(tsShapes).filter((typeName) => !(typeName in goShapes)).sort(); + const fieldDiffs: string[] = []; + + for (const typeName of Object.keys(goShapes).filter((name) => name in tsShapes).sort()) { + const goFields = goShapes[typeName]; + const tsFields = tsShapes[typeName]; + for (const fieldName of Object.keys(goFields).sort()) { + if (!(fieldName in tsFields)) { + fieldDiffs.push(`${typeName}.${fieldName}: missing in TS`); + continue; + } + if (goFields[fieldName].optional !== tsFields[fieldName].optional) { + fieldDiffs.push( + `${typeName}.${fieldName}: optionality Go=${goFields[fieldName].optional} TS=${tsFields[fieldName].optional}` + ); + } + if (goFields[fieldName].container !== tsFields[fieldName].container) { + fieldDiffs.push( + `${typeName}.${fieldName}: container Go=${goFields[fieldName].container} TS=${tsFields[fieldName].container}` + ); + } + } + for (const fieldName of Object.keys(tsFields).sort()) { + if (!(fieldName in goFields)) { + fieldDiffs.push(`${typeName}.${fieldName}: extra in TS`); + } + } + } + + return { missingTypes, extraTypes, fieldDiffs }; +} + +describe('RPC DTO drift guards', () => { + it('keeps Go RPC JSON DTO fields aligned with TS RPC interfaces', () => { + const goShapes = extractGoDTOShapes( + fs.readFileSync(path.join(repoRoot, 'pkg/rpc/api.go'), 'utf8') + ); + const tsShapes = extractTsDTOShapes( + fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/rpc/api.ts'), 'utf8') + ); + + expect(diffDTOShapes(tsShapes, goShapes)).toEqual({ + missingTypes: [], + extraTypes: [], + fieldDiffs: [], + }); + }); + + it('reports adversarial Go-only required fields with field-level paths', () => { + const goShapes = extractGoDTOShapes(` +type NodeV1PingRequest struct { + Required string \`json:"required"\` +} +`); + const tsShapes = extractTsDTOShapes(` +export interface NodeV1PingRequest {} +`); + + expect(diffDTOShapes(tsShapes, goShapes).fieldDiffs).toEqual([ + 'NodeV1PingRequest.required: missing in TS', + ]); + }); + + it('reports adversarial optionality and array/scalar drift', () => { + const goShapes = extractGoDTOShapes(` +type NodeV1GetAssetsResponse struct { + Assets []AssetV1 \`json:"assets,omitempty"\` +} +`); + const tsShapes = extractTsDTOShapes(` +export interface NodeV1GetAssetsResponse { + assets: AssetV1; +} +`); + + expect(diffDTOShapes(tsShapes, goShapes).fieldDiffs).toEqual([ + 'NodeV1GetAssetsResponse.assets: optionality Go=true TS=false', + 'NodeV1GetAssetsResponse.assets: container Go=array TS=scalar', + ]); + }); +}); diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts new file mode 100644 index 000000000..9dcba41a1 --- /dev/null +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -0,0 +1,372 @@ +import { Decimal } from 'decimal.js'; +import { jest } from '@jest/globals'; + +import { Client } from '../../src/client.js'; +import { + transformAppSessionInfo, + transformAssets, + transformNodeConfig, +} from '../../src/utils.js'; +import { + transformAppSessionKeyState, + transformChannelSessionKeyState, +} from '../../src/session_key_state_transforms.js'; + +const userAddress = '0x1111111111111111111111111111111111111111'; +const sessionKeyAddress = '0x2222222222222222222222222222222222222222'; + +const appSessionRaw = { + app_session_id: '0xsession', + app_definition: { + application_id: 'store-v1', + participants: [ + { + wallet_address: userAddress, + signature_weight: 1, + }, + { + wallet_address: sessionKeyAddress, + signature_weight: 1, + }, + ], + quorum: 2, + nonce: '123', + }, + status: 'open', + session_data: '{"intent":"purchase"}', + version: '4', + allocations: [ + { + participant: userAddress, + asset: 'YUSD', + amount: '1.25', + }, + ], +}; + +const channelKeyStateRaw = { + user_address: userAddress, + session_key: sessionKeyAddress, + version: '7', + assets: ['YUSD'], + expires_at: '1739812234', + user_sig: '0xabc123', +}; + +const appSessionKeyStateRaw = { + user_address: userAddress, + session_key: sessionKeyAddress, + version: '8', + application_ids: ['0x00000000000000000000000000000000000000000000000000000000000000a1'], + app_session_ids: ['0x00000000000000000000000000000000000000000000000000000000000000b1'], + expires_at: '1739812234', + user_sig: '0xdef456', +}; + +describe('Nitronode response transform drift guards', () => { + it('maps current get_app_sessions app_definition shape to SDK appDefinition', () => { + const session = transformAppSessionInfo(appSessionRaw); + + expect(session).toEqual({ + appSessionId: '0xsession', + appDefinition: { + applicationId: 'store-v1', + participants: [ + { + walletAddress: userAddress, + signatureWeight: 1, + }, + { + walletAddress: sessionKeyAddress, + signatureWeight: 1, + }, + ], + quorum: 2, + nonce: 123n, + }, + isClosed: false, + sessionData: '{"intent":"purchase"}', + version: 4n, + allocations: [ + { + participant: userAddress, + asset: 'YUSD', + amount: new Decimal('1.25'), + }, + ], + }); + }); + + it('rejects app sessions missing the required app_definition payload', () => { + expect(() => + transformAppSessionInfo({ + app_session_id: '0xsession', + status: 'open', + session_data: '', + version: '1', + allocations: [], + }) + ).toThrow('Invalid app definition: missing required fields'); + }); + + it('rejects malformed top-level app session and definition payloads', () => { + expect(() => transformAppSessionInfo(null)).toThrow( + 'Invalid app session: expected object payload' + ); + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + allocations: undefined, + }) + ).toThrow('Invalid app session allocations: expected allocations to be an array'); + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + app_definition: { + ...appSessionRaw.app_definition, + participants: undefined, + }, + }) + ).toThrow('Invalid app definition: expected participants to be an array'); + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + app_definition: { + ...appSessionRaw.app_definition, + quorum: undefined, + }, + }) + ).toThrow('Invalid app definition: missing required field quorum'); + }); + + it('rejects app sessions missing required allocation fields', () => { + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + allocations: [ + { + participant: userAddress, + asset: 'YUSD', + }, + ], + }) + ).toThrow('Invalid app session allocation[0]: missing required string field amount'); + }); + + it('maps get_config supported_sig_validators from array and base64 forms', () => { + const base = { + node_address: '0x1111111111111111111111111111111111111111' as const, + node_version: 'test', + blockchains: [ + { + name: 'Sepolia', + blockchain_id: '11155111', + channel_hub_address: '0x2222222222222222222222222222222222222222', + locking_contract_address: '0x3333333333333333333333333333333333333333', + }, + ], + }; + + expect( + transformNodeConfig({ + ...base, + supported_sig_validators: [0, 1], + }).supportedSigValidators + ).toEqual([0, 1]); + + expect( + transformNodeConfig({ + ...base, + supported_sig_validators: 'AAE=', + } as any).supportedSigValidators + ).toEqual([0, 1]); + + expect(transformNodeConfig(base as any).supportedSigValidators).toEqual([]); + }); + + it('maps get_assets symbols, decimals, suggested chain, and token chains', () => { + expect( + transformAssets([ + { + name: 'Yellow USD', + symbol: 'YUSD', + decimals: 6, + suggested_blockchain_id: '11155111', + tokens: [ + { + name: 'Yellow USD', + symbol: 'YUSD', + address: '0x4444444444444444444444444444444444444444', + blockchain_id: '11155111', + decimals: 6, + }, + ], + }, + ]) + ).toEqual([ + { + name: 'Yellow USD', + symbol: 'YUSD', + decimals: 6, + suggestedBlockchainId: 11155111n, + tokens: [ + { + name: 'Yellow USD', + symbol: 'YUSD', + address: '0x4444444444444444444444444444444444444444', + blockchainId: 11155111n, + decimals: 6, + }, + ], + }, + ]); + }); + + it('validates channel key-state and app-session key-state fixtures', () => { + expect(transformChannelSessionKeyState(channelKeyStateRaw)).toEqual(channelKeyStateRaw); + expect(transformAppSessionKeyState(appSessionKeyStateRaw)).toEqual(appSessionKeyStateRaw); + }); + + it('rejects malformed key-state fixtures with clear errors', () => { + expect(() => + transformChannelSessionKeyState( + { + ...channelKeyStateRaw, + user_sig: undefined, + }, + 'channel session key state[0]' + ) + ).toThrow('Invalid channel session key state[0]: missing required string field user_sig'); + + expect(() => + transformAppSessionKeyState( + { + ...appSessionKeyStateRaw, + app_session_ids: 'not-an-array', + }, + 'app session key state[0]' + ) + ).toThrow('Invalid app session key state[0]: expected app_session_ids to be string[]'); + + expect(() => + transformAppSessionKeyState( + { + ...appSessionKeyStateRaw, + application_ids: undefined, + applicationIds: appSessionKeyStateRaw.application_ids, + }, + 'app session key state[0]' + ) + ).toThrow('Invalid app session key state[0]: expected application_ids to be string[]'); + + expect(() => transformAppSessionKeyState([], 'app session key state[0]')).toThrow( + 'Invalid app session key state[0]: expected object' + ); + }); + + it('maps high-level client app-session and key-state responses through transform paths', async () => { + const rpcClient = { + appSessionsV1GetAppSessions: jest.fn(async () => ({ + app_sessions: [appSessionRaw], + metadata: { + page: 1, + per_page: 10, + total_count: 1, + page_count: 1, + }, + })), + channelsV1GetLastKeyStates: jest.fn(async () => ({ + states: [channelKeyStateRaw], + })), + appSessionsV1GetLastKeyStates: jest.fn(async () => ({ + states: [appSessionKeyStateRaw], + })), + }; + const clientLike = { rpcClient }; + + const sessionsResult = await (Client.prototype.getAppSessions as any).call(clientLike, { + wallet: userAddress, + page: 1, + pageSize: 10, + }); + const channelKeyStates = await (Client.prototype.getLastChannelKeyStates as any).call( + clientLike, + userAddress + ); + const appSessionKeyStates = await (Client.prototype.getLastKeyStates as any).call( + clientLike, + userAddress + ); + + expect(sessionsResult.sessions).toHaveLength(1); + expect(sessionsResult.metadata).toEqual({ + page: 1, + perPage: 10, + totalCount: 1, + pageCount: 1, + }); + expect(channelKeyStates).toEqual([channelKeyStateRaw]); + expect(appSessionKeyStates).toEqual([appSessionKeyStateRaw]); + expect(rpcClient.appSessionsV1GetAppSessions).toHaveBeenCalledWith({ + app_session_id: undefined, + participant: userAddress, + status: undefined, + pagination: { + offset: 0, + limit: 10, + }, + }); + }); + + it('rejects malformed key-state response containers before mapping', async () => { + const clientLike = { + rpcClient: { + channelsV1GetLastKeyStates: jest.fn(async () => ({ + states: null, + })), + appSessionsV1GetLastKeyStates: jest.fn(async () => ({ + states: {}, + })), + }, + }; + + await expect( + (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) + ).rejects.toThrow('Invalid app key states response: expected states to be an array'); + }); + + it('maps high-level client empty app-session responses', async () => { + const clientLike = { + rpcClient: { + appSessionsV1GetAppSessions: jest.fn(async () => ({ + app_sessions: [], + metadata: { + page: 1, + per_page: 10, + total_count: 0, + page_count: 0, + }, + })), + }, + }; + + const result = await (Client.prototype.getAppSessions as any).call(clientLike, { + wallet: userAddress, + page: 1, + pageSize: 10, + }); + + expect(result).toEqual({ + sessions: [], + metadata: { + page: 1, + perPage: 10, + totalCount: 0, + pageCount: 0, + }, + }); + }); +}); From 14fd16d2fed5523572691b270f3e28f91efd5d56 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Fri, 8 May 2026 16:02:04 +0200 Subject: [PATCH 03/26] MF-I01-I02: fix(contracts): address security audit findings I-01 and I-02 (#728) --- contracts/src/ChannelHub.sol | 10 ++++++--- contracts/src/Utils.sol | 19 ++++++++++++----- contracts/src/interfaces/Types.sol | 3 +++ contracts/test/ChannelHub_Node.t.sol | 6 ++++++ ...nnelHub_challengeSessionKeyValidator.t.sol | 1 + .../ChannelHub_singlechain.lifecycle.t.sol | 1 + contracts/test/ChannelHub_sigValidator.t.sol | 1 + contracts/test/Utils.t.sol | 21 +++++++++++++++++++ 8 files changed, 54 insertions(+), 8 deletions(-) diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index 86d4feeea..df5cc2236 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -337,7 +337,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()); @@ -477,8 +477,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 diff --git a/contracts/src/Utils.sol b/contracts/src/Utils.sol index ca27b8659..9a5ab1183 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) 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_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_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/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; From e2f12b8a865d6e5327f25132f7c6b9cd757411da Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Fri, 8 May 2026 17:47:00 +0300 Subject: [PATCH 04/26] MF-C01: rpc: cap inbound WebSocket frame size and rate-limit per connection (#723) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Cap inbound WebSocket frame size at the transport layer via `gorilla.SetReadLimit` (default 128 KiB). Frames over the cap close the connection with code 1009 before any allocation. - Add a pluggable `FrameRateLimiter` interface (`pkg/rpc/rate_limiter.go`) with a `ByteTokenBucket` implementation. Per-connection byte budget (default 256 KiB/s steady, 1 MiB burst) consulted before each frame is dispatched. - Wire knobs through `WebsocketNodeConfig` and nitronode `runtime.go` (`NITRONODE_WS_MAX_MESSAGE_SIZE`, `NITRONODE_WS_BYTES_PER_SEC`, `NITRONODE_WS_BYTES_BURST`). - Unit tests cover bucket burst/refill/cap/overflow + connection behavior on oversized frame and limiter rejection. - stress-v1 chart picks up the new envs and gains NGINX ingress annotations (`limit-connections`, `limit-rps`, `limit-burst-multiplier`, `proxy-body-size`). Chart README documents the layered defense (Cloudflare → ingress-nginx → app). ## Why Audit finding: the RPC server unmarshals incoming WebSocket frames before any size or rate check. An unauthenticated attacker could send oversized frames or sustained large traffic and exhaust memory/CPU before the per-message rate-limit middleware ran (it executes inside `processRequests`, after the first JSON unmarshal). The new caps stop the work at the transport layer. ## Companion PR Pairs with [layer-3/ops#15](https://github.com/layer-3/ops/pull/15) which preserves real client IPs through ingress-nginx so the per-IP annotations on the WebSocket Ingress key on real clients. ## Notable - Latent bug fixed: `readMessages` did not call `handleClosure` on the queue-full path, so `Serve`'s `wg.Wait` could hang on that exit. - Defaults sized for browser reload reconnect storms (auth + subscribe ≈ 20 KB / tab) — they clear the bucket instantly. - Disable the byte-rate cap with `NITRONODE_WS_BYTES_PER_SEC=-1` for canary rollout. Frame size cap stays on regardless. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * WebSocket connection hardening with configurable message size limits and per-connection byte-rate limiting to mitigate denial-of-service scenarios. * **Documentation** * Added WebSocket DoS hardening guidance including WAF rate-limiting recommendations and NGINX Ingress configuration examples. * **Tests** * Added comprehensive tests for WebSocket rate limiting and oversized frame handling. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/api/rate_limits.go | 52 +++--- nitronode/chart/README.md | 67 ++++++++ .../config/stress-v1/nitronode.yaml.gotmpl | 21 +++ nitronode/runtime.go | 47 ++++++ pkg/rpc/connection.go | 83 +++++++++- pkg/rpc/connection_test.go | 156 ++++++++++++++++++ pkg/rpc/node.go | 15 ++ pkg/rpc/rate_limiter.go | 70 ++++++++ pkg/rpc/rate_limiter_test.go | 72 ++++++++ 9 files changed, 553 insertions(+), 30 deletions(-) create mode 100644 pkg/rpc/rate_limiter.go create mode 100644 pkg/rpc/rate_limiter_test.go 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/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..91907b07a 100644 --- a/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl +++ b/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl @@ -21,6 +21,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 +73,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/runtime.go b/nitronode/runtime.go index 2093e6342..94332e7f5 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 { @@ -196,11 +204,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/pkg/rpc/connection.go b/pkg/rpc/connection.go index 273e01701..1a86e249a 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. @@ -75,6 +79,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. @@ -104,6 +112,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 @@ -141,6 +154,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 +199,25 @@ 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, + 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 +248,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. @@ -322,10 +358,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 +389,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 +414,7 @@ func (conn *WebsocketConnection) readMessages(handleClosure func(error)) { case conn.closeConnCh <- struct{}{}: default: } + handleClosure(nil) return } } diff --git a/pkg/rpc/connection_test.go b/pkg/rpc/connection_test.go index 294073dc7..5787f7669 100644 --- a/pkg/rpc/connection_test.go +++ b/pkg/rpc/connection_test.go @@ -92,6 +92,143 @@ 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_ConnectionID(t *testing.T) { t.Parallel() @@ -125,8 +262,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 +274,7 @@ func newGorillaWsConnMock(ctx context.Context) *gorillaWsConnMock { return &gorillaWsConnMock{ ctx: ctx, messageToReadCh: make(chan []byte, 1), + readErrCh: make(chan error, 1), } } @@ -145,6 +285,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 +344,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..3964a1b27 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. @@ -220,6 +228,11 @@ 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"), @@ -227,6 +240,8 @@ func (wn *WebsocketNode) ServeHTTP(w http.ResponseWriter, r *http.Request) { 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)) +} From 558b58b559ee44702122ff996cffb87679d33f89 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Mon, 11 May 2026 10:11:40 +0200 Subject: [PATCH 05/26] MF-L02: docs(protocol): qualify enforcement guarantee for intent-specific execution paths (#737) --- docs/protocol/enforcement.md | 4 ++-- docs/protocol/overview.md | 6 +++--- docs/protocol/security-and-limitations.md | 5 +++-- 3 files changed, 8 insertions(+), 7 deletions(-) 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..429029e2e 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 From c5de723507af0ece7d5cb17c83f8e6fa87433bdc Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Mon, 11 May 2026 10:12:04 +0200 Subject: [PATCH 06/26] MF-L02-I03-I04_I05: fix(contracts): add more Node trust assumptions and requirements (#738) --- contracts/SECURITY.md | 64 ++++++++++++++++------- contracts/src/Utils.sol | 5 +- docs/protocol/security-and-limitations.md | 1 + pkg/rpc/api.go | 2 +- protocol-description.md | 6 ++- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index f87347f64..998d21378 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,39 @@ no funds can be permanently locked if it does. --- +## 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 +265,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` @@ -383,6 +405,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 diff --git a/contracts/src/Utils.sol b/contracts/src/Utils.sol index 9a5ab1183..dbfd4cfc7 100644 --- a/contracts/src/Utils.sol +++ b/contracts/src/Utils.sol @@ -80,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/docs/protocol/security-and-limitations.md b/docs/protocol/security-and-limitations.md index 429029e2e..962ebca2a 100644 --- a/docs/protocol/security-and-limitations.md +++ b/docs/protocol/security-and-limitations.md @@ -63,6 +63,7 @@ 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. Participants do not need to trust nodes for: diff --git a/pkg/rpc/api.go b/pkg/rpc/api.go index a67ae12e3..bd6c7a856 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -148,7 +148,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"` } diff --git a/protocol-description.md b/protocol-description.md index 9cab99a04..1cb97f2ae 100644 --- a/protocol-description.md +++ b/protocol-description.md @@ -582,8 +582,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 +596,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 +617,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 From e84f227ed432fb5dec93b769baacc3c829586ef0 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Mon, 11 May 2026 16:21:47 +0300 Subject: [PATCH 07/26] MF-M01: backfill state user_sig from on-chain events (#731) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Plumb the on-chain user signature from the parsed `State` payload through the reactor into `channelEvent` / `channelChallengedEvent`, then forward it to the matching `EventHandlerService` handlers. - New `ChannelHubEventHandlerStore.UpdateStateUserSigIfMissing` populates `channel_states.user_sig` when NULL; idempotent on replay via `user_sig IS NULL` guard. - Closes the wedge where a unilateral on-chain checkpoint of a node-only state (e.g. recipient submits the `TransferReceive` state directly) leaves the local row partially signed, causing `EnsureNoOngoingStateTransitions` to permanently mismatch versions and block the channel until an on-chain challenge. ## Threat addressed Recipient signs the node-issued `TransferReceive` state and submits it on chain. `HomeChannelCheckpointed` bumps `channels.state_version` to N+1, but `channel_states` row at N+1 still has `user_sig = NULL`. The gate query in `EnsureNoOngoingStateTransitions` filters on both signatures, picks the prior bilateral row at N, and reports a version mismatch on every subsequent state transition. Channel wedged offchain; recovery requires on-chain challenge — costly griefing vector with a one-tx attack surface. ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `go test ./...` - [x] New unit tests: - `nitronode/event_handlers/service_test.go` — `BackfillsUserSig`, `BackfillError`; existing handler tests updated to expect the backfill mock call (empty sig). - `nitronode/store/database/db_store_test.go` — wedge-resolution end-to-end (gate fails → backfill → gate passes), idempotent replay (existing sig preserved), empty-sig no-op, unknown-version no-op. - `pkg/blockchain/evm/channel_hub_reactor_test.go` — reactor hex-encodes `Candidate.UserSig` into the event. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/event_handlers/service.go | 40 ++++ nitronode/event_handlers/service_test.go | 92 +++++++++ nitronode/event_handlers/testing.go | 6 + nitronode/store/database/db_store_test.go | 175 ++++++++++++++++++ nitronode/store/database/interface.go | 3 + nitronode/store/database/state.go | 18 ++ pkg/blockchain/evm/channel_hub_reactor.go | 28 +++ .../evm/channel_hub_reactor_test.go | 45 +++++ pkg/core/event.go | 5 + pkg/core/interface.go | 5 + 10 files changed, 417 insertions(+) diff --git a/nitronode/event_handlers/service.go b/nitronode/event_handlers/service.go index 72fdc4ead..24c4c07e0 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,6 +114,10 @@ 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 } @@ -158,6 +166,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 +211,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,6 +257,10 @@ 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 } @@ -298,6 +318,10 @@ func (s *EventHandlerService) HandleEscrowDepositChallenged(ctx context.Context, } } + 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 } @@ -328,6 +352,10 @@ 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 } @@ -358,6 +386,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 +446,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 +480,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..5685137c8 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) @@ -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) @@ -266,6 +269,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 +324,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 +379,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) @@ -415,6 +421,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 +463,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 +518,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 +560,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 +599,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) diff --git a/nitronode/event_handlers/testing.go b/nitronode/event_handlers/testing.go index 659abc428..f416405c2 100644 --- a/nitronode/event_handlers/testing.go +++ b/nitronode/event_handlers/testing.go @@ -86,3 +86,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/store/database/db_store_test.go b/nitronode/store/database/db_store_test.go index 8aeb10c24..8a96677f2 100644 --- a/nitronode/store/database/db_store_test.go +++ b/nitronode/store/database/db_store_test.go @@ -646,3 +646,178 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { require.NoError(t, err) }) } + +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")) + }) +} diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 72f87dc70..47997c4c0 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -80,6 +80,9 @@ type DatabaseStore interface { // EnsureNoOngoingStateTransitions validates that no conflicting blockchain operations are pending. EnsureNoOngoingStateTransitions(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. 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/pkg/blockchain/evm/channel_hub_reactor.go b/pkg/blockchain/evm/channel_hub_reactor.go index e8711e458..d143d35ba 100644 --- a/pkg/blockchain/evm/channel_hub_reactor.go +++ b/pkg/blockchain/evm/channel_hub_reactor.go @@ -67,6 +67,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 +78,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 +261,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 +275,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 +289,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 +303,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 +317,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 +332,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 +346,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 +360,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 +375,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 +389,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 +403,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 +418,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 +432,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 +446,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 +460,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 +474,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 +488,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) } diff --git a/pkg/blockchain/evm/channel_hub_reactor_test.go b/pkg/blockchain/evm/channel_hub_reactor_test.go index 18eb31379..e8091b5c4 100644 --- a/pkg/blockchain/evm/channel_hub_reactor_test.go +++ b/pkg/blockchain/evm/channel_hub_reactor_test.go @@ -85,6 +85,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 @@ -371,6 +376,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" diff --git a/pkg/core/event.go b/pkg/core/event.go index 4a407eed3..228961c0a 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -47,12 +47,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 { diff --git a/pkg/core/interface.go b/pkg/core/interface.go index eb647f616..3699fc496 100644 --- a/pkg/core/interface.go +++ b/pkg/core/interface.go @@ -139,6 +139,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 { From 8b4ad8211f4e7a06a743d71bc6e187a1e5b8f8f0 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Mon, 11 May 2026 16:33:50 +0300 Subject: [PATCH 08/26] MF-M02: fix(rpc): release Serve wait group on processSink overflow (#732) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `readMessages` returned without calling `handleClosure` when `processSink` was full, leaving the `Serve` wait group stuck at one outstanding `Done`. The parent closure callback never fired, the WebSocket was never closed, and the outer HTTP handler stayed blocked — `connHub.Remove` never ran. Unauthenticated clients could exhaust server resources by flooding messages faster than the queue drained. - Call `handleClosure(nil)` before returning from the queue-full branch so the wait group invariant (every early return decrements exactly once) holds. - Add a regression test that fills `processSink` without a consumer and asserts the parent closure fires and the underlying connection is closed. ## Notes - Same fix already exists on `fix/nitronode-mf-c01` (part of broader frame-size + rate-limiter hardening). Three-way merge between this branch and c01 is clean — identical line addition, non-overlapping hunks. - c01 also adds a parallel `frameRateLimiter.Admit` early-return path with the same `handleClosure(nil)` shape. ## Test plan - [x] `go test ./pkg/rpc/... -count=1` passes - [x] New test fails without the one-line fix (`parent handleClosure not invoked after processSink overflow; goroutine leak`) - [x] `go vet ./pkg/rpc/...` clean - [x] CI green 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- pkg/rpc/connection_test.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/pkg/rpc/connection_test.go b/pkg/rpc/connection_test.go index 5787f7669..4c5987b42 100644 --- a/pkg/rpc/connection_test.go +++ b/pkg/rpc/connection_test.go @@ -229,6 +229,42 @@ func TestWebsocketConnection_RateLimitedFrame_ClosesConnection(t *testing.T) { 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() From 2d21b00b682b8545a3da6b5c11cf3d72af862bf7 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 12 May 2026 11:49:56 +0300 Subject: [PATCH 09/26] MF-I06: fix(nitronode): gate escrow transitions on home channel onchain materialization (#730) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Rename `CheckOpenChannel` → `CheckActiveChannel`. Return `(string, *core.ChannelStatus, error)` instead of `(string, bool, error)` so callers can distinguish `Void` (DB-only, awaiting onchain confirmation) from `Open` (materialized onchain). Nil status pointer means no active channel. - Document why the permissive `Void+Open` filter is intentional: non-escrow offchain transitions (transfers, etc.) are allowed before onchain confirmation lands. - Gate cross-chain escrow transitions (`MutualLock`, `EscrowLock`) on `Status == Open` with an executable guard placed **before** the stub `return rpc.Errorf("transition is not supported yet")`. Single-step unstub later: drop the stub return; guard stays active. Today the guard already runs — `Void` inputs get `"home channel is not materialized onchain"`, `Open` inputs fall through to `"not supported yet"`. - Add `TestSubmitState_MutualLock_VoidHomeChannel_Rejected` and `TestSubmitState_EscrowLock_VoidHomeChannel_Rejected` to lock the contract: future PRs that unstub these transitions without preserving the guard get red CI. ## Why Audit finding (MF-I06): `request_creation()` inserts the home channel into the DB as `ChannelStatusVoid` before the channel is materialized onchain. `CheckOpenChannel` treated `status <= Open` as usable, so a future cross-chain `MutualLock` / `INITIATE_ESCROW_DEPOSIT` flow could be co-signed before the prior creation/checkpoint state was submitted via `createChannel()`. Onchain, `_isChannelHomeChain()` returns false while status == VOID, so `initiateEscrowDeposit()` would not take the home-chain path. The fix is to surface the actual status to callers and gate cross-chain escrow transitions on `Status == Open`. The escrow branches are currently stubbed, so the guard has no live-traffic impact today (the stub `return` would fire regardless, and `nodeSig` is never returned via `c.Succeed` on the error path). The value of shipping the guard now is the CI contract: the Void-rejection tests pin the behavior so an unstub-PR that forgets the guard cannot land green. ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `go test ./nitronode/store/database/... ./nitronode/api/channel_v1/... ./nitronode/api/app_session_v1/...` - [x] New Void-rejection tests pass; existing `_Success` tests (skipped) unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Refactor** * Improved channel status validation to distinguish between in-progress and on-chain channels across the application. The system now provides more granular channel state information to ensure accurate eligibility checks during deposit submissions and state transitions. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/api/app_session_v1/interface.go | 9 +- .../app_session_v1/submit_deposit_state.go | 8 +- .../submit_deposit_state_test.go | 10 +- nitronode/api/app_session_v1/testing.go | 9 +- nitronode/api/channel_v1/interface.go | 9 +- nitronode/api/channel_v1/submit_state.go | 30 ++- nitronode/api/channel_v1/submit_state_test.go | 205 +++++++++++++++++- nitronode/api/channel_v1/testing.go | 9 +- nitronode/event_handlers/service.go | 2 +- nitronode/event_handlers/service_test.go | 2 +- nitronode/store/database/channel.go | 32 ++- nitronode/store/database/channel_test.go | 25 ++- nitronode/store/database/interface.go | 10 +- 13 files changed, 300 insertions(+), 60 deletions(-) diff --git a/nitronode/api/app_session_v1/interface.go b/nitronode/api/app_session_v1/interface.go index b0b33b2c5..6a936a422 100644 --- a/nitronode/api/app_session_v1/interface.go +++ b/nitronode/api/app_session_v1/interface.go @@ -34,9 +34,12 @@ 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. 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/testing.go b/nitronode/api/app_session_v1/testing.go index e2effdf26..92541e08c 100644 --- a/nitronode/api/app_session_v1/testing.go +++ b/nitronode/api/app_session_v1/testing.go @@ -79,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) { diff --git a/nitronode/api/channel_v1/interface.go b/nitronode/api/channel_v1/interface.go index 1f2e84723..6449a7140 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -28,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); diff --git a/nitronode/api/channel_v1/submit_state.go b/nitronode/api/channel_v1/submit_state.go index 650cfe06d..c082c6b7e 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 diff --git a/nitronode/api/channel_v1/submit_state_test.go b/nitronode/api/channel_v1/submit_state_test.go index 5aedb6bff..2456313cd 100644 --- a/nitronode/api/channel_v1/submit_state_test.go +++ b/nitronode/api/channel_v1/submit_state_test.go @@ -156,7 +156,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() @@ -339,7 +339,7 @@ func TestSubmitState_TransferSend_ReceiverWithEscrowLock_Rejected(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() @@ -455,7 +455,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 +591,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 +751,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 +890,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 +1025,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 +1179,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 +1340,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,7 +1480,7 @@ 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) @@ -1617,7 +1617,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 +1668,189 @@ 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) +} + // 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 0d50290ac..b01a8cf9b 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -41,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 { diff --git a/nitronode/event_handlers/service.go b/nitronode/event_handlers/service.go index 24c4c07e0..60b8142c1 100644 --- a/nitronode/event_handlers/service.go +++ b/nitronode/event_handlers/service.go @@ -124,7 +124,7 @@ func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, // 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 diff --git a/nitronode/event_handlers/service_test.go b/nitronode/event_handlers/service_test.go index 5685137c8..3f88d83c5 100644 --- a/nitronode/event_handlers/service_test.go +++ b/nitronode/event_handlers/service_test.go @@ -103,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. diff --git a/nitronode/store/database/channel.go b/nitronode/store/database/channel.go index feb605bef..57b43185f 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,37 @@ 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 +// 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_test.go b/nitronode/store/database/channel_test.go index 9d0bf9894..a531f2732 100644 --- a/nitronode/store/database/channel_test.go +++ b/nitronode/store/database/channel_test.go @@ -377,7 +377,7 @@ func TestDBStore_GetActiveHomeChannel(t *testing.T) { }) } -func TestDBStore_CheckOpenChannel(t *testing.T) { +func TestDBStore_CheckActiveChannel(t *testing.T) { t.Run("Success - Has open channel", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() @@ -420,25 +420,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 +480,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,9 +528,9 @@ 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) }) } diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 47997c4c0..a477b8fb2 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -46,11 +46,15 @@ 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) + // 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) // UpdateChannel persists changes to a channel's metadata (status, version, etc). UpdateChannel(channel core.Channel) error From 769bd7990bdea87242266186e080775168981e51 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 12 May 2026 11:51:54 +0300 Subject: [PATCH 10/26] MF-M05: fix(nitronode): enforce TLS by default for Postgres (#733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace hardcoded `sslmode=disable` in `postgresqlDbUrl` with configurable `NITRONODE_DATABASE_SSLMODE` (binary default: `require`, validated against the libpq list). - Honor `NITRONODE_DATABASE_URL` verbatim when set so operators can supply a fully-qualified DSN; individual host/user/password/sslmode fields are ignored in that case. - Wire helm chart helper to propagate `config.database.sslmode` as `NITRONODE_DATABASE_SSLMODE`. Chart default is `require` so helm and the binary agree; deployments where Postgres is only reachable on a private network (e.g. cluster-internal pgbouncer, VPC-only Cloud SQL) can opt out by setting `sslmode: disable`. The stress-v1 profile does so explicitly. ## Why Audit finding MF-M05: every Postgres connection (schema create, migrations, runtime) used `sslmode=disable`, so credentials and protocol traffic could be observed or tampered with by anyone on the path. The chart documented an `sslmode` value but never propagated it. ## Notes - Binary default is `require` (TLS without cert verification). Operators wanting strict cert checking should set `verify-full`. - Local dev / testcontainers paths still use `sslmode=disable` intentionally. - Release note: direct binary users without TLS-capable Postgres must set `NITRONODE_DATABASE_SSLMODE=disable` explicitly. Helm users on a private-network Postgres must set `config.database.sslmode: disable` to keep the previous behavior. ## Test plan - [x] `go test ./nitronode/store/database/ -run TestPostgresqlDbUrl` — covers default, explicit, invalid, schema append, URL precedence, unsupported driver - [x] `go vet ./nitronode/store/database/` clean - [x] `go build ./nitronode/...` clean - [x] Helm template renders correctly with `sslmode` set / unset (default → `require`, override → `disable`) - [x] Manual smoke against TLS-enabled Postgres 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/README.md | 3 +- .../config/stress-v1/nitronode.yaml.gotmpl | 3 + nitronode/chart/templates/helpers/_common.tpl | 2 + nitronode/chart/values.yaml | 9 ++- nitronode/store/database/database.go | 33 +++++++- nitronode/store/database/database_test.go | 77 +++++++++++++++++++ 6 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 nitronode/store/database/database_test.go 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/chart/config/stress-v1/nitronode.yaml.gotmpl b/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl index 91907b07a..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 }}" 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/store/database/database.go b/nitronode/store/database/database.go index d0d1435c5..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 != "" { 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") + }) +} From 97871b0504e4a6f687f63214ba8966d320be0779 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Tue, 12 May 2026 11:52:16 +0300 Subject: [PATCH 11/26] MF-M07: Unblock receiver states after finalized escrow operations (#735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace the `lastSignedState.EscrowChannelID != nil` guard in `issueTransferReceiverState` (`channel_v1`) and `issueReleaseReceiverState` (`app_session_v1`) with a new `EnsureNoOngoingEscrowOperation(wallet, asset)` store method. - Predicate keys on the latest signed transition type instead of the raw `EscrowChannelID` field: `EscrowLock` / `MutualLock` always block; `EscrowDeposit` / `EscrowWithdraw` block only when the on-chain escrow channel `state_version` hasn't caught up; everything else allows. - Adds DB-store tests covering all branches (no prior state, non-escrow, lock-types, finalize synced/unsynced, unsigned). ## Background (audit MF-M07) The previous guard rejected any receiver-side state whenever the receiver's latest signed state had a non-nil `EscrowChannelID`. The field is cleared by `State.NextState()` only when the previous transition was `EscrowDeposit` or `EscrowWithdraw`, so the signed state itself retains it after a finalized escrow flow. As a result, a user who completed a deposit/withdrawal could be unable to receive transfers or app-session releases until an unrelated off-chain action advanced past the finalized state. The replacement matches the existing `EnsureNoOngoingStateTransitions` pattern (transition type + state.version vs channel.state_version) but is scoped to escrow concerns only, so home-deposit / home-withdrawal logic is untouched. ## Test plan - [x] `go test ./nitronode/store/database/... -run EnsureNoOngoingEscrowOperation -v` - [x] `go test ./nitronode/...` - [x] `go test ./pkg/...` 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **Bug Fixes** - Enhanced escrow operation validation to detect and block state transitions when escrow activities are in-flight, including locks, deposits, and withdrawals for wallet-asset pairs. - Strengthened operational safeguards by preventing state changes during pending escrow operations not yet finalized on-chain. - Improved consistency of escrow-related state management across payment channel and application session handlers. --------- Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/api/app_session_v1/handler.go | 11 +- nitronode/api/app_session_v1/interface.go | 5 + .../app_session_v1/submit_app_state_test.go | 24 +- nitronode/api/app_session_v1/testing.go | 5 + nitronode/api/channel_v1/handler.go | 10 +- nitronode/api/channel_v1/interface.go | 5 + nitronode/api/channel_v1/submit_state_test.go | 18 +- nitronode/api/channel_v1/testing.go | 5 + nitronode/store/database/db_store.go | 76 +++- nitronode/store/database/db_store_test.go | 398 ++++++++++++++++++ nitronode/store/database/interface.go | 5 + 11 files changed, 507 insertions(+), 55 deletions(-) diff --git a/nitronode/api/app_session_v1/handler.go b/nitronode/api/app_session_v1/handler.go index e731ddfdf..20e34fd0e 100644 --- a/nitronode/api/app_session_v1/handler.go +++ b/nitronode/api/app_session_v1/handler.go @@ -159,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 6a936a422..5ee8e91bc 100644 --- a/nitronode/api/app_session_v1/interface.go +++ b/nitronode/api/app_session_v1/interface.go @@ -46,6 +46,11 @@ type Store interface { 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) 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 af704a71a..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" @@ -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) @@ -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) } @@ -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 @@ -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() diff --git a/nitronode/api/app_session_v1/testing.go b/nitronode/api/app_session_v1/testing.go index 92541e08c..7f7e7bd82 100644 --- a/nitronode/api/app_session_v1/testing.go +++ b/nitronode/api/app_session_v1/testing.go @@ -113,6 +113,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) LockSessionKeyState(userAddress, sessionKey string, kind database.SessionKeyKind) (uint64, error) { args := m.Called(userAddress, sessionKey, kind) return uint64(args.Int(0)), args.Error(1) diff --git a/nitronode/api/channel_v1/handler.go b/nitronode/api/channel_v1/handler.go index a1745ce59..95ae37012 100644 --- a/nitronode/api/channel_v1/handler.go +++ b/nitronode/api/channel_v1/handler.go @@ -110,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 6449a7140..9984c71df 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -44,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 diff --git a/nitronode/api/channel_v1/submit_state_test.go b/nitronode/api/channel_v1/submit_state_test.go index 2456313cd..45ed7fae5 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" @@ -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,17 +325,6 @@ 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) @@ -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) } diff --git a/nitronode/api/channel_v1/testing.go b/nitronode/api/channel_v1/testing.go index b01a8cf9b..29cd00292 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -61,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) 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 8a96677f2..74757ce84 100644 --- a/nitronode/store/database/db_store_test.go +++ b/nitronode/store/database/db_store_test.go @@ -645,6 +645,170 @@ 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) { @@ -821,3 +985,237 @@ func TestDBStore_UpdateStateUserSigIfMissing(t *testing.T) { 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 a477b8fb2..26898a307 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -84,6 +84,11 @@ 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 From 0c02a459ea8bbba3fde842d1d677aa5de51d15ee Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 13 May 2026 11:04:02 +0200 Subject: [PATCH 12/26] MF-M04: feat: provide tooling for and enhance docs on ValidatorRegistered event (#744) --- contracts/SECURITY.md | 16 + docs/protocol/security-and-limitations.md | 1 + pkg/blockchain/evm/validator_watcher.go | 151 ++++++++ pkg/core/event.go | 13 + protocol-description.md | 2 +- sdk/go/examples/validator_watcher/main.go | 133 +++++++ sdk/go/validator_watcher.go | 71 ++++ sdk/ts/src/blockchain/evm/index.ts | 1 + .../src/blockchain/evm/validator_watcher.ts | 165 +++++++++ sdk/ts/src/client.ts | 56 +++ sdk/ts/src/core/event.ts | 21 ++ .../public-api-drift.test.ts.snap | 12 + .../blockchain/evm/validator_watcher.test.ts | 342 ++++++++++++++++++ 13 files changed, 983 insertions(+), 1 deletion(-) create mode 100644 pkg/blockchain/evm/validator_watcher.go create mode 100644 sdk/go/examples/validator_watcher/main.go create mode 100644 sdk/go/validator_watcher.go create mode 100644 sdk/ts/src/blockchain/evm/validator_watcher.ts create mode 100644 sdk/ts/test/unit/blockchain/evm/validator_watcher.test.ts diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index 998d21378..d7f03546e 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -315,6 +315,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. diff --git a/docs/protocol/security-and-limitations.md b/docs/protocol/security-and-limitations.md index 962ebca2a..6a24e9d50 100644 --- a/docs/protocol/security-and-limitations.md +++ b/docs/protocol/security-and-limitations.md @@ -64,6 +64,7 @@ In the current protocol version, participants MUST trust nodes for: - **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/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 228961c0a..1777c9de5 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -66,6 +66,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/protocol-description.md b/protocol-description.md index 1cb97f2ae..3db7c7792 100644 --- a/protocol-description.md +++ b/protocol-description.md @@ -694,7 +694,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. 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/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/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 8656ceeb5..d277641a9 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -1768,6 +1768,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/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index 60e898137..102157e5b 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 @@ -1078,6 +1078,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", ], @@ -2432,6 +2433,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); + }); +}); From 07cc1dc95be5015d8c5e56aa8cdf1680b9606a9d Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Wed, 13 May 2026 16:14:46 +0530 Subject: [PATCH 13/26] MF-L04: fix(contracts): reject redundant native value (#741) ## summary fixes l-04 by rejecting redundant native value on channel challenge and escrow deposit paths that do not consume `msg.value`. ## root cause `challengeChannel()` and the home-chain branch of `initiateEscrowDeposit()` are payable, but some valid paths do not pull user funds. those calls could accept native value that was not credited to channel, escrow, node, or reclaim accounting. ## changes - validate `msg.value` from computed transition effects before applying channel or escrow deposit effects - reject non-zero value for same-version challenges and home-chain escrow deposit initiation - keep exact native value support for real native user-pull paths - tighten `_pullFunds()` so zero-amount pulls cannot bypass value validation - add unit tests for rejected surplus eth and valid exact native eth flows ## validation - `forge test --match-contract 'ChannelHubTest_(challenge|initiateEscrowDeposit)'` passed, 13 tests - `forge test` passed, 282 tests - `forge fmt --check src/ChannelHub.sol test/ChannelHub_units/ChannelHub_challenge.t.sol test/ChannelHub_units/ChannelHub_initiateEscrowDeposit.t.sol` passed - `git diff --check` passed - `slither . --filter-paths 'lib|test'` ran with Slither 0.11.5; it reports 83 findings on this branch and the same 83 findings on `fix/audit-findings-final`, so no new Slither finding was introduced by this PR. The reported categories are existing project-level warnings: arbitrary-send-eth, reentrancy-eth/events, return-bomb, timestamp, assembly, solc-version, low-level-calls, naming-convention, and too-many-digits. ## Summary by CodeRabbit ## Release Notes * **Bug Fixes** * Enhanced native ETH validation during channel challenges to ensure correct payment amounts are provided * Improved escrow deposit handling with stricter ETH value verification for both home-chain and non-home-chain payment scenarios * **Tests** * Added comprehensive test coverage for ETH payment validation across channel challenge and escrow deposit operations [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/layer-3/nitrolite/pull/741) --------- Co-authored-by: nksazonov --- contracts/src/ChannelHub.sol | 34 +++++--- .../ChannelHub_challenge.t.sol | 82 ++++++++++++++++++ .../ChannelHub_initiateEscrowDeposit.t.sol | 86 ++++++++++++++++++- 3 files changed, 189 insertions(+), 13 deletions(-) diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index df5cc2236..a504f8c88 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -633,8 +633,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); @@ -700,6 +701,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 { @@ -1149,17 +1151,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) { @@ -1200,6 +1204,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; @@ -1217,18 +1222,18 @@ contract ChannelHub is ReentrancyGuard { 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) @@ -1374,14 +1379,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); 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_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); + } } From 32bdc518d6ed726738b22cdb36bcd79a645aca5f Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 14:14:27 +0300 Subject: [PATCH 14/26] MF-H02: bind session key registration to a single owner per kind (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Block cross-wallet session-key shadow registration by adding `UNIQUE (session_key, kind)` to `current_session_key_states_v1` and rejecting collisions with a generic `invalid_session_key_state: session_key not allowed` (no ownership leak). - Require a `session_key_sig` co-signature from the session-key holder on every submit, so nobody can register a key they do not control. App side reuses the existing packed payload (already binds `user_address`); channel side gets a new `PackChannelSessionKeyOwnershipV1` that adds `user_address` to the ownership payload. - Reject self-collisions (`user_address == session_key`). ### Why Without these, an attacker could register the victim's session key under the attacker's wallet, outpace the victim's version, and have `GetAppSessionKeyOwner` (which resolved ownership by `MAX(version)` over `session_key` alone) return the attacker for the victim's signatures. Result: the victim's delegated quorum signatures got attributed to the attacker or rejected as non-participant. An attacker without the session-key private key never reaches the pointer lookup — they fail at `session_key_sig does not match session_key` and learn nothing about existing registrations. The generic `session_key not allowed` response is reachable only when the submitter can produce a valid possession proof, i.e. when they already control the key in question; surfacing existence to that caller is acceptable. ### Migration `20260508000000_session_key_ownership_constraints.sql` runs a pre-flight `RAISE EXCEPTION` on existing `(session_key, kind)` duplicates before adding the constraint. Any duplicates surface for ops review since they are exploitation evidence under the old behavior. ### SDK + tooling - `sdk/go`: `SignAppSessionKeyOwnership` / `SignChannelSessionKeyOwnership` helpers. - `sdk/ts`: `client.signAppSessionKeyOwnership` / `client.signChannelSessionKeyOwnership` + `packChannelSessionKeyOwnershipV1`. - `sdk/ts-compat`: facade methods exposed. - `cerebro` CLI: requires the session-key private key up-front and refuses to register a key it does not control. - `example-app`: enable + active-disable populate `session_key_sig`; arbitrary-key revoke surfaces a friendly error since the example app does not retain other keys' private keys. ### Known design implication `session_key_sig` is required on every submit, including revokes. A wallet that loses the session-key private key cannot revoke its own delegation early — it has to wait for `expires_at`. Documented inline in the SDK READMEs and example app. ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `go test ./...` - [x] `cd sdk/ts && npm run typecheck && npm test && npm run lint` - [x] `cd sdk/ts-compat && npm test && npm run lint` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Maharshi Mishra --- cerebro/commands.go | 76 +++++-- docs/api.yaml | 6 + docs/data_models.mmd | 3 + .../submit_session_key_state.go | 49 ++--- .../submit_session_key_state_test.go | 186 ++++++++++++++++-- nitronode/api/app_session_v1/utils.go | 2 + .../channel_v1/submit_session_key_state.go | 22 ++- .../submit_session_key_state_test.go | 185 +++++++++++++++-- nitronode/api/channel_v1/utils.go | 26 +-- ...0000_session_key_ownership_constraints.sql | 66 +++++++ .../store/database/app_session_key_state.go | 15 +- .../database/channel_session_key_state.go | 49 ++--- .../channel_session_key_state_test.go | 2 +- .../database/current_session_key_state.go | 101 ++++++---- .../current_session_key_state_test.go | 70 +++++++ nitronode/store/database/interface.go | 6 +- pkg/app/session_key_v1.go | 52 +++++ pkg/app/session_key_v1_test.go | 83 ++++++++ pkg/core/session_key.go | 63 ++++-- pkg/core/session_key_test.go | 126 +++++++++--- pkg/rpc/types.go | 4 + pkg/sign/eth_msg_signer.go | 8 +- sdk/go/README.md | 36 ++-- sdk/go/app_session.go | 46 ++++- sdk/go/channel.go | 49 ++++- sdk/go/examples/app_sessions/lifecycle.go | 9 + sdk/go/utils.go | 28 +-- sdk/ts-compat/README.md | 14 +- sdk/ts-compat/src/client.ts | 15 ++ .../public-api-drift.test.ts.snap | 2 + .../test/unit/public-api-drift.test.ts | 2 + sdk/ts/README.md | 65 +++--- sdk/ts/examples/app_sessions/lifecycle.ts | 10 +- sdk/ts/examples/example-app/README.md | 17 +- .../src/components/WalletDashboard.tsx | 42 +++- sdk/ts/src/app/types.ts | 2 + sdk/ts/src/client.ts | 79 +++++++- sdk/ts/src/core/utils.ts | 12 +- sdk/ts/src/rpc/types.ts | 2 + sdk/ts/src/session_key_state_transforms.ts | 2 + .../public-api-drift.test.ts.snap | 6 +- sdk/ts/test/unit/transform-drift.test.ts | 9 + 42 files changed, 1326 insertions(+), 321 deletions(-) create mode 100644 nitronode/config/migrations/postgres/20260508000000_session_key_ownership_constraints.sql diff --git a/cerebro/commands.go b/cerebro/commands.go index 7dcb3051e..09ea2e76a 100644 --- a/cerebro/commands.go +++ b/cerebro/commands.go @@ -1019,6 +1019,30 @@ func (o *Operator) createChannelSessionKey(ctx context.Context, sessionKeyAddr, version = existingStates[0].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) state := core.ChannelSessionKeyStateV1{ @@ -1037,6 +1061,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 +1080,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 @@ -1148,6 +1165,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 +1204,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/docs/api.yaml b/docs/api.yaml index eb42a763d..7168b17d8 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -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 diff --git a/docs/data_models.mmd b/docs/data_models.mmd index e997137b8..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 } @@ -230,6 +232,7 @@ classDiagram +smallint kind PK +numeric version +timestamptz updated_at + UNIQUE(session_key, kind) } %% ===== BLOCKCHAIN TABLES ===== 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 4015f2ad9..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,17 +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. @@ -50,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 @@ -70,37 +73,14 @@ 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), "") - 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), "") + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") 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 } @@ -111,6 +91,13 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // 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 { + 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) } 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 d99569bd5..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 @@ -21,7 +21,9 @@ import ( ) // 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 { @@ -46,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) { @@ -81,7 +89,7 @@ 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("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) @@ -226,7 +234,7 @@ 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("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) @@ -378,7 +386,7 @@ 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("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(0, nil) @@ -416,7 +424,7 @@ func TestSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, nil, nil, expiresAt, userSigner) + 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) @@ -456,7 +464,7 @@ func TestSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing.T) { } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, nil, nil, expiresAt, userSigner) + 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) @@ -496,7 +504,7 @@ 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) @@ -511,5 +519,147 @@ 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(), "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), + } + + 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 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/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/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index e1009c53f..c3c9f8c40 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -1,6 +1,8 @@ package channel_v1 import ( + "errors" + "strings" "time" "github.com/layer-3/nitrolite/nitronode/store/database" @@ -45,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 @@ -61,9 +68,13 @@ 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 } @@ -75,6 +86,13 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // 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 { + 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) } 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 b3d8356fc..c09706ba0 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -21,14 +21,18 @@ import ( ) // 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) @@ -37,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) { @@ -67,7 +77,7 @@ 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("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) @@ -145,7 +155,7 @@ 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("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) @@ -294,7 +304,7 @@ 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("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, nil) @@ -332,7 +342,7 @@ func TestChannelSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner) + 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) @@ -373,7 +383,7 @@ func TestChannelSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing 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) + 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) @@ -413,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) @@ -430,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/utils.go b/nitronode/api/channel_v1/utils.go index d7e85bb72..6f5e5508b 100644 --- a/nitronode/api/channel_v1/utils.go +++ b/nitronode/api/channel_v1/utils.go @@ -224,23 +224,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/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/store/database/app_session_key_state.go b/nitronode/store/database/app_session_key_state.go index f582be5af..b41955974 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 { @@ -239,6 +241,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/channel_session_key_state.go b/nitronode/store/database/channel_session_key_state.go index be04e0d39..52be03175 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 { @@ -186,11 +188,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 1ea89e7c4..0e5c56814 100644 --- a/nitronode/store/database/channel_session_key_state_test.go +++ b/nitronode/store/database/channel_session_key_state_test.go @@ -511,7 +511,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()) } diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go index 552c28dbe..2fd124b32 100644 --- a/nitronode/store/database/current_session_key_state.go +++ b/nitronode/store/database/current_session_key_state.go @@ -1,6 +1,7 @@ package database import ( + "errors" "fmt" "strings" "time" @@ -9,6 +10,11 @@ import ( "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 @@ -22,10 +28,15 @@ const ( // 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"` - Kind SessionKeyKind `gorm:"column:kind;primaryKey;type:smallint"` + 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"` } @@ -66,55 +77,63 @@ func upsertCurrentSessionKeyState(tx *gorm.DB, userAddress, sessionKey string, k return nil } -// LockSessionKeyState ensures a pointer row exists for (user, session_key, kind) and locks it -// for the duration of the surrounding transaction. Returns the current version (0 if newly -// created). Mirrors LockUserState. On non-postgres dialects, falls back to read-without-lock. +// 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) - if s.db.Dialector.Name() == "postgres" { - 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) - } + 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) + } - var locked CurrentSessionKeyStateV1 - err := s.db.Clauses(clause.Locking{Strength: "UPDATE"}). - Where("user_address = ? AND session_key = ? AND kind = ?", userAddress, sessionKey, kind). - First(&locked).Error - if err != nil { - return 0, fmt.Errorf("failed to lock current session key state: %w", err) - } - return locked.Version, nil + query := s.db.Where("session_key = ? AND kind = ?", sessionKey, kind) + if s.db.Dialector.Name() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) } - var existing CurrentSessionKeyStateV1 - err := s.db.Where("user_address = ? AND session_key = ? AND kind = ?", userAddress, sessionKey, kind). - First(&existing).Error + var locked CurrentSessionKeyStateV1 + err := query.First(&locked).Error if err != nil { - if err == gorm.ErrRecordNotFound { - seed := CurrentSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKey, - Kind: kind, - Version: 0, - UpdatedAt: time.Now().UTC(), - } - if err := s.db.Create(&seed).Error; err != nil { - return 0, fmt.Errorf("failed to create current session key state: %w", err) - } - return 0, 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 read current session key state: %w", err) + return 0, fmt.Errorf("failed to lock current session key state: %w", err) + } + + if !strings.EqualFold(locked.UserAddress, userAddress) { + return 0, ErrSessionKeyNotAllowed } - return existing.Version, nil + return locked.Version, nil } // CountSessionKeysForUser returns the number of distinct session keys recorded for the wallet diff --git a/nitronode/store/database/current_session_key_state_test.go b/nitronode/store/database/current_session_key_state_test.go index 234a49b86..91d15cb9e 100644 --- a/nitronode/store/database/current_session_key_state_test.go +++ b/nitronode/store/database/current_session_key_state_test.go @@ -1,6 +1,7 @@ package database import ( + "errors" "testing" "time" @@ -14,6 +15,48 @@ 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) @@ -73,6 +116,33 @@ func TestDBStore_LockSessionKeyState(t *testing.T) { 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() diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 26898a307..aec32ac7a 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -174,8 +174,10 @@ type DatabaseStore interface { // --- Session Key State Pointer Operations --- - // LockSessionKeyState ensures the (user, session_key, kind) pointer row exists and locks - // it for the surrounding transaction. Returns the current version (0 if newly created). + // 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 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/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/rpc/types.go b/pkg/rpc/types.go index 2291302d0..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"` } // ============================================================================ 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/sdk/go/README.md b/sdk/go/README.md index d021a3718..053203c82 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -68,15 +68,17 @@ 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.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 active app session key states ``` ### Session Keys — Channels ```go -client.SignChannelSessionKeyState(state) // Sign a channel session key state -client.SubmitChannelSessionKeyState(ctx, state) // Register/update channel session key +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 active channel session key states ``` @@ -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,9 +437,9 @@ 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 states, err := client.GetLastAppKeyStates(ctx, userAddress, nil) @@ -442,7 +451,10 @@ states, err := client.GetLastAppKeyStates(ctx, userAddress, &sdk.GetLastKeyState ### 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,9 +462,9 @@ 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 states, err := client.GetLastChannelKeyStates(ctx, userAddress, nil) diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index cd91b852e..79b9d24fc 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{ @@ -355,12 +359,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 +381,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 +398,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..68f90a18c 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -851,7 +851,9 @@ type GetLastChannelKeyStatesOptions struct { } // 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 +869,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{ @@ -918,12 +921,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 +942,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 +969,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/utils.go b/sdk/go/utils.go index 88fc8bb14..2835a853f 100644 --- a/sdk/go/utils.go +++ b/sdk/go/utils.go @@ -513,12 +513,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 +541,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 +578,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 +612,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/ts-compat/README.md b/sdk/ts-compat/README.md index ca2fc28f1..bafbd8f41 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -178,13 +178,19 @@ 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 | +| `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?)` | 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 | +| `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) | | `getLastKeyStates(userAddress, sessionKey?)` | Fetch app-session key states for wallet/key | ### Transfers diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index f4ba1cfc5..54e891c6c 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'; @@ -859,6 +860,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); } @@ -874,6 +882,13 @@ export class NitroliteClient { 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); } 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..d43aa0499 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 @@ -567,6 +567,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; }>", 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..12f88e152 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -197,9 +197,11 @@ 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('getLastKeyStates:'), ]) diff --git a/sdk/ts/README.md b/sdk/ts/README.md index b43cfd078..b3f744362 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.getLastKeyStates(userAddress, sessionKey?) // Get active app session key states ``` ### 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?) // Get active channel session key states ``` ### Shared Utilities @@ -492,27 +494,29 @@ 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...'); @@ -522,24 +526,23 @@ const filtered = await client.getLastKeyStates('0x1234...', '0xSessionKey...'); ### 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 const states = await client.getLastChannelKeyStates('0x1234...'); 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/client.ts b/sdk/ts/src/client.ts index d277641a9..449d5ee9a 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) @@ -1657,13 +1657,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) @@ -1676,9 +1679,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 */ @@ -1720,9 +1758,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 { @@ -1731,9 +1771,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 */ 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/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/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index 102157e5b..d151c9874 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", @@ -783,6 +784,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", @@ -1068,6 +1070,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", @@ -1357,7 +1361,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}\`", ], }, { diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts index 9dcba41a1..120821a56 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( From 0cbb9f8023a43d28dd94445bc3890bcdf39ac2f2 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Wed, 13 May 2026 18:32:02 +0530 Subject: [PATCH 15/26] MF-I07: fix(contracts): enforce max challenge duration (#752) ## summary - enforce the 7-day max challenge duration in `ChannelHub` - fail NitroNode startup when configured challenge bounds are looser than the contract - update the checked-in TS ChannelHub ABI ## tests - `cd contracts && forge test` - `go test ./...` - `cd sdk/ts && npm run typecheck && npm run build:ci` --- contracts/src/ChannelHub.sol | 6 +- .../ChannelHub_createChannel.t.sol | 39 +++++++++++ nitronode/runtime.go | 28 ++++++++ nitronode/runtime_config_test.go | 70 +++++++++++++++++++ pkg/core/utils.go | 5 ++ sdk/ts/src/blockchain/evm/channel_hub_abi.ts | 13 ++++ 6 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 nitronode/runtime_config_test.go diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index a504f8c88..2645008d7 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -164,6 +164,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; @@ -1344,7 +1345,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. 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/nitronode/runtime.go b/nitronode/runtime.go index 94332e7f5..2daf1b82f 100644 --- a/nitronode/runtime.go +++ b/nitronode/runtime.go @@ -106,6 +106,31 @@ type ValidationLimits struct { 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. func InitBackbone() *Backbone { closers := []func() error{} // collect closer functions for resources that need cleanup @@ -126,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) 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/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/sdk/ts/src/blockchain/evm/channel_hub_abi.ts b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts index b6d6c06d7..03540f490 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', From aaac7903a2c9256552759ca8c508bc601b80dd9a Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 16:25:06 +0300 Subject: [PATCH 16/26] MF-M08: fix(rpc): replace Origin label with application_id on connection gauge (#745) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace the client-controlled `Origin` header label on `rpc_connections_active` with `application_id`, sourced from the `app_id` query parameter validated at WebSocket upgrade. Unauthenticated remotes can no longer drive unbounded Prometheus label cardinality by sending unique `Origin` headers. - Track per-application connection counts inside `ConnectionHub` and call `DeleteLabelValues` once a bucket reaches zero, so series for disconnected applications are shed instead of lingering as zero-valued gauges. - Expose `ApplicationID()` on the `rpc.Connection` interface (alongside the existing `Origin()`) so the hub can label without re-parsing the request. The raw `Origin` field is retained for logging only. ## Test plan - [x] `go build ./...` - [x] `go vet ./...` - [x] `go test ./pkg/rpc/... ./nitronode/metrics/...` - [ ] Verify `rpc_connections_active{application_id="..."}` on a running node and that the series disappears after the last connection for that app closes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit ## Release Notes * **New Features** * Session key pagination with 10-item page limit for improved query performance. * Per-user session key limits (default 100) to prevent abuse. * WebSocket DoS hardening with inbound frame size caps and per-connection byte-rate limiting. * Protocol drift validation in CI ensures RPC, ABI, and signing consistency across code layers. * **Documentation** * Expanded token compatibility requirements; tokens without ERC-20 `decimals()` are rejected. * Clarified on-chain signature domain behavior (EIP-191 vs raw ECDSA). * Protocol drift guardrails and trust assumptions documented. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/layer-3/nitrolite/pull/745) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/metrics/exporter.go | 25 ++++++++++----- nitronode/metrics/interface.go | 4 +-- pkg/rpc/connection.go | 16 ++++++++++ pkg/rpc/connection_hub.go | 56 +++++++++++++++++++++------------- pkg/rpc/node.go | 3 +- 5 files changed, 73 insertions(+), 31 deletions(-) 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/pkg/rpc/connection.go b/pkg/rpc/connection.go index 1a86e249a..e213da262 100644 --- a/pkg/rpc/connection.go +++ b/pkg/rpc/connection.go @@ -44,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 @@ -104,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 @@ -140,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 @@ -212,6 +221,7 @@ func NewWebsocketConnection(config WebsocketConnectionConfig) (*WebsocketConnect return &WebsocketConnection{ connectionID: config.ConnectionID, origin: config.Origin, + applicationID: config.ApplicationID, websocketConn: config.WebsocketConn, writeTimeout: config.WriteTimeout, pingInterval: config.PingInterval, @@ -321,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 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/node.go b/pkg/rpc/node.go index 3964a1b27..55c8f6a32 100644 --- a/pkg/rpc/node.go +++ b/pkg/rpc/node.go @@ -157,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 @@ -236,6 +236,7 @@ func (wn *WebsocketNode) ServeHTTP(w http.ResponseWriter, r *http.Request) { connConfig := WebsocketConnectionConfig{ ConnectionID: connectionID, Origin: r.Header.Get("Origin"), + ApplicationID: applicationID, WebsocketConn: wsConnection, Logger: wn.cfg.Logger, ProcessBufferSize: wn.cfg.WsConnProcessBufferSize, From 38d00d3f24fa47334ed32b4e4cdaf4b503d6ce59 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 13 May 2026 16:01:07 +0200 Subject: [PATCH 17/26] MF-C02: fix(core): add ChannelStatusClosing to gate post-finalize state transitions (#746) --- docs/api.yaml | 2 +- nitronode/api/channel_v1/get_channels.go | 2 + nitronode/api/channel_v1/get_home_channel.go | 2 +- .../api/channel_v1/get_home_channel_test.go | 47 ++++- nitronode/api/channel_v1/interface.go | 13 ++ nitronode/api/channel_v1/request_creation.go | 11 + .../api/channel_v1/request_creation_test.go | 98 +++++++++ nitronode/api/channel_v1/submit_state.go | 16 ++ nitronode/api/channel_v1/submit_state_test.go | 93 ++++++++ nitronode/api/channel_v1/testing.go | 18 ++ nitronode/api/channel_v1/utils.go | 2 + .../20251222000000_initial_schema.sql | 2 +- ...60513000000_add_channel_status_closing.sql | 20 ++ nitronode/event_handlers/service.go | 3 + nitronode/event_handlers/service_test.go | 44 ++++ nitronode/store/database/channel.go | 35 ++++ nitronode/store/database/channel_test.go | 198 ++++++++++++++++++ nitronode/store/database/interface.go | 10 + pkg/core/types.go | 7 +- sdk/go/utils.go | 2 + sdk/ts-compat/README.md | 2 +- sdk/ts-compat/src/client.ts | 3 +- sdk/ts-compat/src/types.ts | 1 + .../public-api-drift.test.ts.snap | 1 + .../test/unit/client-mapping.test.ts | 32 +++ sdk/ts/src/core/types.ts | 3 +- sdk/ts/src/utils.ts | 2 + 27 files changed, 659 insertions(+), 10 deletions(-) create mode 100644 nitronode/config/migrations/postgres/20260513000000_add_channel_status_closing.sql diff --git a/docs/api.yaml b/docs/api.yaml index 7168b17d8..f1d3dcab9 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 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/interface.go b/nitronode/api/channel_v1/interface.go index 9984c71df..185bc3269 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -72,6 +72,19 @@ 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) 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_state.go b/nitronode/api/channel_v1/submit_state.go index c082c6b7e..b5fa1e349 100644 --- a/nitronode/api/channel_v1/submit_state.go +++ b/nitronode/api/channel_v1/submit_state.go @@ -235,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 45ed7fae5..52ba52dca 100644 --- a/nitronode/api/channel_v1/submit_state_test.go +++ b/nitronode/api/channel_v1/submit_state_test.go @@ -1475,6 +1475,14 @@ func TestSubmitState_Finalize_Success(t *testing.T) { 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 && @@ -1841,6 +1849,91 @@ func TestSubmitState_EscrowLock_VoidHomeChannel_Rejected(t *testing.T) { 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 29cd00292..8c562860d 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -97,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 { diff --git a/nitronode/api/channel_v1/utils.go b/nitronode/api/channel_v1/utils.go index 6f5e5508b..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: 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/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 60b8142c1..31121073d 100644 --- a/nitronode/event_handlers/service.go +++ b/nitronode/event_handlers/service.go @@ -154,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 diff --git a/nitronode/event_handlers/service_test.go b/nitronode/event_handlers/service_test.go index 3f88d83c5..e0e5c7990 100644 --- a/nitronode/event_handlers/service_test.go +++ b/nitronode/event_handlers/service_test.go @@ -236,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) diff --git a/nitronode/store/database/channel.go b/nitronode/store/database/channel.go index 57b43185f..33a94c45e 100644 --- a/nitronode/store/database/channel.go +++ b/nitronode/store/database/channel.go @@ -99,6 +99,41 @@ func (s *DBStore) GetActiveHomeChannel(wallet, asset string) (*core.Channel, err return databaseChannelToCore(&dbChannel), nil } +// 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 diff --git a/nitronode/store/database/channel_test.go b/nitronode/store/database/channel_test.go index a531f2732..2b72cf921 100644 --- a/nitronode/store/database/channel_test.go +++ b/nitronode/store/database/channel_test.go @@ -377,6 +377,70 @@ func TestDBStore_GetActiveHomeChannel(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) @@ -533,6 +597,44 @@ func TestDBStore_CheckActiveChannel(t *testing.T) { 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) { @@ -907,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/interface.go b/nitronode/store/database/interface.go index aec32ac7a..df0a4660f 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -49,6 +49,12 @@ type DatabaseStore interface { // "Active" includes both Void (DB-only) and Open (materialized onchain). GetActiveHomeChannel(wallet, asset string) (*core.Channel, 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) @@ -56,6 +62,10 @@ type DatabaseStore interface { // 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 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/sdk/go/utils.go b/sdk/go/utils.go index 2835a853f..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 } diff --git a/sdk/ts-compat/README.md b/sdk/ts-compat/README.md index bafbd8f41..ba129b4a7 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -463,7 +463,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 54e891c6c..f0fe1f7b5 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -598,7 +598,8 @@ export class NitroliteClient { 0: 'void', 1: 'open', 2: 'challenged', - 3: 'closed', + 3: 'closing', + 4: 'closed', }; async getChannels(): Promise { 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 d43aa0499..8934ea3ed 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 @@ -823,6 +823,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..b545c339d 100644 --- a/sdk/ts-compat/test/unit/client-mapping.test.ts +++ b/sdk/ts-compat/test/unit/client-mapping.test.ts @@ -288,6 +288,38 @@ 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('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/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/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: From 36c9832603adbd8945856e633e6f9929dbc07dd0 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 13 May 2026 16:48:44 +0200 Subject: [PATCH 18/26] MF-L06: fix(contracts): clear stale challengeExpireAt on cooperative escrow finalization (#754) --- contracts/src/ChannelHub.sol | 4 +- .../ChannelHub_finalizeEscrowDeposit.t.sol | 92 ++++++++++++++++++- .../ChannelHub_finalizeEscrowWithdrawal.t.sol | 92 ++++++++++++++++++- 3 files changed, 184 insertions(+), 4 deletions(-) diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index 2645008d7..3f2f4499e 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -1219,7 +1219,7 @@ contract ChannelHub is ReentrancyGuard { meta.unlockAt = effects.newUnlockAt; } - if (effects.newChallengeExpiry > 0) { + if (meta.challengeExpireAt != effects.newChallengeExpiry) { meta.challengeExpireAt = effects.newChallengeExpiry; } @@ -1274,7 +1274,7 @@ contract ChannelHub is ReentrancyGuard { _initEscrowWithdrawalMetadata(escrowId, channelId, candidate, user, approvedSignatureValidators); } - if (effects.newChallengeExpiry > 0) { + if (meta.challengeExpireAt != effects.newChallengeExpiry) { meta.challengeExpireAt = effects.newChallengeExpiry; } diff --git a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol index 57338a52d..fc75bc98e 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol @@ -6,7 +6,15 @@ 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 {EscrowDepositEngine} from "../../src/EscrowDepositEngine.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 +72,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..71046d512 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol @@ -6,7 +6,15 @@ 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 {EscrowWithdrawalEngine} from "../../src/EscrowWithdrawalEngine.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 +72,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({ From 29641e88c30023da47f58af21d24676453d2e437 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 13 May 2026 16:49:03 +0200 Subject: [PATCH 19/26] MF-I08: docs: document ChannelClosed event orientation ambiguity during abandoned migration (#755) --- contracts/SECURITY.md | 19 +++++++++++++++++++ protocol-description.md | 16 +++++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index d7f03546e..87be3311a 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -171,6 +171,25 @@ 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. diff --git a/protocol-description.md b/protocol-description.md index 3db7c7792..656c77051 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 From 97f6f8bcb9aeda499550a9d35f5127976e9f2dc4 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 18:11:51 +0300 Subject: [PATCH 20/26] MF-M09: fix(nitronode): auto-challenge home channel on withheld escrow finalize (#753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - When `EscrowDepositChallenged` fires without a newer fully-signed `FINALIZE_ESCROW_DEPOSIT` available locally, the node now schedules a `challengeChannel(...)` on the home blockchain using the `INITIATE_ESCROW_DEPOSIT` state and a node-produced challenger signature. - Closes the cross-chain attack where a user lets the non-home escrow challenge expire, recovers escrow-chain funds, and then enforces FINALIZE on home against the node's locked allocation. - Introduces generic `ActionTypeChallenge = 2` + `ScheduleChallenge(stateID, chainID)`. `BlockchainWorker` packs via `core.PackChallengeState`, signs with the node `ChannelSigner`, and submits via `BlockchainClient.Challenge(state, sig, ChannelParticipantNode)`. ## Behavior change in `HandleEscrowDepositChallenged` - Branch A (newer signed FINALIZE exists) — unchanged, still schedules `FinalizeEscrowDeposit` on the escrow chain. - Branch B (no newer FINALIZE, or last signed state is the INITIATE itself) — fetches the INITIATE state via `GetStateByChannelIDAndVersion`, resolves its home channel, and schedules `Challenge` on the home blockchain. Skips with a warning if the INITIATE state is missing locally, has no `HomeChannelID`, or the home channel is not `Open`. - After the home challenge timer expires, operator runs `closeChannel(...)` manually to recover the node allocation (existing manual recovery path). ## Test plan - [x] `go test ./nitronode/event_handlers/... -run TestHandleEscrowDepositChallenged -count=1` - [x] `go test ./nitronode/... ./pkg/... -count=1` (full suite green) - [x] `go build ./...` - [x] `go vet ./...` - [ ] Manual integration: user withholds FINALIZE state, observe home channel transitions to DISPUTED on-chain after escrow challenge. 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit * **New Features** * Added validator registration event monitoring with recovery support on reconnection. * Implemented pagination (default/max page size 10) for key state query endpoints. * Added WebSocket per-connection rate limiting and maximum message size enforcement. * **Documentation** * Clarified signature domain compatibility between on-chain and off-chain validators. * Updated protocol enforcement rules for channel state submission requirements. * Added validator registration monitoring and ERC-20 approval security guidance. * **Configuration** * Added configurable WebSocket DoS hardening environment variables. * Made database SSL mode configurable (default: `require`). * **Bug Fixes & Improvements** * Enhanced escrow operation locking and user signature persistence. * Enforced per-user session key caps for improved resource management. [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/layer-3/nitrolite/pull/753) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- nitronode/blockchain_worker.go | 51 ++- nitronode/event_handlers/service.go | 77 +++- nitronode/event_handlers/service_test.go | 389 ++++++++++++++++++ nitronode/event_handlers/testing.go | 6 + nitronode/main.go | 7 +- nitronode/store/database/blockchain_action.go | 10 + nitronode/store/database/interface.go | 4 + pkg/blockchain/evm/channel_hub_reactor.go | 4 + .../evm/channel_hub_reactor_test.go | 5 + pkg/core/interface.go | 4 + 10 files changed, 537 insertions(+), 20 deletions(-) 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/event_handlers/service.go b/nitronode/event_handlers/service.go index 31121073d..4bd920a1b 100644 --- a/nitronode/event_handlers/service.go +++ b/nitronode/event_handlers/service.go @@ -269,9 +269,13 @@ func (s *EventHandlerService) HandleEscrowDepositInitiated(ctx context.Context, } // 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 @@ -307,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 { @@ -319,6 +319,10 @@ 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 { @@ -329,6 +333,63 @@ func (s *EventHandlerService) HandleEscrowDepositChallenged(ctx context.Context, 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. diff --git a/nitronode/event_handlers/service_test.go b/nitronode/event_handlers/service_test.go index e0e5c7990..0c16c1b72 100644 --- a/nitronode/event_handlers/service_test.go +++ b/nitronode/event_handlers/service_test.go @@ -433,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) diff --git a/nitronode/event_handlers/testing.go b/nitronode/event_handlers/testing.go index f416405c2..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) diff --git a/nitronode/main.go b/nitronode/main.go index 568cc6cca..185ef6e4b 100644 --- a/nitronode/main.go +++ b/nitronode/main.go @@ -79,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 { @@ -126,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/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/interface.go b/nitronode/store/database/interface.go index df0a4660f..831945961 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -112,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 diff --git a/pkg/blockchain/evm/channel_hub_reactor.go b/pkg/blockchain/evm/channel_hub_reactor.go index d143d35ba..c9eab1e3e 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 diff --git a/pkg/blockchain/evm/channel_hub_reactor_test.go b/pkg/blockchain/evm/channel_hub_reactor_test.go index e8091b5c4..29109f686 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) diff --git a/pkg/core/interface.go b/pkg/core/interface.go index 3699fc496..c673f1ff5 100644 --- a/pkg/core/interface.go +++ b/pkg/core/interface.go @@ -122,6 +122,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 From 7aa82659985ab6b903cbe78587e89ee64505bdca Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 18:12:20 +0300 Subject: [PATCH 21/26] MF-L09: fix(nitronode): validate parsed app session nonce (#751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `CreateAppSession` raw-string nonce check (`Nonce == "" || Nonce == "0"`) had a dead branch and a bypass: `unmapAppDefinitionV1` errors out on empty input before the check, and `strconv.ParseUint` accepts zero-padded inputs (`"00"`, `"000"`, ...) that parse to `0` and skip the `== "0"` comparison. Net result: an app session could be stored with `Nonce = 0`. - Replaced the raw-string check with `appDef.Nonce == 0` so the validation runs against the parsed numeric value. - Extended `TestCreateAppSession_ZeroNonce` into a table-driven test covering `"0"`, `"00"`, `"000"`. ## Test plan - [x] `go test ./nitronode/api/app_session_v1/...` - [x] `go vet ./nitronode/api/app_session_v1/...` 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .../api/app_session_v1/create_app_session.go | 4 +- .../app_session_v1/create_app_session_test.go | 112 +++++++++--------- 2 files changed, 59 insertions(+), 57 deletions(-) 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 7bd3186e2..db4681c14 100644 --- a/nitronode/api/app_session_v1/create_app_session_test.go +++ b/nitronode/api/app_session_v1/create_app_session_test.go @@ -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, 100, - ) - - // 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) { From 2587d995c6627e3332e361332aa2828832fa98da Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 13 May 2026 17:14:31 +0200 Subject: [PATCH 22/26] MF-L05: docs(contracts): document informational events not guaranteed to emit (#756) --- contracts/SECURITY.md | 21 +++++++++++++++++++++ contracts/src/ChannelHub.sol | 28 ++++++++++++++++++++++++++++ protocol-description.md | 19 +++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index 87be3311a..761b1ad92 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -630,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 3f2f4499e..5c25e56e1 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -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); diff --git a/protocol-description.md b/protocol-description.md index 656c77051..698979e94 100644 --- a/protocol-description.md +++ b/protocol-description.md @@ -724,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**. From 0e4612fd956a89a60e8f731bfbbe72e00e501903 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 18:48:02 +0300 Subject: [PATCH 23/26] MF-L08: fix(nitronode/api): default get_last_key_states to active-only with include_inactive opt-in (#749) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `get_last_key_states` on both `channels.v1` and `app_sessions.v1` returned the latest version per session key with no `expires_at` filter, so expired latest states surfaced as "active" — contradicting endpoint name and docs (auth itself was unaffected because `GetAppSessionKeyOwner` / `ValidateChannelSessionKeyForAsset` apply their own expiry filter). - Add `include_inactive` request flag, defaulting `false`. With the default the store filters `expires_at > now` on both the list and the count using a single `now` binding so pagination stays consistent. Setting it to `true` returns all latest states (expired or revoked) for ops/debug. - Cerebro version-detection caller opts in to `include_inactive=true` so post-expiry rotation still observes the prior version and the server-side monotonic pointer is not violated. ## Out of scope (follow-up PR) True revocation semantics (allowing `expires_at` in the past at submit time, freeing the per-user cap slot for revoked keys, log on revoke, doc pass for re-activation) ship on a separate branch. This PR is purely the API correctness fix; the flag is forward-compatible with that work. ## Test plan - [x] `go vet ./...` - [x] `go test ./...` (full suite green, incl. real-DB tests in `nitronode/store/database`) - [ ] CI: `test-go.yml` - [ ] Manual: hit `channels.v1.get_last_key_states` / `app_sessions.v1.get_last_key_states` with and without `include_inactive` against a node carrying a mix of expired and active keys 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit ## Release Notes * **New Features** * Added validator registration event monitoring for enhanced security visibility * Added pagination and filtering support for session key state queries * Enhanced WebSocket DoS hardening with frame-size limits and rate limiting * **Bug Fixes** * Fixed ERC20 transfer handling for oversized return data * Improved concurrent session key submission reliability * Fixed signature validation and state enforcement path validation * **Documentation** * Updated security model with trust assumptions and validator responsibilities * Clarified on-chain protocol enforcement conditions and constraints * Added comprehensive validator monitoring guidance [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/layer-3/nitrolite/pull/749) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- cerebro/commands.go | 22 ++- docs/api.yaml | 16 +- .../api/app_session_v1/get_last_key_states.go | 5 +- .../get_last_key_states_test.go | 42 ++++- nitronode/api/app_session_v1/interface.go | 6 +- nitronode/api/app_session_v1/testing.go | 4 +- .../api/channel_v1/get_last_key_states.go | 5 +- .../channel_v1/get_last_key_states_test.go | 42 ++++- nitronode/api/channel_v1/interface.go | 6 +- nitronode/api/channel_v1/testing.go | 4 +- .../store/database/app_session_key_state.go | 21 ++- .../database/app_session_key_state_test.go | 156 +++++++++++++++- .../database/channel_session_key_state.go | 21 ++- .../channel_session_key_state_test.go | 170 ++++++++++++++++-- nitronode/store/database/interface.go | 14 +- pkg/rpc/api.go | 14 +- sdk/go/README.md | 22 ++- sdk/go/app_session.go | 12 +- sdk/go/channel.go | 10 +- sdk/ts-compat/README.md | 5 +- sdk/ts-compat/src/client.ts | 16 +- .../public-api-drift.test.ts.snap | 3 +- .../test/unit/client-mapping.test.ts | 37 ++++ .../test/unit/public-api-drift.test.ts | 1 + sdk/ts/README.md | 18 +- sdk/ts/src/client.ts | 26 ++- sdk/ts/src/rpc/api.ts | 10 ++ .../public-api-drift.test.ts.snap | 6 +- sdk/ts/test/unit/rpc-drift.test.ts | 2 +- sdk/ts/test/unit/transform-drift.test.ts | 55 +++++- 30 files changed, 671 insertions(+), 100 deletions(-) diff --git a/cerebro/commands.go b/cerebro/commands.go index 09ea2e76a..0cfa03d34 100644 --- a/cerebro/commands.go +++ b/cerebro/commands.go @@ -1010,13 +1010,21 @@ 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 @@ -1152,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 { diff --git a/docs/api.yaml b/docs/api.yaml index f1d3dcab9..d87b2c12c 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -645,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. Mandatory pagination caps response size (max page size 10). + 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 @@ -654,6 +654,10 @@ 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. @@ -663,7 +667,7 @@ api: type: array items: type: channel_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 @@ -840,7 +844,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. Mandatory pagination caps response size (max page size 10). + 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 @@ -849,6 +853,10 @@ 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. @@ -858,7 +866,7 @@ api: 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 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 0bc40e916..997ebca90 100644 --- a/nitronode/api/app_session_v1/get_last_key_states.go +++ b/nitronode/api/app_session_v1/get_last_key_states.go @@ -44,9 +44,12 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { limit = rpc.GetLastKeyStatesPageLimit } + includeInactive := req.IncludeInactive != nil && *req.IncludeInactive + logger.Debug("retrieving session key states", "userAddress", req.UserAddress, "sessionKey", req.SessionKey, + "includeInactive", includeInactive, "limit", limit, "offset", offset) @@ -55,7 +58,7 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { err := h.useStoreInTx(func(tx Store) error { var err error - states, totalCount, err = tx.GetLastAppSessionKeyStates(req.UserAddress, req.SessionKey, limit, offset) + states, totalCount, err = tx.GetLastAppSessionKeyStates(req.UserAddress, req.SessionKey, includeInactive, limit, offset) return err }) 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 index 472c10092..89b758e95 100644 --- a/nitronode/api/app_session_v1/get_last_key_states_test.go +++ b/nitronode/api/app_session_v1/get_last_key_states_test.go @@ -46,7 +46,7 @@ func TestGetLastKeyStates_DefaultsToPageOneOnEmptyResult(t *testing.T) { mockStore := new(MockStore) h := newGetLastKeyStatesHandler(mockStore) - mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), uint32(10), uint32(0)). + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(0)). Return([]app.AppSessionKeyStateV1{}, 0, nil) c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{UserAddress: "0xuser"}) @@ -68,7 +68,7 @@ func TestGetLastKeyStates_PaginationMetadata_AlignedOffset(t *testing.T) { offset := uint32(10) pagination := &rpc.PaginationParamsV1{Limit: &limit, Offset: &offset} - mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), uint32(10), uint32(10)). + 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) @@ -91,7 +91,7 @@ func TestGetLastKeyStates_ClampsLimitToMax(t *testing.T) { excessive := uint32(1000) pagination := &rpc.PaginationParamsV1{Limit: &excessive} - mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), rpc.GetLastKeyStatesPageLimit, uint32(0)). + mockStore.On("GetLastAppSessionKeyStates", "0xuser", (*string)(nil), false, rpc.GetLastKeyStatesPageLimit, uint32(0)). Return([]app.AppSessionKeyStateV1{}, 0, nil) c := callGetLastKeyStates(t, h, rpc.AppSessionsV1GetLastKeyStatesRequest{ @@ -119,7 +119,7 @@ func TestGetLastKeyStates_RejectsSortField(t *testing.T) { 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) + mockStore.AssertNotCalled(t, "GetLastAppSessionKeyStates", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) } func TestGetLastKeyStates_RequiresUserAddress(t *testing.T) { @@ -132,3 +132,37 @@ func TestGetLastKeyStates_RequiresUserAddress(t *testing.T) { 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/interface.go b/nitronode/api/app_session_v1/interface.go index 5ee8e91bc..cb9948abc 100644 --- a/nitronode/api/app_session_v1/interface.go +++ b/nitronode/api/app_session_v1/interface.go @@ -56,7 +56,11 @@ type Store interface { CountSessionKeysForUser(userAddress string) (uint32, error) StoreAppSessionKeyState(state app.AppSessionKeyStateV1) error GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint64, error) - GetLastAppSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, 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/testing.go b/nitronode/api/app_session_v1/testing.go index 7f7e7bd82..a0770bdda 100644 --- a/nitronode/api/app_session_v1/testing.go +++ b/nitronode/api/app_session_v1/testing.go @@ -138,8 +138,8 @@ func (m *MockStore) GetLastAppSessionKeyVersion(wallet, sessionKey string) (uint return args.Get(0).(uint64), args.Error(1) } -func (m *MockStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) { - args := m.Called(wallet, sessionKey, limit, offset) +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, uint32(args.Int(1)), args.Error(2) } diff --git a/nitronode/api/channel_v1/get_last_key_states.go b/nitronode/api/channel_v1/get_last_key_states.go index 3cc748948..69288cce6 100644 --- a/nitronode/api/channel_v1/get_last_key_states.go +++ b/nitronode/api/channel_v1/get_last_key_states.go @@ -44,9 +44,12 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { limit = rpc.GetLastKeyStatesPageLimit } + includeInactive := req.IncludeInactive != nil && *req.IncludeInactive + logger.Debug("retrieving channel session key states", "userAddress", req.UserAddress, "sessionKey", req.SessionKey, + "includeInactive", includeInactive, "limit", limit, "offset", offset) @@ -55,7 +58,7 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { err := h.useStoreInTx(func(tx Store) error { var err error - states, totalCount, err = tx.GetLastChannelSessionKeyStates(req.UserAddress, req.SessionKey, limit, offset) + states, totalCount, err = tx.GetLastChannelSessionKeyStates(req.UserAddress, req.SessionKey, includeInactive, limit, offset) return err }) diff --git a/nitronode/api/channel_v1/get_last_key_states_test.go b/nitronode/api/channel_v1/get_last_key_states_test.go index 914f13540..d4cd37034 100644 --- a/nitronode/api/channel_v1/get_last_key_states_test.go +++ b/nitronode/api/channel_v1/get_last_key_states_test.go @@ -46,7 +46,7 @@ func TestChannelGetLastKeyStates_DefaultsToPageOneOnEmptyResult(t *testing.T) { mockStore := new(MockStore) h := newGetLastKeyStatesHandler(mockStore) - mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), uint32(10), uint32(0)). + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), false, uint32(10), uint32(0)). Return([]core.ChannelSessionKeyStateV1{}, 0, nil) c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{UserAddress: "0xuser"}) @@ -67,7 +67,7 @@ func TestChannelGetLastKeyStates_PaginationMetadata_AlignedOffset(t *testing.T) offset := uint32(10) pagination := &rpc.PaginationParamsV1{Limit: &limit, Offset: &offset} - mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), uint32(10), uint32(10)). + 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) @@ -90,7 +90,7 @@ func TestChannelGetLastKeyStates_ClampsLimitToMax(t *testing.T) { excessive := uint32(1000) pagination := &rpc.PaginationParamsV1{Limit: &excessive} - mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), rpc.GetLastKeyStatesPageLimit, uint32(0)). + mockStore.On("GetLastChannelSessionKeyStates", "0xuser", (*string)(nil), false, rpc.GetLastKeyStatesPageLimit, uint32(0)). Return([]core.ChannelSessionKeyStateV1{}, 0, nil) c := callGetLastKeyStates(t, h, rpc.ChannelsV1GetLastKeyStatesRequest{ @@ -118,7 +118,7 @@ func TestChannelGetLastKeyStates_RejectsSortField(t *testing.T) { 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) + mockStore.AssertNotCalled(t, "GetLastChannelSessionKeyStates", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything) } func TestChannelGetLastKeyStates_RequiresUserAddress(t *testing.T) { @@ -131,3 +131,37 @@ func TestChannelGetLastKeyStates_RequiresUserAddress(t *testing.T) { 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/interface.go b/nitronode/api/channel_v1/interface.go index 185bc3269..37e8dd0f1 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -106,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. Results are paginated. - GetLastChannelSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, 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/testing.go b/nitronode/api/channel_v1/testing.go index 8c562860d..45ff79d87 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -143,8 +143,8 @@ func (m *MockStore) GetLastChannelSessionKeyVersion(wallet, sessionKey string) ( return args.Get(0).(uint64), args.Error(1) } -func (m *MockStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) { - args := m.Called(wallet, sessionKey, limit, offset) +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, uint32(args.Int(1)), args.Error(2) } diff --git a/nitronode/store/database/app_session_key_state.go b/nitronode/store/database/app_session_key_state.go index b41955974..1c693fd40 100644 --- a/nitronode/store/database/app_session_key_state.go +++ b/nitronode/store/database/app_session_key_state.go @@ -109,14 +109,22 @@ func (s *DBStore) StoreAppSessionKeyState(state 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. -// Results are paginated; totalCount is the unpaginated total of matching session keys. -func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) { +// 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() - pointerQuery := s.db.Model(&CurrentSessionKeyStateV1{}). - Where("user_address = ? AND kind = ? AND version > 0", wallet, SessionKeyKindAppSession) + 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 != "" { - pointerQuery = pointerQuery.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) } var totalCount int64 @@ -135,6 +143,9 @@ func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, 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 { diff --git a/nitronode/store/database/app_session_key_state_test.go b/nitronode/store/database/app_session_key_state_test.go index bd1c6c1ab..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, 100, 0) + 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, 100, 0) + 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, 100, 0) + 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, 100, 0) + results, _, err := store.GetLastAppSessionKeyStates("0x0000000000000000000000000000000000000099", nil, true, 100, 0) require.NoError(t, err) assert.Empty(t, results) }) @@ -530,7 +530,7 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreAppSessionKeyState(state2)) - results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, 100, 0) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 1) @@ -555,17 +555,17 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { require.NoError(t, store.StoreAppSessionKeyState(state)) } - page1, total, err := store.GetLastAppSessionKeyStates(testUser1, nil, 2, 0) + 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, 2, 2) + 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, 2, 4) + 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) @@ -584,6 +584,144 @@ func TestDBStore_GetLastAppSessionKeyStates(t *testing.T) { 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) { @@ -784,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, 100, 0) + results, _, err := store.GetLastAppSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) diff --git a/nitronode/store/database/channel_session_key_state.go b/nitronode/store/database/channel_session_key_state.go index 52be03175..750b36cdb 100644 --- a/nitronode/store/database/channel_session_key_state.go +++ b/nitronode/store/database/channel_session_key_state.go @@ -91,14 +91,22 @@ func (s *DBStore) StoreChannelSessionKeyState(state core.ChannelSessionKeyStateV // 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. -// Results are paginated; totalCount is the unpaginated total of matching session keys. -func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, error) { +// 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() - pointerQuery := s.db.Model(&CurrentSessionKeyStateV1{}). - Where("user_address = ? AND kind = ? AND version > 0", wallet, SessionKeyKindChannel) + 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 != "" { - pointerQuery = pointerQuery.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 @@ -116,6 +124,9 @@ func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *stri 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 { diff --git a/nitronode/store/database/channel_session_key_state_test.go b/nitronode/store/database/channel_session_key_state_test.go index 0e5c56814..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, 100, 0) + 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, 100, 0) + 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, 100, 0) + 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, 100, 0) + 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, 100, 0) + 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, 100, 0) + 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, 100, 0) + 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, 100, 0) + results, _, err := store.GetLastChannelSessionKeyStates("0x0000000000000000000000000000000000000099", nil, true, 100, 0) require.NoError(t, err) assert.Empty(t, results) }) @@ -347,7 +347,7 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(state2)) - results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, 100, 0) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 1) @@ -374,19 +374,19 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { } // First page: 2 of 5 - page1, total, err := store.GetLastChannelSessionKeyStates(testUser1, nil, 2, 0) + 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, 2, 2) + 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, 2, 4) + 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) @@ -406,6 +406,148 @@ func TestDBStore_GetLastChannelSessionKeyStates(t *testing.T) { 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. @@ -860,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, 100, 0) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) @@ -912,7 +1054,7 @@ func TestDBStore_ChannelSessionKeyState_ForeignRelations(t *testing.T) { } require.NoError(t, store.StoreChannelSessionKeyState(stateB)) - results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, 100, 0) + results, _, err := store.GetLastChannelSessionKeyStates(testUser1, nil, true, 100, 0) require.NoError(t, err) assert.Len(t, results, 2) diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 831945961..1157fd7f8 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -214,8 +214,10 @@ type DatabaseStore interface { GetLastAppSessionKeyState(wallet, sessionKey string) (*app.AppSessionKeyStateV1, error) // GetLastAppSessionKeyStates retrieves the latest session key states for a user with optional - // filtering. Results are paginated; totalCount is the unpaginated total matching the filter. - GetLastAppSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]app.AppSessionKeyStateV1, uint32, error) + // 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 --- @@ -227,9 +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. Results are paginated; totalCount is the unpaginated - // total matching the filter. - GetLastChannelSessionKeyStates(wallet string, sessionKey *string, limit, offset uint32) ([]core.ChannelSessionKeyStateV1, uint32, 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/pkg/rpc/api.go b/pkg/rpc/api.go index bd6c7a856..235e41af0 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -126,13 +126,16 @@ 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"` @@ -258,13 +261,16 @@ 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/sdk/go/README.md b/sdk/go/README.md index 053203c82..e952e82d4 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -71,7 +71,7 @@ client.RebalanceAppSessions(ctx, signedUpdates) // Atomic rebalanc 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 active app session key states +client.GetLastAppKeyStates(ctx, userAddress, opts) // Get app session key states (active-only by default; opts.IncludeInactive=true to include expired) ``` ### Session Keys — Channels @@ -79,7 +79,7 @@ client.GetLastAppKeyStates(ctx, userAddress, opts) // Get activ 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 active channel session key states +client.GetLastChannelKeyStates(ctx, userAddress, opts) // Get channel session key states (active-only by default; opts.IncludeInactive=true to include expired) ``` ### Shared Utilities @@ -441,11 +441,18 @@ 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 @@ -466,11 +473,18 @@ 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 79b9d24fc..9fc11ed25 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -320,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: @@ -344,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) diff --git a/sdk/go/channel.go b/sdk/go/channel.go index 68f90a18c..46b425343 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -848,6 +848,9 @@ 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. @@ -885,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: @@ -906,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) diff --git a/sdk/ts-compat/README.md b/sdk/ts-compat/README.md index ba129b4a7..764083fe2 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -187,11 +187,12 @@ helpers produce `session_key_sig`. | `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?)` | Fetch channel session-key states for wallet/key | +| `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) | -| `getLastKeyStates(userAddress, sessionKey?)` | Fetch app-session key states for wallet/key | +| `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 diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index f0fe1f7b5..ee08107d9 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -875,8 +875,9 @@ 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 { @@ -894,8 +895,19 @@ export class NitroliteClient { 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/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap index 8934ea3ed..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", diff --git a/sdk/ts-compat/test/unit/client-mapping.test.ts b/sdk/ts-compat/test/unit/client-mapping.test.ts index b545c339d..38cfdc98a 100644 --- a/sdk/ts-compat/test/unit/client-mapping.test.ts +++ b/sdk/ts-compat/test/unit/client-mapping.test.ts @@ -320,6 +320,43 @@ describe('NitroliteClient compat mappings', () => { 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 12f88e152..820fff7bd 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -203,6 +203,7 @@ describe('compat public runtime API drift guard', () => { 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 b3f744362..8ffc2310d 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -81,7 +81,7 @@ client.rebalanceAppSessions(signedUpdates) // Atomic rebala 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.getLastKeyStates(userAddress, sessionKey?) // Get active app session key states +client.getLastAppKeyStates(userAddress, sessionKey?, options?) // Get app session key states (active-only by default; pass { includeInactive: true } to include expired) ``` ### Channel Session Keys @@ -89,7 +89,7 @@ client.getLastKeyStates(userAddress, sessionKey?) // Get a 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?) // Get active channel session key states +client.getLastChannelKeyStates(userAddress, sessionKey?, options?) // Get channel session key states (active-only by default; pass { includeInactive: true } to include expired) ``` ### Shared Utilities @@ -518,9 +518,12 @@ state.session_key_sig = await client.signAppSessionKeyOwnership(state, sessionKe 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 @@ -544,9 +547,12 @@ state.session_key_sig = await client.signChannelSessionKeyOwnership(state, sessi 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/src/client.ts b/sdk/ts/src/client.ts index 449d5ee9a..25819773b 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -1728,19 +1728,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)) { @@ -1810,19 +1816,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)) { diff --git a/sdk/ts/src/rpc/api.ts b/sdk/ts/src/rpc/api.ts index 88a4b2112..970004b96 100644 --- a/sdk/ts/src/rpc/api.ts +++ b/sdk/ts/src/rpc/api.ts @@ -134,6 +134,11 @@ 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; } @@ -246,6 +251,11 @@ 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; } 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 d151c9874..a438736cc 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 @@ -322,6 +322,7 @@ 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", @@ -887,6 +888,7 @@ 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", @@ -1058,8 +1060,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", 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 120821a56..5528952c9 100644 --- a/sdk/ts/test/unit/transform-drift.test.ts +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -302,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 ); @@ -327,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: { @@ -343,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'); }); From ba58e89b856cc9cd1002cfdeb81158755a5947b9 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 19:40:07 +0300 Subject: [PATCH 24/26] test(sdk/ts): refresh public API snapshot for ChannelStatus.Closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot was stale on the branch — MF-C02 (38d00d3f) inserted ChannelStatus.Closing = 3 (shifting Closed to 4) but did not refresh this snapshot. Co-Authored-By: Claude Opus 4.7 (1M context) --- sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 a438736cc..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 @@ -806,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", }, From 6152e74db98144deb11481ee4a3e16f45af60eee Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Wed, 13 May 2026 20:45:32 +0300 Subject: [PATCH 25/26] fix(scripts/drift): rename smoke caller from getLastKeyStates to getLastAppKeyStates The SDK method was renamed in MF-L08; the runtime smoke script still called the old name and broke the Protocol Drift Runtime CI check. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/drift/runtime-smoke.mjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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'); From af32b71513e3c7224ab95f3774b19e000415ce1c Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 13 May 2026 20:22:09 +0200 Subject: [PATCH 26/26] MF-L10: fix: emit escrowIds array in EscrowDepositsPurged event and handle it in Nitronode (#757) --- contracts/src/ChannelHub.sol | 13 +++- .../ChannelHub_purgeEscrowDeposits.t.sol | 54 +++++++++++---- .../ChannelHub_finalizeEscrowDeposit.t.sol | 1 - .../ChannelHub_finalizeEscrowWithdrawal.t.sol | 1 - nitronode/event_handlers/service.go | 38 +++++++++++ nitronode/event_handlers/service_test.go | 52 +++++++++++++++ pkg/blockchain/evm/channel_hub_abi.go | 66 ++++++++++++++----- pkg/blockchain/evm/channel_hub_reactor.go | 10 ++- .../evm/channel_hub_reactor_test.go | 45 +++++++++++++ pkg/core/event.go | 7 ++ pkg/core/interface.go | 1 + sdk/ts/src/blockchain/evm/channel_hub_abi.ts | 6 ++ 12 files changed, 258 insertions(+), 36 deletions(-) diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index 5c25e56e1..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 @@ -459,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]; @@ -475,6 +479,7 @@ contract ChannelHub is ReentrancyGuard { meta.status = EscrowStatus.FINALIZED; meta.lockedAmount = 0; + purgedIds[purgedCount] = escrowId; purgedCount++; escrowHeadTemp++; @@ -487,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); } } 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_units/ChannelHub_finalizeEscrowDeposit.t.sol b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol index fc75bc98e..36425e1a0 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowDeposit.t.sol @@ -6,7 +6,6 @@ import {TestUtils} from "../TestUtils.sol"; import {Utils} from "../../src/Utils.sol"; import {ChannelHub} from "../../src/ChannelHub.sol"; -import {EscrowDepositEngine} from "../../src/EscrowDepositEngine.sol"; import { State, ChannelDefinition, diff --git a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol index 71046d512..4d31414cf 100644 --- a/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol +++ b/contracts/test/ChannelHub_units/ChannelHub_finalizeEscrowWithdrawal.t.sol @@ -6,7 +6,6 @@ import {TestUtils} from "../TestUtils.sol"; import {Utils} from "../../src/Utils.sol"; import {ChannelHub} from "../../src/ChannelHub.sol"; -import {EscrowWithdrawalEngine} from "../../src/EscrowWithdrawalEngine.sol"; import { State, ChannelDefinition, diff --git a/nitronode/event_handlers/service.go b/nitronode/event_handlers/service.go index 4bd920a1b..76637ee2d 100644 --- a/nitronode/event_handlers/service.go +++ b/nitronode/event_handlers/service.go @@ -424,6 +424,44 @@ func (s *EventHandlerService) HandleEscrowDepositFinalized(ctx context.Context, 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. diff --git a/nitronode/event_handlers/service_test.go b/nitronode/event_handlers/service_test.go index 0c16c1b72..98dc484ea 100644 --- a/nitronode/event_handlers/service_test.go +++ b/nitronode/event_handlers/service_test.go @@ -1143,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/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 c9eab1e3e..1d91749af 100644 --- a/pkg/blockchain/evm/channel_hub_reactor.go +++ b/pkg/blockchain/evm/channel_hub_reactor.go @@ -569,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 29109f686..7786e151c 100644 --- a/pkg/blockchain/evm/channel_hub_reactor_test.go +++ b/pkg/blockchain/evm/channel_hub_reactor_test.go @@ -145,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) @@ -953,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/core/event.go b/pkg/core/event.go index 1777c9de5..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 diff --git a/pkg/core/interface.go b/pkg/core/interface.go index c673f1ff5..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 diff --git a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts index 03540f490..7dff25ff5 100644 --- a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts +++ b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts @@ -4156,6 +4156,12 @@ export const ChannelHubAbi = [ type: 'event', name: 'EscrowDepositsPurged', inputs: [ + { + name: 'escrowIds', + type: 'bytes32[]', + indexed: false, + internalType: 'bytes32[]' + }, { name: 'purgedCount', type: 'uint256',