From 30ece8f86ee8e6053ed907c4bb7e074147603b8d Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 5 Jun 2026 14:09:35 +0530 Subject: [PATCH 01/11] fix(go-sdk): preserve channel challenge expiry (#816) ## Summary Fixes the Go SDK channel transform so `ChallengeExpiresAt` from RPC `ChannelV1` is preserved on the returned core `Channel`. ## Context Auditor note: https://yellownetworkgroup.slack.com/archives/C0AHWMC0BRT/p1780517217044149 ## Changes - Copy `ChallengeExpiresAt` in `transformChannel`. - Add regression coverage in `TestTransformChannel`. ## Checks - `git diff --check -- sdk/go/utils.go sdk/go/utils_test.go` - `go test ./sdk/go ./pkg/core ./pkg/rpc` - `go test ./...` ## Summary by CodeRabbit * **Bug Fixes** * Channels now properly expose the challenge expiration timestamp field in the SDK response. * **Tests** * Updated channel transformation test to validate the newly exposed timestamp field. --- sdk/go/utils.go | 1 + sdk/go/utils_test.go | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/sdk/go/utils.go b/sdk/go/utils.go index c339dbae5..98929fe79 100644 --- a/sdk/go/utils.go +++ b/sdk/go/utils.go @@ -163,6 +163,7 @@ func transformChannel(channel rpc.ChannelV1) (core.Channel, error) { BlockchainID: blockchainID, TokenAddress: channel.TokenAddress, ChallengeDuration: channel.ChallengeDuration, + ChallengeExpiresAt: channel.ChallengeExpiresAt, Nonce: nonce, ApprovedSigValidators: channel.ApprovedSigValidators, Status: channelStatus, diff --git a/sdk/go/utils_test.go b/sdk/go/utils_test.go index 4e88ab36d..021a47d6c 100644 --- a/sdk/go/utils_test.go +++ b/sdk/go/utils_test.go @@ -137,6 +137,7 @@ func TestTransformPaginationParams(t *testing.T) { } func TestTransformChannel(t *testing.T) { + challengeExpiresAt := time.Unix(1710000000, 0).UTC() rpcChan := rpc.ChannelV1{ ChannelID: "0xChannelID", UserWallet: "0xUserWallet", @@ -144,6 +145,7 @@ func TestTransformChannel(t *testing.T) { BlockchainID: "137", TokenAddress: "0xToken", ChallengeDuration: 100, + ChallengeExpiresAt: &challengeExpiresAt, Nonce: "1", ApprovedSigValidators: "0x01", Status: "open", @@ -156,6 +158,8 @@ func TestTransformChannel(t *testing.T) { assert.Equal(t, core.ChannelStatusOpen, ch.Status) assert.Equal(t, uint64(137), ch.BlockchainID) assert.Equal(t, uint64(5), ch.StateVersion) + require.NotNil(t, ch.ChallengeExpiresAt) + assert.True(t, ch.ChallengeExpiresAt.Equal(challengeExpiresAt)) // Test error cases rpcChan.BlockchainID = "invalid" From c0e66773265726580af0fbc0f465258430c11ae5 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 5 Jun 2026 18:55:21 +0530 Subject: [PATCH 02/11] Fix SDK MCP Hono audit failure (#821) ## Summary - Refresh `sdk/mcp/package-lock.json` so the MCP SDK dependency tree resolves `hono` 4.12.23 instead of vulnerable 4.12.18. - Fix the `Publish SDK MCP` workflow failure in the `Audit production dependencies` step, where `npm audit --omit=dev --audit-level=moderate` reports Hono moderate advisories. ## Root cause The PR #810 job failed because `sdk/mcp/package-lock.json` pinned production dependency `hono` at 4.12.18, which matches the vulnerable `<=4.12.20` advisory range. The package manifest already allows a patched Hono through `@modelcontextprotocol/sdk`; the lockfile needed to be refreshed. ## Validation - `npm ci` - `npm audit --omit=dev --audit-level=moderate` - `npm run typecheck` - `npm run build` - `npm run verify:package -- pack.json` - Local packed MCP server smoke test equivalent to the workflow smoke job --- sdk/mcp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/mcp/package-lock.json b/sdk/mcp/package-lock.json index 200cc1860..1f3062325 100644 --- a/sdk/mcp/package-lock.json +++ b/sdk/mcp/package-lock.json @@ -1103,9 +1103,9 @@ } }, "node_modules/hono": { - "version": "4.12.18", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.18.tgz", - "integrity": "sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "engines": { "node": ">=16.9.0" From e4c47a7e49e01ce61526985532e5d306852b4fd5 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:08:26 +0200 Subject: [PATCH 03/11] fix: audit findings round final 3 (#846) - MF3-C01: fix(nitronode): require enforcing deposit states before crediting off-chain (#808) - MF3-M04: fix(nitronode): fix uint8 overflow in quorum weight accumulators (#802) - MF-L02: fix(nitronode): lock user balance row in HandleHomeChannelCreated + backfill unsigned receiver states (#803) - MF3-H01: fix(nitronode): fix pagination.limit=0 DoS (divide-by-zero) (#801) - fix(go-sdk): preserve channel challenge expiry (#816) - MF3-L01: fix(core): align transaction ID with referencing transition TxID (#807) - MF3-L10: reject missing last user state in submit_deposit_state (#812) - MF3-L11: fix unbounded responseSinks growth on marshal failure (#813) - fix(nitronode): remediate MF3-I* audit findings (#814) - MF3-L04: fix(nitronode): enforce canonical allocations in deposit state (#811) - MF3-M01, M02, M03, L09: feat(app-sessions): remove rebalance feature (#806) - MF3-L05, L06, L07, L08, I11 : feat(nitronode): remove app registry, action allowances, and user staking (#810) - MF3-M05: allow wallet-only revocation of session keys (#809) - MF3-L14: enforce request-rate limit at the frame layer (#819) - MF3-H02: lock home channel before reading status in event handlers (#820) - MF3-L13: document asset-symbol equivalence operator invariant (#818) - MF3-L16: fix(rpc): escape error message in NewErrorPayload (#829) - MF3-I12, I13, I14: nitronode audit remediations (#822) - MF3-L12: validate session-key scope ID formats at request boundary (#826) - MF3-H03: fix(nitronode): skip unsupported NodeBalanceUpdated tokens instead of fatal (#827) - MF3-L17, I15, I16: app-session participant docs, Void-checkpoint promotion, signature canonicalization note (#828) - MF3-L18: fix(nitronode): lock transfer balances in deterministic order (#830) - MF3-L03: docs: document app session close atomicity blocking on in-flight escrow (#831) - MF3-L15: fix(nitronode): fix reorg double-spend via confirmation gate (#832) - MF3-L19: fix: prevent SC reentrancy + event-handler monotonicity (#837) --- cerebro/README.md | 36 +- cerebro/commands.go | 326 +-- cerebro/operator.go | 171 +- contracts/SECURITY.md | 46 + .../deployments/HOOK-TOKEN-COMPATIBILITY.md | 36 + contracts/src/ChannelHub.sol | 48 +- contracts/test/ChannelHub_reentrancy.t.sol | 278 +++ contracts/test/mocks/ReentrantERC20.sol | 49 + docs/README.md | 6 +- docs/api.yaml | 211 +- docs/data_models.mmd | 1 - docs/protocol/security-and-limitations.md | 17 + llms-full.txt | 3 - nitronode/README.md | 19 +- nitronode/action_gateway/action_gateway.go | 191 -- .../action_gateway/action_gateway_test.go | 356 --- nitronode/action_gateway/interface.go | 41 - .../permissive_action_allower.go | 23 - nitronode/api/app_session_v1/README.md | 249 +- .../api/app_session_v1/create_app_session.go | 56 +- .../app_session_v1/create_app_session_test.go | 349 +-- .../api/app_session_v1/get_app_sessions.go | 8 + .../app_session_v1/get_app_sessions_test.go | 114 +- .../api/app_session_v1/get_last_key_states.go | 17 +- nitronode/api/app_session_v1/handler.go | 16 +- nitronode/api/app_session_v1/interface.go | 11 - .../app_session_v1/rebalance_app_sessions.go | 355 --- .../rebalance_app_sessions_test.go | 1358 ----------- .../api/app_session_v1/submit_app_state.go | 14 - .../app_session_v1/submit_app_state_test.go | 404 ++-- .../app_session_v1/submit_deposit_state.go | 67 +- .../submit_deposit_state_test.go | 728 +++++- .../submit_session_key_state.go | 59 +- .../submit_session_key_state_test.go | 204 +- nitronode/api/app_session_v1/testing.go | 45 - nitronode/api/app_session_v1/utils.go | 12 - nitronode/api/apps_v1/get_apps.go | 80 - nitronode/api/apps_v1/get_apps_test.go | 174 -- nitronode/api/apps_v1/handler.go | 20 - nitronode/api/apps_v1/interface.go | 33 - nitronode/api/apps_v1/submit_app_version.go | 109 - .../api/apps_v1/submit_app_version_test.go | 294 --- nitronode/api/apps_v1/testing.go | 58 - nitronode/api/channel_v1/get_channels.go | 19 +- nitronode/api/channel_v1/get_channels_test.go | 1 - .../api/channel_v1/get_escrow_channel.go | 5 + .../api/channel_v1/get_escrow_channel_test.go | 45 +- .../api/channel_v1/get_home_channel_test.go | 2 - .../api/channel_v1/get_last_key_states.go | 16 +- .../api/channel_v1/get_latest_state_test.go | 2 - nitronode/api/channel_v1/handler.go | 57 +- nitronode/api/channel_v1/interface.go | 8 - .../channel_v1/lock_transfer_balances_test.go | 89 + nitronode/api/channel_v1/request_creation.go | 25 +- .../api/channel_v1/request_creation_test.go | 114 +- .../channel_v1/submit_session_key_state.go | 86 +- .../submit_session_key_state_test.go | 364 ++- nitronode/api/channel_v1/submit_state.go | 32 +- nitronode/api/channel_v1/submit_state_test.go | 114 +- nitronode/api/channel_v1/testing.go | 37 - nitronode/api/node_v1/utils.go | 8 +- nitronode/api/rate_limits.go | 59 - nitronode/api/rate_limits_test.go | 161 -- nitronode/api/rpc_router.go | 61 +- .../api/user_v1/get_action_allowances.go | 59 - .../api/user_v1/get_action_allowances_test.go | 145 -- .../api/user_v1/get_transactions_test.go | 83 + nitronode/api/user_v1/handler.go | 11 +- nitronode/api/user_v1/interface.go | 8 - nitronode/api/user_v1/testing.go | 33 - nitronode/api/user_v1/utils.go | 4 +- nitronode/chart/README.md | 1 - nitronode/chart/config/prod-v1/assets.yaml | 9 + .../chart/config/prod-v1/blockchains.yaml | 16 + .../config/prod-v1/nitronode.yaml.gotmpl | 1 - nitronode/chart/config/sandbox-v1/assets.yaml | 9 + .../chart/config/sandbox-v1/blockchains.yaml | 10 + .../config/sandbox-v1/nitronode.yaml.gotmpl | 1 - nitronode/chart/config/stress-v1/assets.yaml | 9 + .../chart/config/stress-v1/blockchains.yaml | 4 + .../config/stress-v1/nitronode.yaml.gotmpl | 1 - nitronode/chart/templates/configmap.yaml | 2 - nitronode/chart/values.yaml | 2 - ...00000_session_key_user_only_revocation.sql | 29 + ...0_drop_app_registry_staking_action_log.sql | 48 + ...0000_add_block_hash_to_contract_events.sql | 7 + .../config/schemas/action_gateway_schema.yaml | 31 - nitronode/docs/reorg-fix.md | 491 ++++ nitronode/event_handlers/service.go | 361 ++- nitronode/event_handlers/service_test.go | 1912 +++++++++++++-- nitronode/event_handlers/testing.go | 17 +- nitronode/main.go | 85 +- nitronode/metrics/exporter.go | 2 +- nitronode/metrics/interface.go | 10 +- nitronode/runtime.go | 93 +- nitronode/store/database/action_log.go | 91 - nitronode/store/database/action_log_test.go | 249 -- nitronode/store/database/app.go | 118 - nitronode/store/database/app_session.go | 7 +- .../store/database/app_session_key_state.go | 3 +- nitronode/store/database/app_test.go | 317 --- .../store/database/blockchain_action_test.go | 32 +- nitronode/store/database/channel.go | 13 +- .../database/channel_session_key_state.go | 2 +- nitronode/store/database/channel_test.go | 169 +- nitronode/store/database/contract_event.go | 45 +- .../store/database/contract_event_test.go | 18 +- .../database/current_session_key_state.go | 14 +- nitronode/store/database/database.go | 3 - nitronode/store/database/db_store.go | 84 +- nitronode/store/database/db_store_test.go | 154 +- nitronode/store/database/interface.go | 58 +- .../store/database/lifespan_metric_test.go | 6 +- nitronode/store/database/state.go | 15 +- nitronode/store/database/state_test.go | 86 +- .../test/postgres_integration_test.go | 26 +- nitronode/store/database/testing.go | 4 +- nitronode/store/database/transaction.go | 17 +- nitronode/store/database/user_staked.go | 75 - nitronode/store/database/utils.go | 16 +- nitronode/store/database/utils_test.go | 30 + nitronode/store/memory/asset_config.go | 22 + nitronode/store/memory/asset_config_test.go | 58 + nitronode/store/memory/blockchain_config.go | 15 +- .../store/memory/blockchain_config_test.go | 9 +- nitronode/store/memory/memory_store.go | 18 +- nitronode/store/memory/memory_store_test.go | 76 + pkg/app/app_session_v1.go | 90 - pkg/app/app_session_v1_test.go | 39 - pkg/app/app_v1.go | 57 - pkg/app/session_key_v1.go | 48 +- pkg/app/session_key_v1_test.go | 40 + pkg/blockchain/evm/app_registry_abi.go | 2154 ----------------- pkg/blockchain/evm/blockchain_client.go | 8 + pkg/blockchain/evm/channel_hub_reactor.go | 109 +- .../evm/channel_hub_reactor_test.go | 207 +- pkg/blockchain/evm/channel_hub_reader.go | 94 + pkg/blockchain/evm/confirmation_gate.go | 331 +++ pkg/blockchain/evm/confirmation_gate_test.go | 1011 ++++++++ pkg/blockchain/evm/init.go | 1 - pkg/blockchain/evm/interface.go | 21 +- pkg/blockchain/evm/listener.go | 411 +++- pkg/blockchain/evm/listener_test.go | 858 ++++++- pkg/blockchain/evm/locking_client.go | 224 -- pkg/blockchain/evm/locking_reactor.go | 187 -- pkg/blockchain/evm/locking_reactor_test.go | 150 -- pkg/blockchain/evm/mock_test.go | 22 +- pkg/blockchain/evm/reconciler.go | 128 + pkg/blockchain/evm/reconciler_test.go | 235 ++ pkg/core/README.md | 10 +- pkg/core/errors.go | 8 + pkg/core/event.go | 7 +- pkg/core/interface.go | 59 +- pkg/core/session_key.go | 54 +- pkg/core/session_key_test.go | 44 + pkg/core/state_advancer_test.go | 29 + pkg/core/types.go | 155 +- pkg/core/types_test.go | 68 +- pkg/core/utils.go | 31 + pkg/core/utils_test.go | 59 + pkg/log/noop_logger.go | 23 +- pkg/rpc/api.go | 58 - pkg/rpc/client.go | 40 - pkg/rpc/client_test.go | 123 - pkg/rpc/connection.go | 14 +- pkg/rpc/connection_hub.go | 1 - pkg/rpc/connection_test.go | 32 + pkg/rpc/dialer.go | 13 +- pkg/rpc/dialer_internal_test.go | 37 + pkg/rpc/error.go | 10 +- pkg/rpc/error_test.go | 40 + pkg/rpc/methods.go | 13 +- pkg/rpc/rate_limiter.go | 64 + pkg/rpc/rate_limiter_test.go | 72 + pkg/rpc/types.go | 48 +- pkg/sign/mock_signer_test.go | 18 +- playground/README.md | 13 +- playground/REFERENCE.md | 25 +- playground/mockups/history/history-tab.md | 3 +- playground/src/App.tsx | 3 +- playground/src/components/ActionPanel.tsx | 17 +- playground/src/components/ChannelList.tsx | 3 + playground/src/components/ChannelRow.tsx | 29 +- playground/src/components/HistoryTab.tsx | 2 - playground/src/components/StateViewer.tsx | 24 +- playground/src/hooks/useChannelOps.tsx | 140 +- playground/src/hooks/useChannelStates.ts | 61 +- protocol-description.md | 10 + sdk/PROTOCOL_DRIFT_GUARDS.md | 4 +- sdk/go/README.md | 42 +- sdk/go/app_registry.go | 170 -- sdk/go/app_session.go | 90 +- sdk/go/channel.go | 52 +- sdk/go/checkpoint.go | 132 + sdk/go/checkpoint_test.go | 165 ++ sdk/go/client.go | 217 +- sdk/go/client_test.go | 26 - sdk/go/config_test.go | 2 +- sdk/go/examples/app_sessions/lifecycle.go | 195 +- sdk/go/node.go | 25 + sdk/go/node_test.go | 79 + sdk/go/user.go | 44 - sdk/go/utils.go | 10 +- sdk/go/utils_test.go | 8 +- sdk/mcp/package-lock.json | 252 +- sdk/mcp/src/index.ts | 16 +- sdk/ts-compat/README.md | 49 +- sdk/ts-compat/docs/migration-onchain.md | 41 + sdk/ts-compat/src/client.ts | 120 - .../public-api-drift.test.ts.snap | 9 - sdk/ts-compat/test/unit/client.test.ts | 1 - sdk/ts/README.md | 45 +- sdk/ts/examples/app_sessions/README.md | 18 +- sdk/ts/examples/app_sessions/lifecycle.ts | 123 +- .../src/components/WalletDashboard.tsx | 340 +-- sdk/ts/src/app/packing.ts | 91 +- sdk/ts/src/app/types.ts | 11 - sdk/ts/src/blockchain/evm/app_registry_abi.ts | 73 - sdk/ts/src/blockchain/evm/index.ts | 2 - sdk/ts/src/blockchain/evm/locking_client.ts | 223 -- sdk/ts/src/client.ts | 444 ++-- sdk/ts/src/core/state.ts | 8 +- sdk/ts/src/core/types.ts | 10 +- sdk/ts/src/index.ts | 2 +- sdk/ts/src/rpc/api.ts | 52 - sdk/ts/src/rpc/client.ts | 32 - sdk/ts/src/rpc/methods.ts | 7 - sdk/ts/src/rpc/types.ts | 52 +- sdk/ts/src/utils.ts | 20 +- .../public-api-drift.test.ts.snap | 226 +- sdk/ts/test/unit/abi-drift.test.ts | 64 - sdk/ts/test/unit/client.test.ts | 146 +- sdk/ts/test/unit/core/state_advancer.test.ts | 20 + sdk/ts/test/unit/rpc-drift.test.ts | 4 - 234 files changed, 11762 insertions(+), 13226 deletions(-) create mode 100644 contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md create mode 100644 contracts/test/ChannelHub_reentrancy.t.sol create mode 100644 contracts/test/mocks/ReentrantERC20.sol delete mode 100644 nitronode/action_gateway/action_gateway.go delete mode 100644 nitronode/action_gateway/action_gateway_test.go delete mode 100644 nitronode/action_gateway/interface.go delete mode 100644 nitronode/action_gateway/permissive_action_allower.go delete mode 100644 nitronode/api/app_session_v1/rebalance_app_sessions.go delete mode 100644 nitronode/api/app_session_v1/rebalance_app_sessions_test.go delete mode 100644 nitronode/api/apps_v1/get_apps.go delete mode 100644 nitronode/api/apps_v1/get_apps_test.go delete mode 100644 nitronode/api/apps_v1/handler.go delete mode 100644 nitronode/api/apps_v1/interface.go delete mode 100644 nitronode/api/apps_v1/submit_app_version.go delete mode 100644 nitronode/api/apps_v1/submit_app_version_test.go delete mode 100644 nitronode/api/apps_v1/testing.go create mode 100644 nitronode/api/channel_v1/lock_transfer_balances_test.go delete mode 100644 nitronode/api/rate_limits.go delete mode 100644 nitronode/api/rate_limits_test.go delete mode 100644 nitronode/api/user_v1/get_action_allowances.go delete mode 100644 nitronode/api/user_v1/get_action_allowances_test.go create mode 100644 nitronode/config/migrations/postgres/20260602000000_session_key_user_only_revocation.sql create mode 100644 nitronode/config/migrations/postgres/20260603000000_drop_app_registry_staking_action_log.sql create mode 100644 nitronode/config/migrations/postgres/20260608000000_add_block_hash_to_contract_events.sql delete mode 100644 nitronode/config/schemas/action_gateway_schema.yaml create mode 100644 nitronode/docs/reorg-fix.md delete mode 100644 nitronode/store/database/action_log.go delete mode 100644 nitronode/store/database/action_log_test.go delete mode 100644 nitronode/store/database/app.go delete mode 100644 nitronode/store/database/app_test.go delete mode 100644 nitronode/store/database/user_staked.go create mode 100644 nitronode/store/database/utils_test.go create mode 100644 nitronode/store/memory/memory_store_test.go delete mode 100644 pkg/blockchain/evm/app_registry_abi.go create mode 100644 pkg/blockchain/evm/channel_hub_reader.go create mode 100644 pkg/blockchain/evm/confirmation_gate.go create mode 100644 pkg/blockchain/evm/confirmation_gate_test.go delete mode 100644 pkg/blockchain/evm/locking_client.go delete mode 100644 pkg/blockchain/evm/locking_reactor.go delete mode 100644 pkg/blockchain/evm/locking_reactor_test.go create mode 100644 pkg/blockchain/evm/reconciler.go create mode 100644 pkg/blockchain/evm/reconciler_test.go create mode 100644 pkg/core/errors.go create mode 100644 pkg/rpc/dialer_internal_test.go create mode 100644 pkg/rpc/error_test.go delete mode 100644 sdk/go/app_registry.go create mode 100644 sdk/go/checkpoint.go create mode 100644 sdk/go/checkpoint_test.go create mode 100644 sdk/go/node_test.go delete mode 100644 sdk/ts/src/blockchain/evm/app_registry_abi.ts delete mode 100644 sdk/ts/src/blockchain/evm/locking_client.ts diff --git a/cerebro/README.md b/cerebro/README.md index d76fc0da1..0c139ef41 100644 --- a/cerebro/README.md +++ b/cerebro/README.md @@ -68,12 +68,12 @@ config session-key app-keys List active app ses ```text token-balance Check on-chain token balance approve Approve token spending for deposits -deposit Deposit to channel (auto-create if needed) +deposit Deposit to channel (auto-create if needed; off-chain credit lags by the chain's confirmation delay) withdraw Withdraw from channel -transfer Transfer to another wallet +transfer Transfer to another wallet (off-chain, instant) acknowledge Acknowledge transfer or channel creation close-channel Close home channel on-chain -checkpoint Submit latest state on-chain +checkpoint Submit latest state on-chain (off-chain credit lags by the chain's confirmation delay) ``` ### Queries @@ -84,30 +84,15 @@ chains List supported blockchains assets [chain_id] List supported assets (optionally filter by chain) balances [wallet] Get user balances (defaults to configured wallet) transactions [wallet] Get transaction history -action-allowances [wallet] Get action allowances state [wallet] Get latest state home-channel [wallet] Get home channel escrow-channel Get escrow channel by ID ``` -### App Registry +### App Sessions ```text -app-info Show application details -my-apps List your registered applications -register-app [no-approval] Register a new application -app-sessions List app sessions -``` - -### Security Token Operations - -```text -security-token approve Approve security token spending -security-token balance [wallet] Check escrowed security token balance -security-token escrow [target_address] Escrow security tokens -security-token initiate-withdrawal Start unlock period -security-token cancel-withdrawal Cancel unlock and re-lock -security-token withdraw Withdraw unlocked security tokens +app-sessions List app sessions ``` ### Other @@ -219,6 +204,17 @@ cerebro> assets cerebro> config node ``` +### Confirmation delay + +On-chain operations (`deposit`, `withdraw`, `checkpoint`, `close-channel`) submit a transaction and +return once it is **mined**. The node then waits a per-chain **confirmation delay** before crediting the +result to your off-chain balance — a safety gate against chain reorganizations. Until it elapses, +`balances` will not reflect a fresh deposit. + +`chains` and `config node` print each chain's delay (`confirmation_delay_secs`; `0` means the gate is +disabled and credit is immediate). Off-chain `transfer` is never gated. After a `deposit`/`checkpoint`, +re-run `balances` once the printed delay has passed to see the credit. + ### Inspect State ```bash diff --git a/cerebro/commands.go b/cerebro/commands.go index 3b811f747..9b9daf005 100644 --- a/cerebro/commands.go +++ b/cerebro/commands.go @@ -16,6 +16,7 @@ import ( "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/sign" sdk "github.com/layer-3/nitrolite/sdk/go" + "github.com/shopspring/decimal" "golang.org/x/term" ) @@ -66,6 +67,7 @@ OPERATIONS acknowledge Acknowledge transfer or channel creation close-channel Close home channel on-chain checkpoint Submit latest state on-chain + wait-credit Wait for off-chain credit after checkpoint QUERIES ping Test node connection @@ -73,24 +75,12 @@ QUERIES assets [chain_id] List supported assets (optionally filter by chain) balances [wallet] Get user balances (defaults to configured wallet) transactions [wallet] Get transaction history - action-allowances [wallet] Get action allowances state [wallet] Get latest state home-channel [wallet] Get home channel escrow-channel Get escrow channel by ID -APP REGISTRY - app-info Show application details - my-apps List your registered applications - register-app [no-approval] Register a new application - app-sessions List app sessions - -SECURITY TOKEN OPERATIONS - security-token approve Approve security token spending - security-token balance [wallet] Check escrowed security token balance - security-token escrow [target_address] Escrow security tokens - security-token initiate-withdrawal Start unlock period - security-token cancel-withdrawal Cancel unlock and re-lock - security-token withdraw Withdraw unlocked security tokens +APP SESSIONS + app-sessions List app sessions OTHER help Display this help message @@ -461,8 +451,21 @@ func (o *Operator) checkpoint(ctx context.Context, asset string) { return } - fmt.Printf("SUCCESS: Checkpoint completed\n") + fmt.Printf("SUCCESS: Checkpoint submitted on-chain\n") fmt.Printf("Transaction Hash: %s\n", txHash) + + // Best-effort: tell the user when the off-chain credit will actually land. + // The node runs a confirmation gate (PR #832): the off-chain balance only + // updates confirmation_delay_secs after the tx is mined. Any failure here is + // non-fatal — the checkpoint already succeeded. + if delay, ok := o.confirmationDelayForAsset(ctx, asset); ok { + if delay == 0 { + fmt.Println("INFO: Off-chain credit is immediate on this chain (no confirmation gate).") + } else { + fmt.Printf("INFO: Off-chain credit expected in ~%ds (node confirmation window).\n", delay) + fmt.Printf("INFO: Run 'balances' after that, or 'wait-credit %s' to wait automatically.\n", asset) + } + } } // ============================================================================ @@ -511,9 +514,7 @@ func (o *Operator) nodeInfo(ctx context.Context) { for _, bc := range config.Blockchains { fmt.Printf(" - %s (ID: %d)\n", bc.Name, bc.ID) fmt.Printf(" Channel Hub: %s\n", bc.ChannelHubAddress) - if bc.LockingContractAddress != "" { - fmt.Printf(" Locking: %s\n", bc.LockingContractAddress) - } + fmt.Printf(" Confirmation Delay: %s\n", formatConfirmationDelay(bc.ConfirmationDelaySecs)) } } @@ -547,6 +548,7 @@ func (o *Operator) listChains(ctx context.Context) { fmt.Printf("- %s\n", chain.Name) fmt.Printf(" Chain ID: %d\n", chain.ID) fmt.Printf(" Contract: %s\n", chain.ChannelHubAddress) + fmt.Printf(" Confirm: %s\n", formatConfirmationDelay(chain.ConfirmationDelaySecs)) // Check if RPC is configured _, err := o.store.GetRPC(chain.ID) @@ -748,90 +750,6 @@ func (o *Operator) listTransactions(ctx context.Context, wallet string) { } } -func (o *Operator) getActionAllowances(ctx context.Context, wallet string) { - allowances, err := o.client.GetActionAllowances(ctx, wallet) - if err != nil { - fmt.Printf("ERROR: Failed to get action allowances: %v\n", err) - return - } - - fmt.Printf("Action Allowances for %s\n", wallet) - fmt.Println("========================================") - if len(allowances) == 0 { - fmt.Println("No action allowances found") - return - } - - for _, a := range allowances { - fmt.Printf("- %s\n", a.GatedAction) - fmt.Printf(" Window: %s\n", a.TimeWindow) - fmt.Printf(" Used: %d / %d\n", a.Used, a.Allowance) - remaining := uint64(0) - if a.Allowance > a.Used { - remaining = a.Allowance - a.Used - } - fmt.Printf(" Remaining: %d\n", remaining) - } -} - -// ============================================================================ -// App Registry -// ============================================================================ - -func (o *Operator) getApps(ctx context.Context, appID *string, ownerWallet *string) { - fmt.Println("Fetching registered applications...") - - apps, _, err := o.client.GetApps(ctx, &sdk.GetAppsOptions{ - AppID: appID, - OwnerWallet: ownerWallet, - }) - if err != nil { - fmt.Printf("ERROR: Failed to get apps: %v\n", err) - return - } - - if len(apps) == 0 { - fmt.Println("No applications found.") - return - } - - fmt.Printf("Found %d application(s):\n\n", len(apps)) - for _, a := range apps { - fmt.Printf(" App ID: %s\n", a.App.ID) - fmt.Printf(" Owner: %s\n", a.App.OwnerWallet) - fmt.Printf(" Version: %d\n", a.App.Version) - if a.App.CreationApprovalNotRequired { - fmt.Println(" Approval: Not required") - } else { - fmt.Println(" Approval: Required") - } - if a.App.Metadata != "" { - fmt.Printf(" Metadata: %s\n", a.App.Metadata) - } - fmt.Printf(" Created: %s\n", a.CreatedAt.Format("2006-01-02 15:04:05")) - fmt.Printf(" Updated: %s\n", a.UpdatedAt.Format("2006-01-02 15:04:05")) - fmt.Println() - } -} - -func (o *Operator) registerApp(ctx context.Context, appID, metadata string, creationApprovalNotRequired bool) { - fmt.Printf("Registering application: %s...\n", appID) - - err := o.client.RegisterApp(ctx, appID, metadata, creationApprovalNotRequired) - if err != nil { - fmt.Printf("ERROR: Failed to register app: %v\n", err) - return - } - - fmt.Println("SUCCESS: Application registered") - fmt.Printf(" App ID: %s\n", appID) - if creationApprovalNotRequired { - fmt.Println(" Approval: Not required for session creation") - } else { - fmt.Println(" Approval: Required for session creation") - } -} - // ============================================================================ // Low-Level State Management (Base Client) // ============================================================================ @@ -1281,148 +1199,106 @@ func (o *Operator) listAppSessionKeys(ctx context.Context, wallet string) { } // ============================================================================ -// Security Token Operations +// Helper Methods // ============================================================================ -func (o *Operator) escrowSecurityTokens(ctx context.Context, chainIDStr, targetAddress, amountStr string) { - chainID, err := o.parseChainID(chainIDStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return - } - - amount, err := o.parseAmount(amountStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return - } - - // Default target to own wallet if not specified - if targetAddress == "" { - targetAddress = o.getImportedWalletAddress() - if targetAddress == "" { - fmt.Println("ERROR: No wallet configured. Use 'config wallet import' first.") - return - } - fmt.Printf("INFO: Using configured wallet as target: %s\n", targetAddress) - } - - fmt.Printf("Escrowing %s security tokens for %s on chain %d...\n", amount.String(), targetAddress, chainID) - - txHash, err := o.client.EscrowSecurityTokens(ctx, targetAddress, chainID, amount) - if err != nil { - fmt.Printf("ERROR: Escrow failed: %v\n", err) - return +// formatConfirmationDelay renders a chain's confirmation_delay_secs for display. +// Zero means the gate is disabled (BFT / single-slot chains) — off-chain credit is +// effectively immediate. +func formatConfirmationDelay(secs uint32) string { + if secs == 0 { + return "instant (no confirmation gate)" } - - fmt.Println("SUCCESS: Security tokens escrowed") - fmt.Printf("Transaction Hash: %s\n", txHash) + return fmt.Sprintf("~%ds", secs) } -func (o *Operator) initiateSecurityWithdrawal(ctx context.Context, chainIDStr string) { - chainID, err := o.parseChainID(chainIDStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return - } - - fmt.Printf("Initiating security tokens withdrawal on chain %d...\n", chainID) - - txHash, err := o.client.InitiateSecurityTokensWithdrawal(ctx, chainID) - if err != nil { - fmt.Printf("ERROR: Initiate withdrawal failed: %v\n", err) - return - } - - fmt.Println("SUCCESS: Security tokens withdrawal initiated") - fmt.Printf("Transaction Hash: %s\n", txHash) -} - -func (o *Operator) cancelSecurityWithdrawal(ctx context.Context, chainIDStr string) { - chainID, err := o.parseChainID(chainIDStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return - } - - fmt.Printf("Cancelling security tokens withdrawal on chain %d...\n", chainID) - - txHash, err := o.client.CancelSecurityTokensWithdrawal(ctx, chainID) - if err != nil { - fmt.Printf("ERROR: Cancel withdrawal failed: %v\n", err) - return - } - - fmt.Println("SUCCESS: Security tokens withdrawal cancelled (re-locked)") - fmt.Printf("Transaction Hash: %s\n", txHash) -} - -func (o *Operator) withdrawSecurityTokens(ctx context.Context, chainIDStr, destination string) { - chainID, err := o.parseChainID(chainIDStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return - } - - fmt.Printf("Withdrawing security tokens to %s on chain %d...\n", destination, chainID) - - txHash, err := o.client.WithdrawSecurityTokens(ctx, chainID, destination) - if err != nil { - fmt.Printf("ERROR: Withdraw security tokens failed: %v\n", err) - return - } - - fmt.Println("SUCCESS: Security tokens withdrawn") - fmt.Printf("Transaction Hash: %s\n", txHash) -} - -func (o *Operator) approveSecurityToken(ctx context.Context, chainIDStr, amountStr string) { - chainID, err := o.parseChainID(chainIDStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) +// waitCredit waits for the off-chain enforced balance of the given asset to change +// after a checkpoint/deposit. It builds its own context (not the shared 30s command +// context) so the confirmation-window sleep isn't killed prematurely. +func (o *Operator) waitCredit(asset string) { + wallet := o.getImportedWalletAddress() + if wallet == "" { + fmt.Println("ERROR: No wallet configured. Use 'config wallet import' first.") return } - amount, err := o.parseAmount(amountStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return - } + // Resolve confirmation delay (advisory — best effort). + delay, delayKnown := o.confirmationDelayForAsset(context.Background(), asset) - fmt.Printf("Approving %s security tokens on chain %d...\n", amount.String(), chainID) + // Build a generous context: delay*2 + 60s (min 60s). + timeout := time.Duration(delay)*2*time.Second + 60*time.Second + waitCtx, waitCancel := context.WithTimeout(context.Background(), timeout) + defer waitCancel() - txHash, err := o.client.ApproveSecurityToken(ctx, chainID, amount) - if err != nil { - fmt.Printf("ERROR: Approve security token failed: %v\n", err) - return + // Read baseline enforced balance. + baseEnforced := decimal.Zero + { + shortCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + balances, err := o.client.GetBalances(shortCtx, wallet) + cancel() + if err != nil { + // Without a reliable baseline a pre-existing balance would be misread as a + // freshly-landed credit. Abort rather than report a false SUCCESS. + fmt.Printf("ERROR: Could not read baseline balance: %v\n", err) + fmt.Println("Cannot reliably detect a credit without a baseline; run 'wait-credit' again or check 'balances'.") + return + } + for _, b := range balances { + if strings.EqualFold(b.Asset, asset) { + baseEnforced = b.Enforced + break + } + } } - fmt.Println("SUCCESS: Security token spending approved") - fmt.Printf("Transaction Hash: %s\n", txHash) -} + fmt.Printf("Watching enforced balance for %s (wallet: %s)\n", asset, wallet) + fmt.Printf("Baseline enforced: %s\n", baseEnforced.String()) -func (o *Operator) securityBalance(ctx context.Context, chainIDStr, wallet string) { - chainID, err := o.parseChainID(chainIDStr) - if err != nil { - fmt.Printf("ERROR: %v\n", err) - return + if delay > 0 { + fmt.Printf("INFO: Waiting ~%ds for the confirmation window before polling...\n", delay) + select { + case <-time.After(time.Duration(delay) * time.Second): + case <-waitCtx.Done(): + fmt.Println("WARNING: Context expired during confirmation window wait.") + return + } + } else { + if !delayKnown { + fmt.Println("INFO: confirmation gate disabled (or delay unknown); polling immediately.") + } else { + fmt.Println("INFO: Off-chain credit is immediate on this chain (no confirmation gate); polling immediately.") + } } - fmt.Printf("Querying security token balance for %s on chain %d...\n", wallet, chainID) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() - balance, err := o.client.GetLockedBalance(ctx, chainID, wallet) - if err != nil { - fmt.Printf("ERROR: Failed to get security token balance: %v\n", err) - return + for { + select { + case <-waitCtx.Done(): + fmt.Printf("WARNING: enforced balance unchanged after %s; run 'balances' to re-check.\n", timeout) + return + case <-ticker.C: + shortCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + balances, err := o.client.GetBalances(shortCtx, wallet) + cancel() + if err != nil { + fmt.Printf("WARNING: Failed to poll balances: %v\n", err) + continue + } + for _, b := range balances { + if strings.EqualFold(b.Asset, asset) { + if !b.Enforced.Equal(baseEnforced) { + fmt.Printf("SUCCESS: Off-chain credit landed. New enforced balance: %s\n", b.Enforced.String()) + return + } + break + } + } + } } - - fmt.Printf("Security token balance: %s\n", balance.String()) } -// ============================================================================ -// Helper Methods -// ============================================================================ - // generatePrivateKey generates a new Ethereum private key func generatePrivateKey() (string, error) { // Generate new ECDSA private key diff --git a/cerebro/operator.go b/cerebro/operator.go index e08ac0f6f..cbc3ac106 100644 --- a/cerebro/operator.go +++ b/cerebro/operator.go @@ -53,7 +53,10 @@ func (o *Operator) buildStateSigner(walletPrivateKey string) (core.ChannelSigner if err != nil { return nil, fmt.Errorf("failed to create session key channel signer: %w", err) } - sessionRawSigner, _ := sign.NewEthereumRawSigner(skPrivateKey) + sessionRawSigner, err := sign.NewEthereumRawSigner(skPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to create session key raw signer: %w", err) + } fmt.Printf("INFO: Using session key for state signing: %s\n", sessionRawSigner.PublicKey().Address().String()) return signer, nil } @@ -176,6 +179,7 @@ func (o *Operator) complete(d prompt.Document) []prompt.Suggest { {Text: "close-channel", Description: "Close home channel on-chain"}, {Text: "acknowledge", Description: "Acknowledge transfer or channel creation"}, {Text: "checkpoint", Description: "Submit latest state on-chain"}, + {Text: "wait-credit", Description: "Wait for off-chain credit to land after checkpoint"}, // Node information {Text: "ping", Description: "Test node connection"}, @@ -185,25 +189,16 @@ func (o *Operator) complete(d prompt.Document) []prompt.Suggest { // User queries {Text: "balances", Description: "Get user balances"}, {Text: "transactions", Description: "Get transaction history"}, - {Text: "action-allowances", Description: "Get action allowances"}, // State management {Text: "state", Description: "Get latest state"}, {Text: "home-channel", Description: "Get home channel"}, {Text: "escrow-channel", Description: "Get escrow channel"}, - // App registry - {Text: "app-info", Description: "Show application details"}, - {Text: "my-apps", Description: "List your registered applications"}, - {Text: "register-app", Description: "Register a new application"}, - // App sessions (Base Client - Low-level) {Text: "app-sessions", Description: "List app sessions"}, - // Security token operations - {Text: "security-token", Description: "Security token operations"}, - -{Text: "exit", Description: "Exit the CLI"}, + {Text: "exit", Description: "Exit the CLI"}, } } @@ -217,19 +212,10 @@ func (o *Operator) complete(d prompt.Document) []prompt.Suggest { {Text: "node", Description: "Node info and connection"}, {Text: "session-key", Description: "Session key management"}, } - case "close-channel", "acknowledge", "checkpoint": + case "close-channel", "acknowledge", "checkpoint", "wait-credit": return o.getAssetSuggestions() case "token-balance", "approve", "deposit", "withdraw": return o.getChainSuggestions() - case "security-token": - return []prompt.Suggest{ - {Text: "approve", Description: "Approve security token spending"}, - {Text: "balance", Description: "Check escrowed security token balance"}, - {Text: "escrow", Description: "Escrow security tokens"}, - {Text: "initiate-withdrawal", Description: "Start unlock period"}, - {Text: "cancel-withdrawal", Description: "Cancel unlock and re-lock"}, - {Text: "withdraw", Description: "Withdraw unlocked security tokens"}, - } case "state", "home-channel": // state [wallet] , home-channel [wallet] // Suggest asset first (common case), wallet can be typed manually @@ -237,7 +223,7 @@ func (o *Operator) complete(d prompt.Document) []prompt.Suggest { case "transfer": // transfer return o.getWalletSuggestion() - case "balances", "transactions", "action-allowances": + case "balances", "transactions": return o.getWalletSuggestion() case "assets": return o.getChainSuggestions() @@ -287,9 +273,6 @@ func (o *Operator) complete(d prompt.Document) []prompt.Suggest { case "state", "home-channel": // state [wallet] — if wallet was explicitly provided, suggest asset return o.getAssetSuggestions() - case "security-token": - // security-token ... - return o.getChainSuggestions() case "escrow-channel": // Escrow channel ID (no suggestion) return nil @@ -504,6 +487,12 @@ func (o *Operator) Execute(s string) { return } o.checkpoint(ctx, args[1]) + case "wait-credit": + if len(args) < 2 { + fmt.Println("ERROR: Usage: wait-credit ") + return + } + o.waitCredit(args[1]) // Node information case "ping": @@ -601,118 +590,11 @@ func (o *Operator) Execute(s string) { } o.getEscrowChannel(ctx, args[1]) - // App registry - case "app-info": - if len(args) < 2 { - fmt.Println("ERROR: Usage: app-info ") - return - } - o.getApps(ctx, &args[1], nil) - - case "my-apps": - wallet := o.getImportedWalletAddress() - if wallet == "" { - fmt.Println("ERROR: No wallet configured. Use 'config wallet import' first.") - return - } - o.getApps(ctx, nil, &wallet) - - case "register-app": - if len(args) < 2 { - fmt.Println("ERROR: Usage: register-app [no-approval]") - fmt.Println("INFO: Pass 'no-approval' as second arg to allow session creation without owner approval") - return - } - noApproval := len(args) >= 3 && args[2] == "no-approval" - o.registerApp(ctx, args[1], "", noApproval) - - // User action allowances - case "action-allowances": - wallet := "" - if len(args) >= 2 { - wallet = args[1] - } else { - wallet = o.getImportedWalletAddress() - if wallet == "" { - fmt.Println("ERROR: Usage: action-allowances ") - fmt.Println("INFO: No wallet configured. Use 'config wallet import' first or specify a wallet address.") - return - } - fmt.Printf("INFO: Using configured wallet: %s\n", wallet) - } - o.getActionAllowances(ctx, wallet) - // App sessions case "app-sessions": wallet := o.getImportedWalletAddress() o.listAppSessions(ctx, wallet) - - // Security token operations - case "security-token": - if len(args) < 2 { - fmt.Println("ERROR: Usage: security-token ...") - fmt.Println("Commands: approve, balance, escrow, initiate-withdrawal, cancel-withdrawal, withdraw") - return - } - switch args[1] { - case "approve": - if len(args) < 4 { - fmt.Println("ERROR: Usage: security-token approve ") - return - } - o.approveSecurityToken(ctx, args[2], args[3]) - case "escrow": - if len(args) < 4 { - fmt.Println("ERROR: Usage: security-token escrow [target_address] ") - fmt.Println("INFO: If target_address is omitted, your own wallet is used.") - return - } - if len(args) >= 5 { - o.escrowSecurityTokens(ctx, args[2], args[3], args[4]) - } else { - o.escrowSecurityTokens(ctx, args[2], "", args[3]) - } - case "initiate-withdrawal": - if len(args) < 3 { - fmt.Println("ERROR: Usage: security-token initiate-withdrawal ") - return - } - o.initiateSecurityWithdrawal(ctx, args[2]) - case "cancel-withdrawal": - if len(args) < 3 { - fmt.Println("ERROR: Usage: security-token cancel-withdrawal ") - return - } - o.cancelSecurityWithdrawal(ctx, args[2]) - case "withdraw": - if len(args) < 4 { - fmt.Println("ERROR: Usage: security-token withdraw ") - return - } - o.withdrawSecurityTokens(ctx, args[2], args[3]) - case "balance": - if len(args) < 3 { - fmt.Println("ERROR: Usage: security-token balance [wallet_address]") - return - } - wallet := "" - if len(args) >= 4 { - wallet = args[3] - } else { - wallet = o.getImportedWalletAddress() - if wallet == "" { - fmt.Println("ERROR: No wallet configured. Use 'config wallet import' first or specify a wallet address.") - return - } - fmt.Printf("INFO: Using configured wallet: %s\n", wallet) - } - o.securityBalance(ctx, args[2], wallet) - default: - fmt.Printf("ERROR: Unknown security-token command: %s\n", args[1]) - fmt.Println("Commands: approve, balance, escrow, initiate-withdrawal, cancel-withdrawal, withdraw") - } - case "exit": // Actual exit is driven by go-prompt's ExitChecker on "exit" input // (see cerebro/main.go). Closing exitCh here would race the prompt @@ -786,6 +668,31 @@ func (o *Operator) getWalletSuggestion() []prompt.Suggest { } } +// confirmationDelayForAsset resolves the confirmation_delay_secs for the chain that the +// given asset settles on. It returns (delay, true) on success and (0, false) when the +// chain cannot be resolved (no home channel yet, RPC error, chain not in node config). +// Callers must treat this as advisory — never gate a successful operation on it. +func (o *Operator) confirmationDelayForAsset(ctx context.Context, asset string) (uint32, bool) { + wallet := o.getImportedWalletAddress() + if wallet == "" { + return 0, false + } + channel, err := o.client.GetHomeChannel(ctx, wallet, asset) + if err != nil || channel == nil { + return 0, false + } + chains, err := o.client.GetBlockchains(ctx) + if err != nil { + return 0, false + } + for _, c := range chains { + if c.ID == channel.BlockchainID { + return c.ConfirmationDelaySecs, true + } + } + return 0, false +} + func (o *Operator) parseChainID(chainIDStr string) (uint64, error) { chainID, err := strconv.ParseUint(chainIDStr, 10, 64) if err != nil { diff --git a/contracts/SECURITY.md b/contracts/SECURITY.md index fe8b92719..fd5e14405 100644 --- a/contracts/SECURITY.md +++ b/contracts/SECURITY.md @@ -51,6 +51,36 @@ Invariant: Invariant: > Credits accrued while a channel was in `CHALLENGED` status are preserved — either reintegrated into the channel on challenge clearance, or carried into the next epoch on closure — and cannot be shadowed by a concurrently-opened replacement channel. +--- + +8. App session closure is **atomic across all participants**. The Node issues a new release receive-state on every participant's home channel as part of a single close transaction; a failure on any single participant aborts the entire close, leaving the session open and no release credits issued to anyone. + +The Node refuses to issue a release state when the recipient's most recent signed state encodes an **escrow operation that `EnsureNoOngoingEscrowOperation` does not yet treat as safely settled**. In practice this covers: + +* any pending `escrow_lock` or `mutual_lock` (always considered unresolved until superseded); +* `escrow_deposit` or `escrow_withdraw` whose on-chain escrow-channel version has not caught up with the signed state version — with a narrow one-version-behind allowance for `escrow_deposit` while the escrow channel is `Open` or `Closed` (the steady state during a normal finalize/purge cycle). + +The release receive-state is stacked on top of the recipient's most recent signed state. If that state encodes an unfinalized escrow, signing a release on top risks state-chain invariant violations should the escrow ultimately revert or settle to a different version than was assumed when the release was issued. The Node therefore blocks until the escrow resolves rather than risking a co-signed credit that cannot be enforced on-chain. + +Consequence: a single participant whose escrow has not yet completed — whether due to slow on-chain confirmation, an active challenge, or deliberate non-finalization — blocks the cooperative close for **all** other participants in the session. Remaining participants have two recovery paths: + +* wait for the blocking participant's escrow to resolve and retry the close, +* if the session's state machine permits intermediate updates, individually unwind their share via off-chain transfers out of the session and re-attempt closure with the remaining (non-blocked) members. + +This is an accepted trade-off for now, which may be lifted in the future: protocol safety (no release state co-signed against a potentially-reverting escrow chain) takes precedence over close-time liveness. Sessions whose participants require independent exit guarantees should be designed with state machines that support partial settlement rather than relying solely on cooperative close. + +Unlike the `CHALLENGED` channel path (rule 6) — where the release issuer **does** store unsigned releases for non-`Open` home channels so the rescue squash can pick them up at close — the close path does **not** extend that unsigned-fallback pattern to the escrow-rejected case. When `EnsureNoOngoingEscrowOperation` rejects a participant, the entire close aborts rather than queueing an unsigned release on top of an in-flight escrow. Extending the fallback here is a possible future improvement but is non-trivial: the safety of stacking an unsigned release on an unfinalized escrow depends on later reconciling the eventual escrow outcome with the queued credit, and the current implementation prefers refusal over partial trust. + +Invariant: +> A single participant with a pending escrow operation can block cooperative closure of an app session for all other participants until the escrow resolves; no participant receives a release credit while the close is blocked. + +--- + +9. The Node processes on-chain channel-lifecycle events with per-channel version monotonicity. An event whose `StateVersion` is strictly less than the row's current `StateVersion` is dropped with a structured warn log (see `nitronode/event_handlers/service.go`). For home-channel events (`ChannelChallenged`, `ChannelCheckpointed`, `ChannelClosed`), a dropped event additionally triggers an on-chain `getChannelData` read via the `ChainStateRefresher` (`pkg/blockchain/evm/chain_state_refresher.go`, interface in `pkg/core/interface.go`) that overwrites the local row's `Status`, `StateVersion`, and `ChallengeExpiresAt`. This is defense-in-depth against out-of-order event delivery from indexer mis-order, reorg replay, or any future contract change that re-introduces a same-transaction event-order quirk. Escrow event handlers enforce the guard without the refresh hook; cross-chain RPC plumbing for escrow refresh is a deferred follow-up item. Pending its arrival, escrow rows can remain divergent from chain across an interim window until the next on-chain event arrives. + +Invariant: +> The Node's local `channels.state_version` is monotonic per `channelId`. After any dropped lifecycle event for a home channel, the Node row converges with on-chain authoritative state without manual intervention. + ## Invariants --- @@ -185,6 +215,12 @@ no funds can be permanently locked if it does. --- +### Reentrancy + +26. **Lifecycle reentrancy guard**: Every external/public function in `ChannelHub` that mutates lifecycle state is guarded by `nonReentrant` modifier. This prevents cross-function reentrancy via inbound token hooks (ERC777-style `tokensReceived`, non-standard `transferFrom` callbacks) from interleaving lifecycle operations during a `_pullFunds` call. The outbound side remains additionally protected by the `TRANSFER_GAS_LIMIT = 100_000` cap, which prevents recipient hooks from completing a reentrant lifecycle call within the forwarded gas budget. + +--- + ### ChannelClosed event orientation during abandoned migration `initiateMigration()` on the new home chain swaps `homeLedger` ↔ `nonHomeLedger` before storing the state, so that `homeLedger` always represents the chain where execution happens. A consequence of this swap is that `meta.lastState` on the new home chain is stored in opposite orientation from what both parties signed. @@ -235,6 +271,14 @@ In the single-node deployment model, the ChannelHub is deployed by the Node oper - **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. +### Asset-symbol equivalence + +The unified asset model treats every chain-specific token configured under a single asset symbol as a fully fungible 1:1 representation of the same asset. Off-chain credit denominated in an asset can therefore be redeemed from any token inventory sharing that symbol, independent of which inventory originally backed it. The validator binds unchanneled credit to the chain/token chosen at first channel creation, enforcing only that the asset symbol matches — not that the tokens are economically equivalent. + +This is intended behaviour: it enables cross-chain redemption. The only harmful configuration is an operator mapping economically non-equivalent tokens under one symbol (e.g. a test token and production USDC), which would let credit sourced from the cheap inventory be redeemed against the valuable one. + +**The Node operator MUST configure only economically equivalent (1:1 redeemable) tokens under a single asset symbol.** Token equivalence cannot be verified programmatically and is an operator configuration responsibility. See `docs/protocol/security-and-limitations.md` for the full trust-assumption statement. + --- ## Signature Validation Security @@ -532,6 +576,8 @@ Inbound transfer failures occur during: **Mitigation**: The Nitronode only processes operations after observing successful on-chain events. If a user signs a deposit state but the transfer fails on-chain, the state is never enforced, and the Node does not provide services based on unconfirmed deposits. +**Reentrancy via inbound hooks**: Tokens whose `transferFrom` invokes a recipient hook (ERC777-style `tokensReceived`, ERC1363 callbacks, or non-standard ERC20 implementations) could in principle re-enter `ChannelHub` lifecycle entrypoints during an inbound pull. The `nonReentrant` guard on every lifecycle entrypoint blocks this class of attack at the contract layer — see invariant 26 above and `contracts/src/ChannelHub.sol`. Coverage splits by deployment vintage: future deployments built from commit `2a6a9f0d` or later carry the guard and are protected at the contract layer; currently-deployed contracts at the addresses listed in `contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md` predate the guard and are protected only by the off-chain "no hook-bearing tokens" onboarding policy enforced by the Node operator. + --- ### Outbound Transfer Failures (ChannelHub → User) diff --git a/contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md b/contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md new file mode 100644 index 000000000..7ba71d48e --- /dev/null +++ b/contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md @@ -0,0 +1,36 @@ +# ChannelHub Deployments — Hook-Token Support + +The `ChannelHub` deployments listed in the matrix below **do not support +hook-enabled tokens**. The following token classes MUST NOT be onboarded to +any of these deployments: + +- **ERC777** (e.g. `imBTC`, legacy `xDAI`) +- **ERC1363 / ERC677** +- **Non-standard ERC20 with re-entrant `transferFrom`** (some rebasing or + fee-on-transfer tokens with callbacks) + +Enforcement is the responsibility of the Node operator. This constraint may +be lifted on future deployments to new chains; each new deployment must be +added to the matrix below with its support status recorded explicitly. + +Last reviewed: 2026-06-15. + +## Matrix + +| Chain ID | Chain | ChannelHub Address | Deploy Commit | Deploy Tag | +| ---: | --- | --- | --- | --- | +| 1 | Ethereum | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 14 | Flare | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 56 | BNB Smart Chain | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 137 | Polygon | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 480 | World Chain | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 8453 | Base | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 59144 | Linea | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 80002 | Polygon Amoy | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 | +| 84532 | Base Sepolia | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 | +| 84532 | Base Sepolia | `0x61b9e0767f2eca7e33802e82f9c64b1ebe72ba31` | `9110ba06` | stress v1.3.0 | +| 59141 | Linea Sepolia | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 | +| 1440000 | XRPL EVM | `0x1a2f750170474d4c54f8d318d9d4343588b4c4d1` | `e07ad9c2` | prod v1.3.0 | +| 1449000 | XRPL EVM Testnet | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 | +| 11155111 | Sepolia | `0x5dba8515af063db0c243c15ece7b99f91459c7c3` | `b88d511c` | sandbox v1.3.0 | +| 11155111 | Sepolia | `0x7d61ec428cfae560f43647af567ea7c6e2cc5527` | `104c13df` | stress v1.3.0 | diff --git a/contracts/src/ChannelHub.sol b/contracts/src/ChannelHub.sol index ea448cadb..9126ef3cc 100644 --- a/contracts/src/ChannelHub.sol +++ b/contracts/src/ChannelHub.sol @@ -351,7 +351,7 @@ contract ChannelHub is ReentrancyGuard { // inflate _nodeBalances during ERC777/hook callbacks, enabling read-only reentrancy for // external protocols querying getNodeBalance(). Contrast with withdrawFromNode, which uses // CEI (decrement before push) to prevent re-entrancy drains. - function depositToNode(address token, uint256 amount) external payable { + function depositToNode(address token, uint256 amount) external payable nonReentrant { require(amount > 0, IncorrectAmount()); _pullFunds(msg.sender, token, amount); @@ -363,7 +363,7 @@ contract ChannelHub is ReentrancyGuard { emit NodeBalanceUpdated(token, updatedBalance); } - function withdrawFromNode(address to, address token, uint256 amount) external { + function withdrawFromNode(address to, address token, uint256 amount) external nonReentrant { require(to != address(0), InvalidAddress()); require(amount > 0, IncorrectAmount()); require(msg.sender == NODE, IncorrectMsgSender()); @@ -445,7 +445,7 @@ contract ChannelHub is ReentrancyGuard { } } - function purgeEscrowDeposits(uint256 maxSteps) external { + function purgeEscrowDeposits(uint256 maxSteps) external nonReentrant { _purgeEscrowDeposits(maxSteps); } @@ -549,7 +549,7 @@ contract ChannelHub is ReentrancyGuard { // This enables users who already have off-chain virtual states with non-zero version // to create a channel and perform initial operation simultaneously // NOTE: For native ETH channels with DEPOSIT intent, msg.sender must supply msg.value == deposit amount. - function createChannel(ChannelDefinition calldata def, State calldata initState) external payable { + function createChannel(ChannelDefinition calldata def, State calldata initState) external payable nonReentrant { require( initState.intent == StateIntent.DEPOSIT || initState.intent == StateIntent.WITHDRAW || initState.intent == StateIntent.OPERATE, @@ -587,7 +587,7 @@ contract ChannelHub is ReentrancyGuard { } // NOTE: For native ETH channels, msg.sender must supply msg.value == deposit amount. - function depositToChannel(bytes32 channelId, State calldata candidate) public payable { + function depositToChannel(bytes32 channelId, State calldata candidate) public payable nonReentrant { require(candidate.intent == StateIntent.DEPOSIT, IncorrectStateIntent()); ChannelDefinition memory def = _channels[channelId].definition; @@ -602,7 +602,7 @@ contract ChannelHub is ReentrancyGuard { emit ChannelDeposited(channelId, candidate); } - function withdrawFromChannel(bytes32 channelId, State calldata candidate) public { + function withdrawFromChannel(bytes32 channelId, State calldata candidate) public nonReentrant { require(candidate.intent == StateIntent.WITHDRAW, IncorrectStateIntent()); ChannelDefinition memory def = _channels[channelId].definition; @@ -617,7 +617,7 @@ contract ChannelHub is ReentrancyGuard { emit ChannelWithdrawn(channelId, candidate); } - function checkpointChannel(bytes32 channelId, State calldata candidate) external { + function checkpointChannel(bytes32 channelId, State calldata candidate) external nonReentrant { require(candidate.intent == StateIntent.OPERATE, IncorrectStateIntent()); // Can only checkpoint operate states ChannelDefinition memory def = _channels[channelId].definition; @@ -637,7 +637,7 @@ contract ChannelHub is ReentrancyGuard { State calldata candidate, bytes calldata challengerSig, ParticipantIndex challengerIdx - ) external payable { + ) external payable nonReentrant { ChannelMeta storage meta = _channels[channelId]; ChannelDefinition memory def = meta.definition; ChannelStatus status = meta.status; @@ -686,7 +686,7 @@ contract ChannelHub is ReentrancyGuard { emit ChannelChallenged(channelId, candidate, challengeExpiry); } - function closeChannel(bytes32 channelId, State calldata candidate) external { + function closeChannel(bytes32 channelId, State calldata candidate) external nonReentrant { ChannelMeta storage meta = _channels[channelId]; ChannelDefinition memory def = meta.definition; ChannelStatus status = meta.status; @@ -726,7 +726,11 @@ contract ChannelHub is ReentrancyGuard { // ========= Cross-Chain Functions ========== // NOTE: On non-home chain, user funds are pulled. For native ETH, msg.sender must supply msg.value == deposit amount. - function initiateEscrowDeposit(ChannelDefinition calldata def, State calldata candidate) external payable { + function initiateEscrowDeposit(ChannelDefinition calldata def, State calldata candidate) + external + payable + nonReentrant + { require(candidate.intent == StateIntent.INITIATE_ESCROW_DEPOSIT, IncorrectStateIntent()); _requireValidDefinition(def); @@ -757,6 +761,7 @@ contract ChannelHub is ReentrancyGuard { function challengeEscrowDeposit(bytes32 escrowId, bytes calldata challengerSig, ParticipantIndex challengerIdx) external + nonReentrant { EscrowDepositMeta storage meta = _escrowDeposits[escrowId]; bytes32 channelId = meta.channelId; @@ -777,7 +782,10 @@ contract ChannelHub is ReentrancyGuard { emit EscrowDepositChallenged(escrowId, meta.initState, effects.newChallengeExpiry); } - function finalizeEscrowDeposit(bytes32 channelId, bytes32 escrowId, State calldata candidate) external { + function finalizeEscrowDeposit(bytes32 channelId, bytes32 escrowId, State calldata candidate) + external + nonReentrant + { if (_isEscrowDepositHomeChain(channelId, escrowId)) { // HOME CHAIN: Get user from channel definition require(candidate.intent == StateIntent.FINALIZE_ESCROW_DEPOSIT, IncorrectStateIntent()); @@ -828,7 +836,7 @@ contract ChannelHub is ReentrancyGuard { emit EscrowDepositFinalized(escrowId, channelId, candidate); } - function initiateEscrowWithdrawal(ChannelDefinition calldata def, State calldata candidate) external { + function initiateEscrowWithdrawal(ChannelDefinition calldata def, State calldata candidate) external nonReentrant { require(candidate.intent == StateIntent.INITIATE_ESCROW_WITHDRAWAL, IncorrectStateIntent()); _requireValidDefinition(def); @@ -858,6 +866,7 @@ contract ChannelHub is ReentrancyGuard { function challengeEscrowWithdrawal(bytes32 escrowId, bytes calldata challengerSig, ParticipantIndex challengerIdx) external + nonReentrant { EscrowWithdrawalMeta storage meta = _escrowWithdrawals[escrowId]; bytes32 channelId = meta.channelId; @@ -878,7 +887,10 @@ contract ChannelHub is ReentrancyGuard { emit EscrowWithdrawalChallenged(escrowId, meta.initState, effects.newChallengeExpiry); } - function finalizeEscrowWithdrawal(bytes32 channelId, bytes32 escrowId, State calldata candidate) external { + function finalizeEscrowWithdrawal(bytes32 channelId, bytes32 escrowId, State calldata candidate) + external + nonReentrant + { if (_isEscrowWithdrawalHomeChain(channelId, escrowId)) { // HOME CHAIN: Get user from channel definition require(candidate.intent == StateIntent.FINALIZE_ESCROW_WITHDRAWAL, IncorrectStateIntent()); @@ -933,7 +945,7 @@ contract ChannelHub is ReentrancyGuard { emit EscrowWithdrawalFinalized(escrowId, channelId, candidate); } - function initiateMigration(ChannelDefinition calldata def, State calldata candidate) external { + function initiateMigration(ChannelDefinition calldata def, State calldata candidate) external nonReentrant { require(candidate.intent == StateIntent.INITIATE_MIGRATION, IncorrectStateIntent()); bytes32 channelId = Utils.getChannelId(def, VERSION); @@ -968,7 +980,7 @@ contract ChannelHub is ReentrancyGuard { } } - function finalizeMigration(bytes32 channelId, State calldata candidate) external { + function finalizeMigration(bytes32 channelId, State calldata candidate) external nonReentrant { require(candidate.intent == StateIntent.FINALIZE_MIGRATION, IncorrectStateIntent()); ChannelDefinition memory def = _channels[channelId].definition; @@ -1429,7 +1441,7 @@ contract ChannelHub is ReentrancyGuard { } } - function _pullFunds(address from, address token, uint256 amount) internal nonReentrant { + function _pullFunds(address from, address token, uint256 amount) internal { if (amount == 0) return; _requireMsgValueForPull(token, amount); @@ -1441,7 +1453,7 @@ contract ChannelHub is ReentrancyGuard { /// @dev Reverts if the transfer fails. Used in non-adversarial contexts where atomicity is required /// (e.g. voluntary vault withdrawals where the caller controls the destination). - function _pushFunds(address to, address token, uint256 amount) internal nonReentrant { + function _pushFunds(address to, address token, uint256 amount) internal { if (amount == 0) return; if (token == address(0)) { @@ -1454,7 +1466,7 @@ contract ChannelHub is ReentrancyGuard { /// @dev Never reverts. On failure, accumulates funds in `_reclaims[to]` for later recovery via `claimFunds()`. /// Used in adversarial contexts (e.g. channel settlement) where a reverting recipient must not block progress. - function _nonRevertingPushFunds(address to, address token, uint256 amount) internal nonReentrant { + function _nonRevertingPushFunds(address to, address token, uint256 amount) internal { if (amount == 0) return; if (token == address(0)) { diff --git a/contracts/test/ChannelHub_reentrancy.t.sol b/contracts/test/ChannelHub_reentrancy.t.sol new file mode 100644 index 000000000..7abf115cf --- /dev/null +++ b/contracts/test/ChannelHub_reentrancy.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {Test} from "forge-std/Test.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; + +import {ReentrantERC20} from "./mocks/ReentrantERC20.sol"; +import {TestUtils} from "./TestUtils.sol"; + +import {ChannelHub} from "../src/ChannelHub.sol"; +import {ECDSAValidator} from "../src/sigValidators/ECDSAValidator.sol"; +import { + ChannelDefinition, + ChannelStatus, + DEFAULT_SIG_VALIDATOR_ID, + Ledger, + ParticipantIndex, + State, + StateIntent +} from "../src/interfaces/Types.sol"; +import {ISignatureValidator} from "../src/interfaces/ISignatureValidator.sol"; +import {Utils} from "../src/Utils.sol"; + +/** + * @notice Regression tests for MF3-L19 (audit) — reentrancy via hook-bearing ERC20 tokens during + * the inbound `_pullFunds` callback. Each test exercises one of the four scenarios from + * `reentrancy-finding-validation.md`: + * 1. Outer DEPOSIT pull → inner lifecycle call. + * 2. Outer `challengeChannel(newer-version)` pull → inner lifecycle call. + * 3. Outer escrow-deposit pull → inner lifecycle call. + * 4. Outer `createChannel(DEPOSIT)` pull → inner lifecycle call. + * + * The malicious token (`ReentrantERC20`) calls back into `ChannelHub` from inside + * `transferFrom` before the outer's state mutations and event emit. The expected behavior + * after the MF3-L19 remediation is that OpenZeppelin's `nonReentrant` modifier on the + * outer lifecycle function rejects the inner call with `ReentrancyGuardReentrantCall`. + * + * To make these tests target the guard rather than secondary checks, the inner reentry + * payloads are calls that would otherwise reach guarded code paths quickly. We do not + * require the inner call to be fully signed — the guard fires before any signature + * validation. + */ +// forge-lint: disable-next-item(unsafe-typecast) +contract ChannelHubTest_Reentrancy is Test { + ChannelHub public cHub; + ReentrantERC20 public token; + + uint256 constant NODE_PK = 1; + uint256 constant ALICE_PK = 2; + + address node; + address alice; + + ISignatureValidator immutable ECDSA_SIG_VALIDATOR = new ECDSAValidator(); + + uint8 constant CHANNEL_HUB_VERSION = 1; + uint32 constant CHALLENGE_DURATION = 86400; + uint64 constant NONCE = 1; + uint256 constant INITIAL_BALANCE = 10000; + + bytes4 immutable REENTRANCY_GUARD_SELECTOR = ReentrancyGuard.ReentrancyGuardReentrantCall.selector; + + function setUp() public { + node = vm.addr(NODE_PK); + alice = vm.addr(ALICE_PK); + + cHub = new ChannelHub(ECDSA_SIG_VALIDATOR, node); + token = new ReentrantERC20("Reentrant Token", "REENT"); + + token.mint(node, INITIAL_BALANCE); + token.mint(alice, INITIAL_BALANCE); + + vm.startPrank(node); + token.approve(address(cHub), INITIAL_BALANCE); + cHub.depositToNode(address(token), INITIAL_BALANCE); + vm.stopPrank(); + + vm.prank(alice); + token.approve(address(cHub), INITIAL_BALANCE); + } + + // ========== Helpers ========== + + function _buildDef() internal view returns (ChannelDefinition memory) { + return ChannelDefinition({ + challengeDuration: CHALLENGE_DURATION, + user: alice, + node: node, + nonce: NONCE, + approvedSignatureValidators: 1, + metadata: bytes32(0) + }); + } + + function _signMutual(State memory state, bytes32 channelId) internal pure returns (State memory) { + state.userSig = TestUtils.signStateEip191WithEcdsaValidator(vm, channelId, state, ALICE_PK); + state.nodeSig = TestUtils.signStateEip191WithEcdsaValidator(vm, channelId, state, NODE_PK); + return state; + } + + function _initialDepositState(uint256 amount) internal view returns (State memory) { + return State({ + version: 0, + intent: StateIntent.DEPOSIT, + metadata: bytes32(0), + homeLedger: Ledger({ + chainId: uint64(block.chainid), + token: address(token), + decimals: 18, + userAllocation: amount, + userNetFlow: int256(amount), + nodeAllocation: 0, + nodeNetFlow: 0 + }), + nonHomeLedger: Ledger({ + chainId: 0, + token: address(0), + decimals: 0, + userAllocation: 0, + userNetFlow: 0, + nodeAllocation: 0, + nodeNetFlow: 0 + }), + userSig: "", + nodeSig: "" + }); + } + + function _openChannel(uint256 initialAmount) internal returns (bytes32 channelId, State memory state) { + ChannelDefinition memory def = _buildDef(); + channelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION); + state = _initialDepositState(initialAmount); + state = _signMutual(state, channelId); + + vm.prank(alice); + cHub.createChannel(def, state); + } + + // ========== Scenario 1: createChannel(DEPOSIT) outer → inner depositToChannel ========== + + function test_reentrancy_createChannel_rejectsInnerDepositToChannel() public { + ChannelDefinition memory def = _buildDef(); + bytes32 channelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION); + State memory state = _initialDepositState(1000); + state = _signMutual(state, channelId); + + // Inner call: depositToChannel with empty State data. The guard fires before any decoding, + // so the precise inner state shape is irrelevant. + State memory empty; + bytes memory innerCalldata = abi.encodeCall(ChannelHub.depositToChannel, (channelId, empty)); + token.armReentry(address(cHub), innerCalldata); + + vm.prank(alice); + cHub.createChannel(def, state); + + assertFalse(token.lastReentrySucceeded(), "inner depositToChannel must be rejected"); + bytes memory ret = token.lastReentryReturnData(); + assertGe(ret.length, 4, "inner revert returndata must contain a selector"); + bytes4 sel; + assembly { + sel := mload(add(ret, 0x20)) + } + assertEq(sel, REENTRANCY_GUARD_SELECTOR, "inner revert must be ReentrancyGuardReentrantCall"); + } + + // ========== Scenario 2: depositToChannel outer → inner checkpointChannel ========== + + function test_reentrancy_depositToChannel_rejectsInnerCheckpointChannel() public { + (bytes32 channelId, State memory state) = _openChannel(1000); + + // Build the next state — DEPOSIT bump. + State memory next = + TestUtils.nextState(state, StateIntent.DEPOSIT, [uint256(1500), uint256(0)], [int256(1500), int256(0)]); + next = _signMutual(next, channelId); + + State memory empty; + bytes memory innerCalldata = abi.encodeCall(ChannelHub.checkpointChannel, (channelId, empty)); + token.armReentry(address(cHub), innerCalldata); + + vm.prank(alice); + cHub.depositToChannel(channelId, next); + + assertFalse(token.lastReentrySucceeded(), "inner checkpointChannel must be rejected"); + bytes memory ret = token.lastReentryReturnData(); + assertGe(ret.length, 4, "inner revert returndata must contain a selector"); + bytes4 sel; + assembly { + sel := mload(add(ret, 0x20)) + } + assertEq(sel, REENTRANCY_GUARD_SELECTOR, "inner revert must be ReentrancyGuardReentrantCall"); + } + + // ========== Scenario 3: challengeChannel(newer-version) outer → inner closeChannel ========== + + function test_reentrancy_challengeChannel_newVersion_rejectsInnerCloseChannel() public { + (bytes32 channelId, State memory state) = _openChannel(1000); + + // Newer DEPOSIT state to force pull (`challengeChannel` invokes `_applyTransitionEffects` + // for higher-version candidates, which calls `_pullFunds` on a DEPOSIT intent). + State memory newer = + TestUtils.nextState(state, StateIntent.DEPOSIT, [uint256(1500), uint256(0)], [int256(1500), int256(0)]); + newer = _signMutual(newer, channelId); + + // Challenger sig is over `signingData || "challenge"` (see ChannelHub_Base helper). + bytes memory signingData = Utils.toSigningData(newer); + bytes memory challengerSigningData = abi.encodePacked(signingData, "challenge"); + bytes memory challengerSigPayload = + TestUtils.signEip191(vm, ALICE_PK, Utils.pack(channelId, challengerSigningData)); + bytes memory challengerSig = abi.encodePacked(DEFAULT_SIG_VALIDATOR_ID, challengerSigPayload); + + State memory empty; + bytes memory innerCalldata = abi.encodeCall(ChannelHub.closeChannel, (channelId, empty)); + token.armReentry(address(cHub), innerCalldata); + + vm.prank(alice); + cHub.challengeChannel(channelId, newer, challengerSig, ParticipantIndex.USER); + + assertFalse(token.lastReentrySucceeded(), "inner closeChannel must be rejected"); + bytes memory ret = token.lastReentryReturnData(); + assertGe(ret.length, 4, "inner revert returndata must contain a selector"); + bytes4 sel; + assembly { + sel := mload(add(ret, 0x20)) + } + assertEq(sel, REENTRANCY_GUARD_SELECTOR, "inner revert must be ReentrancyGuardReentrantCall"); + } + + // ========== Scenario 4: initiateEscrowDeposit (non-home chain) outer → inner purgeEscrowDeposits ========== + + function test_reentrancy_initiateEscrowDeposit_nonHome_rejectsInnerPurge() public { + // Construct an escrow-deposit candidate where homeLedger is on a different chain id so the + // current chain is the non-home chain (pull path triggered). + ChannelDefinition memory def = _buildDef(); + bytes32 channelId = Utils.getChannelId(def, CHANNEL_HUB_VERSION); + + State memory candidate = State({ + version: 1, + intent: StateIntent.INITIATE_ESCROW_DEPOSIT, + metadata: bytes32(0), + homeLedger: Ledger({ + chainId: uint64(block.chainid) + 1, // home is a different chain + token: address(token), + decimals: 18, + userAllocation: 0, + userNetFlow: 0, + nodeAllocation: 500, + nodeNetFlow: -500 + }), + nonHomeLedger: Ledger({ + chainId: uint64(block.chainid), // non-home is this chain → pull occurs + token: address(token), + decimals: 18, + userAllocation: 500, + userNetFlow: 500, + nodeAllocation: 0, + nodeNetFlow: 0 + }), + userSig: "", + nodeSig: "" + }); + candidate = _signMutual(candidate, channelId); + + bytes memory innerCalldata = abi.encodeCall(ChannelHub.purgeEscrowDeposits, (uint256(1))); + token.armReentry(address(cHub), innerCalldata); + + vm.prank(alice); + cHub.initiateEscrowDeposit(def, candidate); + + assertFalse(token.lastReentrySucceeded(), "inner purgeEscrowDeposits must be rejected"); + bytes memory ret = token.lastReentryReturnData(); + assertGe(ret.length, 4, "inner revert returndata must contain a selector"); + bytes4 sel; + assembly { + sel := mload(add(ret, 0x20)) + } + assertEq(sel, REENTRANCY_GUARD_SELECTOR, "inner revert must be ReentrancyGuardReentrantCall"); + } +} diff --git a/contracts/test/mocks/ReentrantERC20.sol b/contracts/test/mocks/ReentrantERC20.sol new file mode 100644 index 000000000..e26471aa6 --- /dev/null +++ b/contracts/test/mocks/ReentrantERC20.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.30; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/** + * @title ReentrantERC20 + * @notice Mock ERC20 token whose transferFrom calls back into a configured target with configured + * calldata before completing the transfer. Used to simulate ERC777-style `tokensToSend` + * hooks for reentrancy testing of ChannelHub lifecycle functions. + * @dev The reentry fires exactly once: the contract clears its reentry config on each transferFrom + * so the inner call (which itself triggers transferFrom on guarded paths) does not recurse + * indefinitely. The reentry call's success/return data is captured in + * `lastReentryReturnData` / `lastReentrySucceeded` so tests can assert on the inner outcome. + */ +contract ReentrantERC20 is ERC20 { + address public reentryTarget; + bytes public reentryCalldata; + bool public lastReentrySucceeded; + bytes public lastReentryReturnData; + bool public reentryArmed; + + constructor(string memory name, string memory symbol) ERC20(name, symbol) {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + /// @notice Arm the token to perform a single reentry call into `target` with `data` on the + /// next `transferFrom` invocation. The reentry hook fires before the underlying ERC20 state + /// is mutated, mirroring an ERC777 `tokensToSend` callback. + function armReentry(address target, bytes calldata data) external { + reentryTarget = target; + reentryCalldata = data; + reentryArmed = true; + } + + function transferFrom(address from, address to, uint256 amount) public override returns (bool) { + if (reentryArmed) { + reentryArmed = false; + address target = reentryTarget; + bytes memory data = reentryCalldata; + (bool ok, bytes memory ret) = target.call(data); + lastReentrySucceeded = ok; + lastReentryReturnData = ret; + } + return super.transferFrom(from, to, amount); + } +} diff --git a/docs/README.md b/docs/README.md index f2019d116..d643fc93b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,13 +32,11 @@ The following communication flows are not yet documented: ```text cerebro/ # Cerebro Testing Client nitronode/ - action_gateway/ # Rate limiting via gated actions api/ app_session_v1/ # App session endpoints (create, deposit, operate, withdraw, close) - apps_v1/ # Application registry endpoints channel_v1/ # Channel endpoints (create, submit_state, get_state, transfer) node_v1/ # Node info endpoints - user_v1/ # User endpoints (balances, staking) + user_v1/ # User endpoints (balances, transactions) config/ migrations/ postgres/ # Goose SQL migrations (embedded at compile time) @@ -50,7 +48,7 @@ nitronode/ blockchain_worker.go # Processes pending BlockchainAction records runtime.go # Embeds migrations, initializes services main.go # Entry point, EVM listeners, metric exporters -contracts/ # Smart contracts (ChannelHub, Locking, etc.) +contracts/ # Smart contracts (ChannelHub, ChannelEngine, escrow engines) docs/ # This directory pkg/ app/ # App session types (AppSessionStatus, quorum, allocations) diff --git a/docs/api.yaml b/docs/api.yaml index 0188f4f26..1a81659a5 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -159,7 +159,7 @@ types: fields: - name: application_id type: string - description: Application identifier from an app registry + description: Client-chosen application identifier (must match the application ID format). Not validated against any registry. - name: participants type: array items: @@ -193,7 +193,7 @@ types: description: A unique application session identifier - name: intent type: string - description: The intent of the app session update (operate, deposit, withdraw, close, rebalance) + description: The intent of the app session update (operate, deposit, withdraw, close) - name: version type: string description: Version of the app state @@ -270,6 +270,9 @@ types: - name: contract_address type: string description: Address of the main contract on this blockchain + - name: confirmation_delay_secs + type: integer + description: Per-chain reorg-protection window in seconds; events are buffered for this duration before being committed. 0 disables the gate. See nitronode/docs/reorg-fix.md. - balance_entry: description: Balance for a specific asset @@ -297,9 +300,9 @@ types: - escrow_lock - escrow_withdraw - migrate - - rebalance - finalize - challenge_rescue + - unknown # fallback for legacy/removed types in response rows (e.g. former rebalance=42); not a valid request filter value - transaction: description: Transaction record @@ -391,7 +394,10 @@ types: description: > Session-key holder's EIP-191 signature over the same payload as user_sig — abi.encode(SESSION_KEY_AUTH_TYPEHASH, session_key, metadata_hash) — proving possession of the key - being registered. Required on every submit to prevent registration of keys the submitter does not control. + being registered. Required when expires_at is in the future (registration, update, re-activation) + to prevent registration of keys the submitter does not control. Optional for revocation + (expires_at at or before now): a wallet may revoke unilaterally with user_sig alone so a lost or + uncooperative session key cannot block revocation. - app_session_key_state: description: Represents the state of an app session key @@ -409,12 +415,20 @@ types: type: array items: type: string - description: Application IDs associated with this session key + description: > + Application IDs associated with this session key. Each entry must + match ^[a-z0-9_-]{1,66}$ (lowercase letters, digits, dashes, and + underscores, max 66 chars). Malformed entries are rejected before + signature verification. - name: app_session_id type: array items: type: string - description: Application session IDs associated with this session key + description: > + Application session IDs associated with this session key. Each entry + must be a 0x-prefixed 32-byte hash in lowercase canonical form + (^0x[0-9a-f]{64}$). Checksummed or uppercase hex is rejected before + signature verification. - name: expires_at type: string description: Unix timestamp in seconds indicating when the session key expires @@ -423,67 +437,7 @@ types: 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 - fields: - - name: id - type: string - description: Application identifier - - name: owner_wallet - type: string - description: Owner's wallet address - - name: metadata - type: string - description: Application metadata (bytes32 hash) - - name: version - type: string - description: Current version of the application - - name: creation_approval_not_required - type: boolean - description: Whether app sessions can be created without owner approval - - - app_info: - description: Full application info - fields: - - name: id - type: string - description: Application identifier - - name: owner_wallet - type: string - description: Owner's wallet address - - name: metadata - type: string - description: Application metadata (bytes32 hash) - - name: version - type: string - description: Current version of the application - - name: creation_approval_not_required - type: boolean - description: Whether app sessions can be created without owner approval - - name: created_at - type: string - description: Creation timestamp (unix seconds) - - name: updated_at - type: string - description: Last update timestamp (unix seconds) - - - action_allowance: - description: Allowance information for a specific gated action - fields: - - name: gated_action - type: string - description: The specific action being gated (transfer, app_session_deposit, app_session_operation, app_session_withdrawal) - - name: time_window - type: string - description: Time window for which the allowance is valid (e.g. "24h0m0s") - - name: allowance - type: string - description: Total allowance for the action within the time window - - name: used - type: string - description: Amount already used within the time window + description: Session-key holder's signature over the same packed state (which already binds user_address) proving possession of the key being registered. Required when expires_at is in the future (registration, update, re-activation) to prevent registration of keys the submitter does not control. Optional for revocation (expires_at at or before now); a wallet may revoke unilaterally with user_sig alone so a lost or uncooperative session key cannot block revocation. - pagination_params: description: Pagination request parameters @@ -494,7 +448,7 @@ types: optional: true - name: limit type: integer - description: Number of items to return + description: Number of items to return. A value of 0 is treated the same as omitting the field — the server default is used. optional: true - name: sort type: string @@ -661,15 +615,16 @@ api: The metadata hash binds `expires_at`, so each revoke or re-activation requires a fresh user signature over the new payload. Negative unix timestamps are rejected. - Both `user_sig` (wallet) and `session_key_sig` (session-key holder) are required - on every submit, including the revocation path — the session key must co-sign its - own deactivation. Wallet-only revocation (for a lost or compromised key) is not - supported by this method; that flow requires a separate code path and is tracked - as a follow-up. + Activation, update, and re-activation (`expires_at` in the future) require both + `user_sig` (wallet) and `session_key_sig` (session-key holder, proving possession). + Revocation (`expires_at` at or before now) requires only `user_sig`: a wallet may + revoke unilaterally so a lost, unavailable, or compromised session key cannot block + revocation of its own delegation. Any `session_key_sig` supplied on the revocation + path is ignored. request: - field_name: state type: channel_session_key_state - description: Session key metadata and delegation information. Set `expires_at` to a past value to revoke; future to (re-)activate. + description: Session key metadata and delegation information. Set `expires_at` to a value at or before now to revoke (user_sig only); future to (re-)activate (both signatures). response: [] errors: - message: invalid_session_key_state @@ -765,20 +720,6 @@ api: description: The signature quorum requirement is not satisfied - message: ongoing_transition description: There is an ongoing transition that must be resolved first - - name: rebalance_app_sessions - description: Rebalance multiple application sessions atomically - request: - - field_name: signed_updates - type: array - description: List of signed application session state updates to be submitted (each update must have intent 'rebalance') - items: - type: signed_app_state_update - response: - - field_name: batch_id - type: string - description: A unique identifier of executed rebalance operation - - errors: [] - name: get_app_definition description: Retrieve the application definition for a specific app session. Returns a successful response with `definition` omitted when no app session exists for the given ID. request: @@ -824,7 +765,7 @@ api: - message: invalid_parameters description: The request parameters are invalid - name: create_app_session - description: Create a new application session between participants. The application must be registered in the app registry. If the application requires creation approval (creation_approval_not_required is false), an owner signature is required. + description: Create a new application session between participants. The application_id is a client-chosen identifier and is not validated against any registry; no owner approval is required. request: - field_name: definition type: app_definition @@ -837,10 +778,6 @@ api: description: Participant signatures for the app session creation items: type: string - - field_name: owner_sig - type: string - description: Owner signature for app session creation, required when the application's creation_approval_not_required is false - optional: true response: - field_name: app_session_id type: string @@ -854,12 +791,6 @@ api: errors: - message: invalid_definition description: The application definition is invalid - - message: application_not_registered - description: The application is not registered in the app registry - - message: owner_sig_required - description: Owner signature is required for this application (creation_approval_not_required is false) - - message: invalid_owner_signature - description: The owner signature is invalid or does not match the registered app owner - message: insufficient_balance description: Participant has insufficient balance for allocations - name: submit_session_key_state @@ -876,15 +807,16 @@ api: The metadata hash binds `expires_at`, so each revoke or re-activation requires a fresh user signature over the new payload. Negative unix timestamps are rejected. - Both `user_sig` (wallet) and `session_key_sig` (session-key holder) are required - on every submit, including the revocation path — the session key must co-sign its - own deactivation. Wallet-only revocation (for a lost or compromised key) is not - supported by this method; that flow requires a separate code path and is tracked - as a follow-up. + Activation, update, and re-activation (`expires_at` in the future) require both + `user_sig` (wallet) and `session_key_sig` (session-key holder, proving possession). + Revocation (`expires_at` at or before now) requires only `user_sig`: a wallet may + revoke unilaterally so a lost, unavailable, or compromised session key cannot block + revocation of its own delegation. Any `session_key_sig` supplied on the revocation + path is ignored. request: - field_name: state type: app_session_key_state - description: Session key metadata and delegation information. Set `expires_at` to a past value to revoke; future to (re-)activate. + description: Session key metadata and delegation information. Set `expires_at` to a value at or before now to revoke (user_sig only); future to (re-)activate (both signatures). response: [] errors: - message: invalid_session_key_state @@ -920,58 +852,6 @@ api: - message: account_not_found description: The specified account was not found - - name: apps - description: Operations related to application registry management - versions: - - version: v1 - methods: - - name: get_apps - description: Retrieve registered applications with optional filtering by app ID and owner wallet - request: - - field_name: app_id - type: string - description: Filter by application ID - optional: true - - field_name: owner_wallet - type: string - description: Filter by owner wallet address - optional: true - - field_name: pagination - type: pagination_params - description: Pagination parameters (offset, limit, sort) - optional: true - response: - - field_name: apps - type: array - items: - type: app_info - description: List of registered applications - - field_name: metadata - type: pagination_metadata - description: Pagination information - errors: - - message: invalid_parameters - description: The request parameters are invalid - - name: submit_app_version - description: Register a new application in the app registry. Currently only version 1 (creation) is supported. The owner must sign the packed app data to prove ownership. - request: - - field_name: app - type: app - description: Application definition including ID, owner wallet, metadata, version, and creation approval flag - - field_name: owner_sig - type: string - description: Owner's EIP-191 signature over the packed application data - response: [] - errors: - - message: invalid_app_id - description: The application ID does not match the required format - - message: invalid_version - description: Only version 1 (creation) is currently supported - - message: invalid_signature - description: The owner signature is invalid - - message: app_already_exists - description: An application with this ID already exists - - name: session_keys description: Operations related to session key management versions: @@ -1032,23 +912,6 @@ api: type: pagination_metadata description: Pagination information optional: true - - name: get_action_allowances - description: Retrieve action allowances for a user based on their staking level - request: - - field_name: wallet - type: string - description: User's wallet address - response: - - field_name: allowances - type: array - items: - type: action_allowance - description: List of action allowances - errors: - - message: wallet_required - description: The wallet address is required - - message: retrieval_failed - description: Failed to retrieve action allowances - name: node description: Utility methods to get node's configuration and check connectivity diff --git a/docs/data_models.mmd b/docs/data_models.mmd index 4cf7584c8..0287eec5e 100644 --- a/docs/data_models.mmd +++ b/docs/data_models.mmd @@ -24,7 +24,6 @@ classDiagram 30 transfer 40 commit 41 release - 42 rebalance 100 migrate 110 escrow_lock 120 mutual_lock diff --git a/docs/protocol/security-and-limitations.md b/docs/protocol/security-and-limitations.md index 0bcb6ec3b..e2a790846 100644 --- a/docs/protocol/security-and-limitations.md +++ b/docs/protocol/security-and-limitations.md @@ -47,6 +47,18 @@ The blockchain layer provides the following guarantees: - After the challenge period, the enforced state becomes final - Final state allocations determine asset distribution +## Off-Chain Convergence with On-Chain State + +The Node maintains a local view of each channel's state by subscribing to on-chain events emitted by the `ChannelHub` contract. To defend against out-of-order event delivery — which can be caused by contract-level reentrancy, indexer mis-ordering, or reorg replay — every event handler that mutates `channels.state_version` or `channels.status` enforces a strict version-monotonicity guard: an event whose `StateVersion` is lower than the row's current `StateVersion` is dropped with a structured warn log. + +For home-channel events (`ChannelChallenged`, `ChannelCheckpointed`, `ChannelClosed`), a dropped event additionally triggers a chain-state refresh: the Node calls `getChannelData` on the home-chain `ChannelHub` contract via the `ChainStateRefresher` and overwrites the local row with the authoritative on-chain status, version, and challenge expiry. This ensures convergence with chain even when single-event delivery is insufficient — for example, when an outer `ChannelChallenged` is delivered after a higher-version inner `ChannelCheckpointed` emitted from the same transaction. + +The refresh runs synchronously inside the event-processing transaction. On RPC failure the transaction rolls back and the listener replays the event, so the convergence opportunity is never silently lost. + +Escrow event handlers ship the version guard without the refresh hook; the cross-chain RPC plumbing required for escrow refresh is a deferred follow-up item. Pending its arrival, escrow rows can remain divergent from chain across an interim window until the next on-chain event arrives. + +The on-chain side complements this guard: every `ChannelHub` lifecycle entrypoint is protected by OpenZeppelin's `nonReentrant` modifier. This prevents inbound token hooks (ERC777 `tokensReceived`, non-standard `transferFrom` callbacks) from interleaving lifecycle operations and producing the out-of-order events that would otherwise force the Node's defense-in-depth path to fire. + ## Node Liquidity and Cross-Chain Trust Each user channel is opened with a node. To maintain cross-chain functionality, the node MUST hold sufficient liquidity on each supported blockchain to satisfy off-chain state allocations. @@ -64,7 +76,9 @@ 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. +- **Asset-symbol equivalence** — the node operator controls which chain-specific tokens are configured under a single unified asset symbol. The protocol treats all tokens sharing a symbol as fully fungible 1:1 representations of the same asset, so off-chain credit denominated in that asset can be redeemed from any of those token inventories regardless of which one originally backed it (the validator binds unchanneled credit to the chain/token chosen at first channel creation, enforcing only that the asset symbol matches). This is intended behaviour that enables cross-chain redemption. Operators MUST therefore configure only economically equivalent (1:1 redeemable) tokens under one symbol; grouping non-equivalent tokens (e.g. a test token and production USDC) under the same symbol would let credit sourced from the cheap inventory be redeemed against the valuable one. Token equivalence cannot be verified programmatically and is an operator configuration responsibility. - **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. +- **Chain reorg depth** — the node credits off-chain balances after observing on-chain events. To bound reorg risk, each chain has a `confirmation_delay_secs` window before events are committed; events whose block is reorged out within that window are discarded. When the configured delay is set below the chain's hard finality time, a residual risk remains: a deeper reorg can leave the off-chain state with no on-chain backing. Operators MUST set `confirmation_delay_secs` to at least the chain's finality time when this residual exposure is unacceptable. See [Reorg-Protection Confirmation Gate](../../nitronode/docs/reorg-fix.md). Participants do not need to trust nodes for: @@ -80,7 +94,10 @@ The following capabilities are not yet implemented or have acknowledged design t - Watchtower services for automated enforcement - Support for non-EVM blockchains - Formal verification of protocol rules +- Hook-enabled tokens (ERC777, ERC1363, non-standard ERC20 with re-entrant `transferFrom`) are not supported on currently-deployed `ChannelHub` instances. The Node operator MUST NOT onboard such tokens. Per-deployment status (chain ID, ChannelHub address, deploy commit) is recorded in [`contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md`](../../contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md). This restriction may be lifted on future deployments to new chains; each new deployment records its support status in the same file. +- Stored signatures are validated but not canonicalized. The node persists accepted `user_sig` / `node_sig` values verbatim (unbounded `text`) rather than re-encoding them after verification. A signature can therefore carry non-canonical or unused trailing bytes and still be retained once its cryptographic checks pass — most notably session-key signatures, where `SessionKeyValidator.validateSignature()` ABI-decodes a `(SessionKeyAuthorization, bytes)` payload and verifies only the embedded ECDSA signatures, leaving any extra bytes around an otherwise valid payload intact. Signatures learned from on-chain events (hex-encoded from the candidate and written via `UpdateStateSigsIfMissing`) follow the same path and bypass the WebSocket message-size limit enforced on direct RPC submissions. This carries no asset-safety risk — verification still gates acceptance — but consumers MUST NOT assume stored signature bytes are minimal or canonical. - Session key off-chain scope enforcement does not apply to direct receive-state acknowledgement. Session key expiration and asset-scope restrictions are enforced by the Nitronode off-chain only; the `SessionKeyValidator` contract validates cryptographic signatures alone. A party holding a session key — even one that has expired, been revoked, or been retired — can bypass the `acknowledge` endpoint, manually sign a pending node-issued receive state, and submit it directly to the contract. This is accepted: receive states exclusively increase the user's allocation and cannot redirect funds away from the user, so out-of-scope acknowledgement carries no financial risk and preserves a recovery path when the node is unavailable. +- App session cooperative closure is atomic across all participants. The Node refuses to issue a release receive-state to any participant whose latest signed state encodes an escrow operation that the off-chain gate does not yet treat as safely settled — covering any pending `escrow_lock`/`mutual_lock`, plus `escrow_deposit` or `escrow_withdraw` states the gate still treats as unsafe (broadly, those whose on-chain escrow channel has not caught up, with a narrow one-version-behind allowance for `escrow_deposit` during normal finalize/purge transitions). Stacking a co-signed release on an unfinalized escrow risks state-chain invariant violations if the escrow ultimately reverts or settles to an unexpected version. As a consequence, a single participant with a pending escrow blocks cooperative close for all others in the session until their escrow resolves. Affected participants may wait for the obstruction to clear, or — where the session state machine permits intermediate updates — unwind their share individually via off-chain transfers out of the session and re-close without the blocked participant. This is an accepted trade-off favouring protocol safety over close-time liveness: every release the Node co-signs must remain enforceable on-chain, and an unfinalized escrow cannot offer that guarantee. ## Future Improvements diff --git a/llms-full.txt b/llms-full.txt index c696e6f1b..3dd45e92a 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -165,7 +165,6 @@ Canonical reference: `docs/api.yaml`. Methods are grouped by domain with `v1` ve | `create_app_session` | Create a new app session between participants | | `submit_app_state` | Submit app state update (Operate, Withdraw, or Close intent) | | `submit_deposit_state` | Submit a deposit into an app session | -| `rebalance_app_sessions` | Atomic rebalancing across multiple app sessions | | `get_app_definition` | Retrieve app definition for a session | | `get_app_sessions` | List app sessions with optional filtering | | `submit_session_key_state` | Register/update an app session key | @@ -232,7 +231,6 @@ const client = await Client.create( | `createAppSession(definition, sessionData, quorumSigs)` | Create app session | | `submitAppState(appStateUpdate, quorumSigs)` | Submit app state (Operate, Withdraw, or Close intent) | | `submitAppSessionDeposit(appStateUpdate, quorumSigs, asset, amount)` | Deposit into app session | -| `rebalanceAppSessions(signedUpdates)` | Atomic rebalancing across multiple app sessions | ### Security Token Locking (on-chain escrow) @@ -294,7 +292,6 @@ defer client.Close() | `CreateAppSession(ctx, def, sessionData, quorumSigs)` | Create app session | | `SubmitAppState(ctx, appStateUpdate, quorumSigs)` | Submit app state (Operate, Withdraw, or Close intent) | | `SubmitAppSessionDeposit(ctx, appStateUpdate, quorumSigs, asset, amount)` | Deposit into app session | -| `RebalanceAppSessions(ctx, signedUpdates)` | Atomic rebalancing across multiple app sessions | ### Security Token Locking (on-chain escrow) diff --git a/nitronode/README.md b/nitronode/README.md index a8976728a..36c679903 100644 --- a/nitronode/README.md +++ b/nitronode/README.md @@ -9,7 +9,6 @@ Nitronode provides a WebSocket-based RPC service that allows users and applicati - Perform instant off-chain transfers between users - Execute multi-party application sessions with arbitrary logic - Delegate signing authority via session keys -- Atomically rebalance funds across multiple application sessions - Track balances and transaction history across all supported assets The node monitors blockchain events, validates state transitions using a monotonic sequence logic, and ensures secure coordination between on-chain channels and off-chain state updates. @@ -20,6 +19,7 @@ Nitronode is built with a modular architecture: - **RPC Server**: WebSocket-based JSON-RPC server handling client requests. - **Blockchain Listeners**: Monitors on-chain events from Nitrolite `ChannelHub` contracts across multiple chains. +- **Confirmation Gate**: Per-chain reorg-protection buffer between the listener and event handlers. Delays event delivery by `confirmation_delay_secs` so that events whose blocks are reorged out before the window elapses are dropped instead of committed. See [docs/reorg-fix.md](docs/reorg-fix.md). - **Event Handlers**: Processes blockchain events to update internal channel and user states. - **Storage Layer**: - **Database Store**: Persistent storage for channels, states, and transactions (supports SQLite and PostgreSQL). @@ -32,12 +32,22 @@ Nitronode is built with a modular architecture: The WebSocket RPC service exposes several API groups: 1. **channel_v1**: Core payment channel management (Creation, State Submission, Latest State). -2. **app_session_v1**: Advanced application session management (Creation, Deposits, Rebalancing). +2. **app_session_v1**: Advanced application session management (Creation, Deposits). 3. **user_v1**: User-specific queries (Balances, Transaction History). 4. **node_v1**: Node-level information (Config, Supported Assets). For detailed API specifications, see [../docs/api.yaml](../docs/api.yaml). +### Event handler version monotonicity + +Channel-lifecycle events from the blockchain listener are applied with per-channel version monotonicity. If an event whose `StateVersion` is lower than the row's current `StateVersion` arrives (possible under contract reentrancy, indexer mis-order, or reorg replay), the handler logs a structured warning and drops the event without mutating the row. Implementation lives in [`event_handlers/service.go`](event_handlers/service.go). + +For home-channel events (`ChannelChallenged`, `ChannelCheckpointed`, `ChannelClosed`), a dropped event additionally triggers a chain-state refresh: the Node fetches the authoritative on-chain channel state via `getChannelData` on the bound `ChannelHub` contract and overwrites the row's `Status`, `StateVersion`, and `ChallengeExpiresAt`. The refresher implementation is [`pkg/blockchain/evm/chain_state_refresher.go`](../pkg/blockchain/evm/chain_state_refresher.go), bound through the [`core.ChainStateRefresher`](../pkg/core/interface.go) interface. + +The refresh runs inside the event-processing transaction. On RPC failure the transaction rolls back and the listener replays the event, so convergence is never silently lost. Escrow event handlers enforce the guard without the refresh hook — cross-chain RPC plumbing for escrow refresh is a deferred follow-up item. Pending its arrival, escrow rows can remain divergent from chain across an interim window until the next on-chain event arrives. + +Operators may see `"event state version is less than current channel state version, ignoring"` warn logs during channel-lifecycle events; these indicate the defense-in-depth path fired and the Node converged with chain. + ## Configuration Nitronode uses YAML files for core configuration and environment variables for sensitive data and runtime overrides. @@ -54,6 +64,7 @@ blockchains: id: 80002 contract_address: "0x9d1E88627884e066B81A02d69BCB2437a520534C" block_step: 1000 + confirmation_delay_secs: 10 # reorg-protection window; 0 disables. See docs/reorg-fix.md. - name: base_sepolia id: 84532 @@ -64,6 +75,8 @@ blockchains: Define supported assets and their multi-chain token deployments in `config/assets.yaml`: +> **Warning:** all tokens grouped under one `symbol` are treated as fully fungible 1:1 representations of the same asset — off-chain credit denominated in that asset can be redeemed from any of these token inventories. Group only economically equivalent (1:1 redeemable) tokens under one symbol; mixing non-equivalent tokens (e.g. a test token and production USDC) lets credit sourced from the cheap inventory be redeemed against the valuable one. Equivalence cannot be verified programmatically and is an operator responsibility. See [Asset-symbol equivalence](../docs/protocol/security-and-limitations.md). + ```yaml assets: - symbol: "USDC" @@ -127,6 +140,7 @@ docker run -p 7824:7824 -e NITRONODE_SIGNER_KEY=... nitronode nitronode/ ├── api/ # JSON-RPC request handlers ├── config/ # Default configurations and migrations +├── docs/ # Component design notes (e.g. reorg-fix.md) ├── event_handlers/ # Logic for reacting to blockchain events ├── metrics/ # Prometheus telemetry implementation ├── store/ # Persistence layer (SQL and Memory) @@ -156,6 +170,7 @@ The following protocol operations are fully specified in [protocol-description.m - [Nitrolite Protocol Overview](../protocol-description.md) - [Communication Flows](../docs/communication_flows/) - [API Reference](../docs/api.yaml) +- [Reorg-Protection Confirmation Gate](docs/reorg-fix.md) ## License diff --git a/nitronode/action_gateway/action_gateway.go b/nitronode/action_gateway/action_gateway.go deleted file mode 100644 index 9cc6f42b1..000000000 --- a/nitronode/action_gateway/action_gateway.go +++ /dev/null @@ -1,191 +0,0 @@ -package action_gateway - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "time" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/shopspring/decimal" - "go.yaml.in/yaml/v2" -) - -const ( - actionGatewayFileName = "action_gateway.yaml" - defaultTimeWindow = 24 * time.Hour -) - -type ActionLimitConfig struct { - LevelStepTokens decimal.Decimal `yaml:"level_step_tokens"` - AppCost decimal.Decimal `yaml:"app_cost"` - ActionGates map[core.GatedAction]ActionGateConfig `yaml:"action_gates"` -} - -type ActionGateConfig struct { - FreeActionsAllowance uint64 `yaml:"free_actions_allowance"` - IncreasePerLevel uint64 `yaml:"increase_per_level"` -} - -type ActionGateway struct { - cfg ActionLimitConfig -} - -func NewActionGateway(cfg ActionLimitConfig) (*ActionGateway, error) { - if !cfg.LevelStepTokens.IsPositive() { - return nil, errors.New("LevelStepTokens must be greater than zero") - } - if !cfg.AppCost.IsPositive() { - return nil, errors.New("AppCost must be greater than zero") - } - - seenIDs := make(map[uint8]core.GatedAction, len(cfg.ActionGates)) - for action := range cfg.ActionGates { - id := action.ID() - if id == 0 { - return nil, fmt.Errorf("unknown action_gates key: %q", action) - } - if prev, exists := seenIDs[id]; exists && prev != action { - return nil, fmt.Errorf("duplicate gated action id %d for %q and %q", id, prev, action) - } - seenIDs[id] = action - } - - return &ActionGateway{ - cfg: cfg, - }, nil -} - -func NewActionGatewayFromYaml(configDirPath string) (*ActionGateway, error) { - assetsPath := filepath.Join(configDirPath, actionGatewayFileName) - f, err := os.Open(assetsPath) - if err != nil { - return nil, err - } - defer f.Close() - - var cfg ActionLimitConfig - if err := yaml.NewDecoder(f).Decode(&cfg); err != nil { - return nil, err - } - - return NewActionGateway(cfg) -} - -func (a *ActionGateway) AllowAction(tx Store, userAddress string, gatedAction core.GatedAction) error { - if _, ok := a.cfg.ActionGates[gatedAction]; !ok { - return nil - } - - stakedTokens, err := tx.GetTotalUserStaked(userAddress) - if err != nil { - return fmt.Errorf("failed to get user staked amount: %w", err) - } - - remainingStaked := stakedTokens - if stakedTokens.IsPositive() { - appCount, err := tx.GetAppCount(userAddress) - if err != nil { - return fmt.Errorf("failed to get app count: %w", err) - } - - maintenanceCost := a.cfg.AppCost.Mul(decimal.NewFromUint64(appCount)) - remainingStaked = stakedTokens.Sub(maintenanceCost) - } - - allowance := a.stakedTo24hActionsAllowance(gatedAction, remainingStaked) - - usedCount, err := tx.GetUserActionCount(userAddress, gatedAction, defaultTimeWindow) - if err != nil { - return fmt.Errorf("failed to get user action count: %w", err) - } - - if usedCount >= allowance { - return fmt.Errorf("action %s limit reached: used %d of %d allowed in 24h", gatedAction, usedCount, allowance) - } - - if err := tx.RecordAction(userAddress, gatedAction); err != nil { - return fmt.Errorf("failed to record action: %w", err) - } - - return nil -} - -func (v *ActionGateway) AllowAppRegistration(tx Store, userAddress string) error { - stakedTokens, err := tx.GetTotalUserStaked(userAddress) - if err != nil { - return fmt.Errorf("failed to get user staked amount: %w", err) - } - if stakedTokens.IsZero() { - return errors.New("cannot register an app with zero staked tokens") - } - - appCount, err := tx.GetAppCount(userAddress) - if err != nil { - return fmt.Errorf("failed to get app count: %w", err) - } - - maintenanceCost := v.cfg.AppCost.Mul(decimal.NewFromUint64(appCount + 1)) - if stakedTokens.LessThan(maintenanceCost) { - return fmt.Errorf("not enough staked tokens to register a new app: staked %s, required %s", stakedTokens.String(), maintenanceCost.String()) - } - - return nil -} - -// stakedTo24hActionsAllowance returns the number of executions allowed in 24 hours for a specific gated action. -func (a *ActionGateway) stakedTo24hActionsAllowance(gatedAction core.GatedAction, remainingStaked decimal.Decimal) uint64 { - actionLinitsConfig, ok := a.cfg.ActionGates[gatedAction] - if !ok { - return 0 - } - actionAllowance := uint64(actionLinitsConfig.FreeActionsAllowance) - if remainingStaked.IsPositive() { - levels := uint64(remainingStaked.Div(a.cfg.LevelStepTokens).IntPart()) - actionAllowance += actionLinitsConfig.IncreasePerLevel * levels - } - return actionAllowance -} - -// GetUserAllowances returns user allowance for every gated action. -func (a *ActionGateway) GetUserAllowances(tx Store, userAddress string) ([]core.ActionAllowance, error) { - stakedTokens, err := tx.GetTotalUserStaked(userAddress) - if err != nil { - return nil, fmt.Errorf("failed to get user staked amount: %w", err) - } - - actionCounts, err := tx.GetUserActionCounts(userAddress, defaultTimeWindow) - if err != nil { - return nil, err - } - - remainingStaked := stakedTokens - if stakedTokens.IsPositive() { - appCount, err := tx.GetAppCount(userAddress) - if err != nil { - return nil, fmt.Errorf("failed to get app count: %w", err) - } - - maintenanceCost := a.cfg.AppCost.Mul(decimal.NewFromUint64(appCount)) - remainingStaked = stakedTokens.Sub(maintenanceCost) - } - - timeWindowStr := defaultTimeWindow.String() - result := make([]core.ActionAllowance, 0, len(a.cfg.ActionGates)) - for action := range a.cfg.ActionGates { - result = append(result, core.ActionAllowance{ - GatedAction: action, - TimeWindow: timeWindowStr, - Allowance: a.stakedTo24hActionsAllowance(action, remainingStaked), - Used: actionCounts[action], - }) - } - - slices.SortFunc(result, func(a, b core.ActionAllowance) int { - return int(a.GatedAction.ID()) - int(b.GatedAction.ID()) - }) - - return result, nil -} diff --git a/nitronode/action_gateway/action_gateway_test.go b/nitronode/action_gateway/action_gateway_test.go deleted file mode 100644 index dad63ad5a..000000000 --- a/nitronode/action_gateway/action_gateway_test.go +++ /dev/null @@ -1,356 +0,0 @@ -package action_gateway - -import ( - "errors" - "testing" - "time" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// mockAVStore implements AVStore for unit tests. -type mockAVStore struct { - totalStaked decimal.Decimal - stakedErr error - appCount uint64 - appCountErr error - actionCount uint64 - actionErr error - actionCounts map[core.GatedAction]uint64 - actionCSErr error - recordedActions []core.GatedAction - recordErr error -} - -func (m *mockAVStore) GetTotalUserStaked(string) (decimal.Decimal, error) { - return m.totalStaked, m.stakedErr -} - -func (m *mockAVStore) GetAppCount(string) (uint64, error) { - return m.appCount, m.appCountErr -} - -func (m *mockAVStore) GetUserActionCount(string, core.GatedAction, time.Duration) (uint64, error) { - return m.actionCount, m.actionErr -} - -func (m *mockAVStore) GetUserActionCounts(string, time.Duration) (map[core.GatedAction]uint64, error) { - return m.actionCounts, m.actionCSErr -} - -func (m *mockAVStore) RecordAction(_ string, action core.GatedAction) error { - m.recordedActions = append(m.recordedActions, action) - return m.recordErr -} - -func defaultConfig() ActionLimitConfig { - return ActionLimitConfig{ - LevelStepTokens: decimal.NewFromInt(100), - AppCost: decimal.NewFromInt(50), - ActionGates: map[core.GatedAction]ActionGateConfig{ - core.GatedActionTransfer: {FreeActionsAllowance: 5, IncreasePerLevel: 10}, - }, - } -} - -func mustNewGateway(t *testing.T, cfg ActionLimitConfig) *ActionGateway { - t.Helper() - gw, err := NewActionGateway(cfg) - require.NoError(t, err) - return gw -} - -// --- NewActionGateway --- - -func TestNewActionGateway(t *testing.T) { - t.Run("valid config", func(t *testing.T) { - gw, err := NewActionGateway(defaultConfig()) - require.NoError(t, err) - assert.NotNil(t, gw) - }) - - t.Run("zero LevelStepTokens", func(t *testing.T) { - cfg := defaultConfig() - cfg.LevelStepTokens = decimal.Zero - _, err := NewActionGateway(cfg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "LevelStepTokens") - }) - - t.Run("zero AppCost", func(t *testing.T) { - cfg := defaultConfig() - cfg.AppCost = decimal.Zero - _, err := NewActionGateway(cfg) - assert.Error(t, err) - assert.Contains(t, err.Error(), "AppCost") - }) -} - -// --- stakedTo24hActionsAllowance --- - -func TestStakedTo24hActionsAllowance(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - - t.Run("unknown action returns 0", func(t *testing.T) { - assert.Equal(t, uint64(0), gw.stakedTo24hActionsAllowance("unknown_action", decimal.NewFromInt(1000))) - }) - - t.Run("zero staked returns free allowance only", func(t *testing.T) { - assert.Equal(t, uint64(5), gw.stakedTo24hActionsAllowance(core.GatedActionTransfer, decimal.Zero)) - }) - - t.Run("negative staked returns free allowance only", func(t *testing.T) { - assert.Equal(t, uint64(5), gw.stakedTo24hActionsAllowance(core.GatedActionTransfer, decimal.NewFromInt(-100))) - }) - - t.Run("positive staked adds levels", func(t *testing.T) { - // 250 tokens / 100 step = 2 levels -> 5 + 2*10 = 25 - assert.Equal(t, uint64(25), gw.stakedTo24hActionsAllowance(core.GatedActionTransfer, decimal.NewFromInt(250))) - }) - - t.Run("partial level truncated", func(t *testing.T) { - // 199 tokens / 100 step = 1 level -> 5 + 1*10 = 15 - assert.Equal(t, uint64(15), gw.stakedTo24hActionsAllowance(core.GatedActionTransfer, decimal.NewFromInt(199))) - }) -} - -// --- AllowAction --- - -func TestAllowAction(t *testing.T) { - t.Run("allowed with free allowance, zero staked", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCount: 0, - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - require.NoError(t, err) - assert.Equal(t, []core.GatedAction{core.GatedActionTransfer}, store.recordedActions) - }) - - t.Run("allowed with staked tokens and apps", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - // 300 staked - 2 apps * 50 cost = 200 remaining -> 2 levels -> 5 + 20 = 25 - store := &mockAVStore{ - totalStaked: decimal.NewFromInt(300), - appCount: 2, - actionCount: 24, // under 25 - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - require.NoError(t, err) - }) - - t.Run("rejected at limit", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCount: 5, // equals free allowance - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - assert.Error(t, err) - assert.Contains(t, err.Error(), "limit reached") - }) - - t.Run("rejected over limit", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCount: 10, - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - assert.Error(t, err) - }) - - t.Run("unknown gated action returns nil without store calls", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - stakedErr: errors.New("should not be called"), - } - err := gw.AllowAction(store, "0xuser", "nonexistent") - require.NoError(t, err) - assert.Empty(t, store.recordedActions) - }) - - t.Run("staked error propagated", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{stakedErr: errors.New("db down")} - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - assert.ErrorContains(t, err, "db down") - }) - - t.Run("app count error propagated", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.NewFromInt(100), - appCountErr: errors.New("db down"), - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - assert.ErrorContains(t, err, "db down") - }) - - t.Run("action count error propagated", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionErr: errors.New("db down"), - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - assert.ErrorContains(t, err, "db down") - }) - - t.Run("record error propagated", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCount: 0, - recordErr: errors.New("write fail"), - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - assert.ErrorContains(t, err, "write fail") - }) - - t.Run("skips GetAppCount when staked is zero", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - // If GetAppCount were called it would return an error - store := &mockAVStore{ - totalStaked: decimal.Zero, - appCountErr: errors.New("should not be called"), - actionCount: 0, - } - err := gw.AllowAction(store, "0xuser", core.GatedActionTransfer) - require.NoError(t, err) - }) -} - -// --- AllowAppRegistration --- - -func TestAllowAppRegistration(t *testing.T) { - t.Run("allowed with enough stake", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - // 0 existing apps, cost for 1 = 50, staked = 100 - store := &mockAVStore{totalStaked: decimal.NewFromInt(100), appCount: 0} - err := gw.AllowAppRegistration(store, "0xuser") - require.NoError(t, err) - }) - - t.Run("allowed exact cost", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - // 1 existing app, cost for 2 = 100, staked = 100 - store := &mockAVStore{totalStaked: decimal.NewFromInt(100), appCount: 1} - err := gw.AllowAppRegistration(store, "0xuser") - require.NoError(t, err) - }) - - t.Run("rejected not enough stake", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - // 1 existing app, cost for 2 = 100, staked = 99 - store := &mockAVStore{totalStaked: decimal.NewFromInt(99), appCount: 1} - err := gw.AllowAppRegistration(store, "0xuser") - assert.Error(t, err) - assert.Contains(t, err.Error(), "not enough staked tokens") - }) - - t.Run("rejected zero staked", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{totalStaked: decimal.Zero} - err := gw.AllowAppRegistration(store, "0xuser") - assert.Error(t, err) - assert.Contains(t, err.Error(), "zero staked tokens") - }) -} - -// --- GetUserAllowances --- - -func TestGetUserAllowances(t *testing.T) { - multiActionConfig := ActionLimitConfig{ - LevelStepTokens: decimal.NewFromInt(100), - AppCost: decimal.NewFromInt(50), - ActionGates: map[core.GatedAction]ActionGateConfig{ - core.GatedActionTransfer: {FreeActionsAllowance: 5, IncreasePerLevel: 10}, - core.GatedActionAppSessionOperation: {FreeActionsAllowance: 20, IncreasePerLevel: 5}, - }, - } - - t.Run("zero staked returns free allowances", func(t *testing.T) { - gw := mustNewGateway(t, multiActionConfig) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCounts: map[core.GatedAction]uint64{core.GatedActionTransfer: 3}, - } - result, err := gw.GetUserAllowances(store, "0xuser") - require.NoError(t, err) - require.Len(t, result, 2) - - // Results should be sorted by GatedAction ID - assert.Equal(t, core.GatedActionTransfer, result[0].GatedAction) - assert.Equal(t, uint64(5), result[0].Allowance) - assert.Equal(t, uint64(3), result[0].Used) - - assert.Equal(t, core.GatedActionAppSessionOperation, result[1].GatedAction) - assert.Equal(t, uint64(20), result[1].Allowance) - assert.Equal(t, uint64(0), result[1].Used) - }) - - t.Run("staked tokens increase allowances", func(t *testing.T) { - gw := mustNewGateway(t, multiActionConfig) - // 500 staked - 2 apps * 50 = 400 remaining -> 4 levels - store := &mockAVStore{ - totalStaked: decimal.NewFromInt(500), - appCount: 2, - actionCounts: map[core.GatedAction]uint64{}, - } - result, err := gw.GetUserAllowances(store, "0xuser") - require.NoError(t, err) - - // Transfer: 5 + 4*10 = 45 - assert.Equal(t, uint64(45), result[0].Allowance) - // AppSessionOperation: 20 + 4*5 = 40 - assert.Equal(t, uint64(40), result[1].Allowance) - }) - - t.Run("maintenance exceeds staked gives free allowance", func(t *testing.T) { - gw := mustNewGateway(t, multiActionConfig) - // 50 staked - 2 apps * 50 = -50 remaining - store := &mockAVStore{ - totalStaked: decimal.NewFromInt(50), - appCount: 2, - actionCounts: map[core.GatedAction]uint64{}, - } - result, err := gw.GetUserAllowances(store, "0xuser") - require.NoError(t, err) - - assert.Equal(t, uint64(5), result[0].Allowance) - assert.Equal(t, uint64(20), result[1].Allowance) - }) - - t.Run("time window is set", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCounts: map[core.GatedAction]uint64{}, - } - result, err := gw.GetUserAllowances(store, "0xuser") - require.NoError(t, err) - assert.Equal(t, "24h0m0s", result[0].TimeWindow) - }) - - t.Run("staked error propagated", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{stakedErr: errors.New("db down")} - _, err := gw.GetUserAllowances(store, "0xuser") - assert.ErrorContains(t, err, "db down") - }) - - t.Run("action counts error propagated", func(t *testing.T) { - gw := mustNewGateway(t, defaultConfig()) - store := &mockAVStore{ - totalStaked: decimal.Zero, - actionCSErr: errors.New("db down"), - } - _, err := gw.GetUserAllowances(store, "0xuser") - assert.ErrorContains(t, err, "db down") - }) -} diff --git a/nitronode/action_gateway/interface.go b/nitronode/action_gateway/interface.go deleted file mode 100644 index 582043d6b..000000000 --- a/nitronode/action_gateway/interface.go +++ /dev/null @@ -1,41 +0,0 @@ -package action_gateway - -import ( - "time" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/shopspring/decimal" -) - -// ActionAllower defines the interface for action gating and allowance checks. -type ActionAllower interface { - // AllowAction checks if a user is allowed to perform a specific gated action - // based on their past activity and allowances. - AllowAction(tx Store, userAddress string, gatedAction core.GatedAction) error - - // AllowAppRegistration checks if a user is allowed to register a new application - // based on their staked tokens and existing app count. - AllowAppRegistration(tx Store, userAddress string) error - - // GetUserAllowances returns user allowance for every gated action. - // An empty slice indicates the user has no limits. - GetUserAllowances(tx Store, userAddress string) ([]core.ActionAllowance, error) -} - -type Store interface { - // GetAppCount returns the total number of applications owned by a specific wallet. - GetAppCount(ownerWallet string) (uint64, error) - - // GetTotalUserStaked returns the total staked amount for a user across all blockchains. - GetTotalUserStaked(wallet string) (decimal.Decimal, error) - - // RecordAction inserts a new action log entry for a user. - RecordAction(wallet string, gatedAction core.GatedAction) error - - // GetUserActionCount returns the number of actions matching the given wallet and gated action - // within the specified time window (counting backwards from now). - GetUserActionCount(wallet string, gatedAction core.GatedAction, window time.Duration) (uint64, error) - - // GetUserActionCounts returns a map of gated actions to their respective counts for a user within the specified time window. - GetUserActionCounts(userWallet string, window time.Duration) (map[core.GatedAction]uint64, error) -} diff --git a/nitronode/action_gateway/permissive_action_allower.go b/nitronode/action_gateway/permissive_action_allower.go deleted file mode 100644 index a7044c099..000000000 --- a/nitronode/action_gateway/permissive_action_allower.go +++ /dev/null @@ -1,23 +0,0 @@ -package action_gateway - -import "github.com/layer-3/nitrolite/pkg/core" - -// PermissiveActionAllower is an ActionAllower that allows all actions without any checks. -// It returns empty user allowances, indicating the user is not limited. -type PermissiveActionAllower struct{} - -func NewPermissiveActionAllower() *PermissiveActionAllower { - return &PermissiveActionAllower{} -} - -func (p *PermissiveActionAllower) AllowAction(_ Store, _ string, _ core.GatedAction) error { - return nil -} - -func (p *PermissiveActionAllower) AllowAppRegistration(_ Store, _ string) error { - return nil -} - -func (p *PermissiveActionAllower) GetUserAllowances(_ Store, _ string) ([]core.ActionAllowance, error) { - return []core.ActionAllowance{}, nil -} diff --git a/nitronode/api/app_session_v1/README.md b/nitronode/api/app_session_v1/README.md index 987353ace..e01b573c0 100644 --- a/nitronode/api/app_session_v1/README.md +++ b/nitronode/api/app_session_v1/README.md @@ -1,6 +1,6 @@ # App Session V1 API Implementation -This directory contains the V1 API handlers for app session management, implementing the `create_app_session`, `submit_deposit_state`, `submit_app_state`, `rebalance_app_sessions`, `get_app_sessions`, `get_app_definition`, `submit_session_key_state`, and `get_last_key_states` endpoints. +This directory contains the V1 API handlers for app session management, implementing the `create_app_session`, `submit_deposit_state`, `submit_app_state`, `get_app_sessions`, `get_app_definition`, `submit_session_key_state`, and `get_last_key_states` endpoints. ## Architecture @@ -14,7 +14,6 @@ This directory contains the V1 API handlers for app session management, implemen - `create_app_session.go` - Create app session endpoint - `submit_deposit_state.go` - Submit deposit state endpoint - `submit_app_state.go` - Submit app state endpoint (operate, withdraw, close) - - `rebalance_app_sessions.go` - Rebalance app sessions endpoint - `get_app_sessions.go` - Get app sessions endpoint (with filtering) - `get_app_definition.go` - Get app definition endpoint - `submit_session_key_state.go` - Submit session key state endpoint @@ -67,7 +66,7 @@ This directory contains the V1 API handlers for app session management, implemen ``` **Validation**: -- At least 2 participants required +- At least 1 participant required - Nonce must be non-zero - Quorum cannot exceed total signature weights - All weights must be non-negative @@ -346,217 +345,7 @@ For withdraw and close intents, the handler issues new channel states to partici - Stores the new channel state - Records the release transaction -### 4. `app_sessions.v1.rebalance_app_sessions` - -**Purpose**: Processes multi-session rebalancing operations atomically. Rebalancing redistributes funds across multiple app sessions in a single atomic operation, potentially involving multiple assets. - -**Use Cases**: -- **Liquidity Management**: Redistribute liquidity among sessions -- **Portfolio Rebalancing**: Adjust allocations across multiple gaming sessions or trading positions -- **Cross-Session Settlements**: Settle obligations between multiple app sessions atomically -- **Session Consolidation**: Move funds from multiple sessions into fewer sessions for efficiency -- **Multi-Asset Swaps**: Exchange different assets between sessions (e.g., Session A sends USDC to Session B, Session B sends ETH to Session A) - -**Key Features**: -- Atomic operation across multiple sessions -- Multi-asset support (can rebalance multiple assets simultaneously) -- Conservation enforcement (sum of changes per asset must equal zero) -- Batch-based transaction model with deterministic batch IDs -- Quorum verification for each participating session - -**Request**: -```json -{ - "signed_updates": [ - { - "app_state_update": { - "app_session_id": "0xsession1...", - "intent": "rebalance", - "version": 5, - "allocations": [ - {"participant": "0xUser1", "asset": "USDC", "amount": "100"}, - {"participant": "0xUser1", "asset": "ETH", "amount": "0.5"} - ], - "session_data": "..." - }, - "quorum_sigs": ["0xsig1...", "0xsig2..."] - }, - { - "app_state_update": { - "app_session_id": "0xsession2...", - "intent": "rebalance", - "version": 3, - "allocations": [ - {"participant": "0xUser2", "asset": "USDC", "amount": "200"}, - {"participant": "0xUser2", "asset": "ETH", "amount": "1.5"} - ], - "session_data": "..." - }, - "quorum_sigs": ["0xsig3...", "0xsig4..."] - } - ] -} -``` - -**Response**: -```json -{ - "batch_id": "0xbatch123..." -} -``` - -**Validation**: - -*Common Validation:* -- At least 2 sessions required for rebalancing -- All updates must have `intent = "rebalance"` -- Each session can only appear once in the rebalance -- All sessions must exist and be open -- Version must be sequential (current + 1) for each session -- Signatures must meet quorum for each session -- All allocations must be non-negative -- All allocations must be to valid participants - -*Conservation Validation:* -- Sum of all balance changes must equal zero **per asset** -- Formula: `Σ (new_balance[session_i][A] - current_balance[session_i][A]) = 0` for each asset A -- This ensures funds are redistributed, not created or destroyed - -**Signature Verification**: -- Uses ABI encoding via `PackAppStateUpdateV1` for each session -- Recovers signer addresses from ECDSA signatures -- Validates that signers are participants in their respective sessions -- Accumulates signature weights to verify quorum is met for each session - -**Transaction Model**: - -Rebalancing uses a **batch-based transaction model** where a generated batch ID acts as an intermediate clearing account: - -``` -Session A (loses USDC, gains ETH) → Batch ID → Session B (gains USDC, loses ETH) -Session C (loses USDC) → Batch ID → Session D (gains USDC) -``` - -Each asset flows through the batch ID independently, but all flows are part of one atomic operation. - -**Batch ID Generation**: - -The batch ID is deterministically generated from session IDs and versions using ABI encoding: - -```go -// GenerateRebalanceBatchIDV1 creates a deterministic batch ID -sessionVersions := []AppSessionVersionV1{ - {SessionID: "0xSessionA", Version: 5}, - {SessionID: "0xSessionB", Version: 3}, -} -batchID := GenerateRebalanceBatchIDV1(sessionVersions) -// Result: 0xBatch456... (Keccak256 hash of ABI-encoded data) -``` - -This ensures: -- **Deterministic**: Same sessions + versions always produce the same batch ID -- **Unique**: Different version combinations produce different batch IDs -- **State-Bound**: Batch ID is tied to the exact state versions being modified - -**Transaction Recording**: - -For each session and asset involved, transactions are created: - -*For sessions losing funds:* -```go -Transaction{ - TxType: TransactionTypeRebalance, - FromAccount: app_session_id, - ToAccount: batch_id, - Amount: amount_leaving_session, - Asset: asset -} -``` - -*For sessions gaining funds:* -```go -Transaction{ - TxType: TransactionTypeRebalance, - FromAccount: batch_id, - ToAccount: app_session_id, - Amount: amount_entering_session, - Asset: asset -} -``` - -**Ledger Entries**: - -In addition to transactions, ledger entries are recorded for each session and asset: - -```go -// For session losing 100 USDC -RecordLedgerEntry(app_session_id, "USDC", -100, nil) - -// For session gaining 50 USDC and 0.5 ETH -RecordLedgerEntry(app_session_id, "USDC", 50, nil) -RecordLedgerEntry(app_session_id, "ETH", 0.5, nil) -``` - -**Example Flow: Multi-Asset Swap** - -Three app sessions with multiple assets: -- **Session A**: Has 200 USDC and 1 ETH → needs 100 USDC and 1.5 ETH (loses 100 USDC, gains 0.5 ETH) -- **Session B**: Has 50 USDC and 2 ETH → needs 150 USDC and 1.5 ETH (gains 100 USDC, loses 0.5 ETH) - -1. **Prepare Signed Updates**: Each session's participants sign an `app_state_update` with `intent: "rebalance"` and new allocations -2. **Submit to API**: POST to `/v1/app_sessions/rebalance_app_sessions` with both signed updates -3. **Node Processing** (atomic transaction): - - Validate all sessions and signatures - - Generate batch ID: `keccak256("0xSessionA" + "5" + "0xSessionB" + "3")` → `0xBatch789` - - Calculate balance changes: - - Session A: USDC -100, ETH +0.5 - - Session B: USDC +100, ETH -0.5 - - Verify conservation: USDC (-100 + 100 = 0), ETH (+0.5 - 0.5 = 0) ✓ - - Record 4 transactions (2 assets × 2 sessions) - - Record 4 ledger entries - - Update both session versions -4. **Result**: Sessions rebalanced atomically, batch ID `0xBatch789` returned - -**Querying Rebalancing Operations**: - -```sql --- Find all rebalance transactions -SELECT * FROM ledger_transactions -WHERE tx_type = 'rebalance' -ORDER BY created_at DESC; - --- Find all sessions in a specific rebalance batch -SELECT * FROM ledger_transactions -WHERE (from_account = '0xBatch789' OR to_account = '0xBatch789') - AND tx_type = 'rebalance' -ORDER BY asset, created_at; - --- Find rebalances affecting a specific session -SELECT * FROM ledger_transactions -WHERE (from_account = '0xSessionA' OR to_account = '0xSessionA') - AND tx_type = 'rebalance' -ORDER BY created_at DESC; -``` - -**Comparison with Other Operations**: - -| Feature | Deposit | Withdraw | Operate | Close | **Rebalance** | -|---------|---------|----------|---------|-------|---------------| -| Affects | 1 session | 1 session | 1 session | 1 session | **Multiple sessions** | -| Assets | Single | Single | Single | Single | **Multiple** | -| Funds Source | User channel | Session | Within session | Session to users | **Session to session** | -| Transaction Count | 1 | N | 0 | N | **N × M (sessions × assets)** | -| Atomicity | Single session | Single session | Single session | Single session | **Cross-session** | - -**Security Considerations**: -1. **Signature Verification**: Each session's signatures are independently verified against its quorum requirements -2. **Atomicity**: The entire operation is wrapped in a database transaction - partial completion is impossible -3. **Conservation**: The system enforces that funds are redistributed per asset, not created or destroyed -4. **Deterministic Batch IDs**: Based on session IDs and versions - prevents replay and ensures uniqueness -5. **Version Checking**: Prevents concurrent modifications to the same session -6. **No State Mutation**: Only sessions included in the rebalance are modified - -### 5. `app_sessions.v1.get_app_sessions` +### 4. `app_sessions.v1.get_app_sessions` **Purpose**: Retrieves application sessions with optional filtering by participant or app session ID. Includes participant allocations for each session. @@ -628,7 +417,7 @@ ORDER BY created_at DESC; - Status is converted to string representation ("open"/"closed") - SessionData is null if empty string -### 6. `app_sessions.v1.get_app_definition` +### 5. `app_sessions.v1.get_app_definition` **Purpose**: Retrieves the application definition for a specific app session. Returns the immutable configuration established at session creation. @@ -670,7 +459,7 @@ ORDER BY created_at DESC; - Does not include dynamic state like version, status, or allocations - Nonce is from the session definition (not current version) -### 7. `app_sessions.v1.submit_session_key_state` +### 6. `app_sessions.v1.submit_session_key_state` **Purpose**: Submits a session key state for registration, rotation/update, or revocation. Session keys allow delegated signing for app sessions, enabling applications to sign on behalf of a user's wallet. @@ -711,11 +500,11 @@ ORDER BY created_at DESC; - `version` must be greater than 0 - `expires_at` may be in the past — past values express revocation (the key is retired and the slot is freed) - `user_sig` is required -- `application_ids` entries must be lowercase strings (non-lowercase values are rejected before signature verification) -- `app_session_ids` entries must be lowercase strings (non-lowercase values are rejected before signature verification) +- `application_ids` entries must match `^[a-z0-9_-]{1,66}$` (lowercase letters, digits, dashes, underscores, max 66 chars); malformed entries are rejected before signature verification +- `app_session_ids` entries must be 0x-prefixed 32-byte hashes in lowercase canonical form (`^0x[0-9a-f]{64}$`); checksummed/uppercase hex is rejected before signature verification - Version must be sequential (latest_version + 1) - Signature must recover to `user_address` -- The per-user cap (`NITRONODE_MAX_SESSION_KEYS_PER_USER`, default 100) is enforced whenever the submit transitions the slot from inactive to active: a brand-new key (no prior state) or a reactivation (previous latest state's `expires_at` was already in the past). Rotation/update against a still-active key, and revocation submits, are not subject to the cap. +- The per-user cap (`NITRONODE_MAX_SESSION_KEYS_PER_USER`, default 100) is enforced whenever the submit transitions the slot from inactive to active: a brand-new key (no prior state) or a reactivation (previous latest state's `expires_at` was already in the past). Rotation/update against a still-active key, and revocation submits, are not subject to the cap. A value `<= 0` disables the cap entirely (unlimited session keys per user). **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. @@ -727,7 +516,7 @@ ORDER BY created_at DESC; - Recovers signer address from ECDSA signature - Validates that recovered address matches `user_address` -### 8. `app_sessions.v1.get_last_key_states` +### 7. `app_sessions.v1.get_last_key_states` **Purpose**: Retrieves the latest non-expired session key states for a user, with optional filtering by session key address. @@ -774,14 +563,12 @@ ORDER BY created_at DESC; - `create_app_session.go` - Create app session endpoint handler - `submit_deposit_state.go` - Submit deposit state endpoint handler - `submit_app_state.go` - Submit app state endpoint handler (operate, withdraw, close) -- `rebalance_app_sessions.go` - Rebalance app sessions endpoint handler - `get_app_sessions.go` - Get app sessions endpoint handler (with filtering and pagination) - `get_app_definition.go` - Get app definition endpoint handler - `submit_session_key_state.go` - Submit session key state endpoint handler - `get_last_key_states.go` - Get last session key states endpoint handler - `interface.go` - Store and signature validator interfaces - `utils.go` - Mapping functions between RPC and core types -- `rebalance_app_sessions_test.go` - Comprehensive tests for rebalancing **Business Logic** (`pkg/app/`): - `app_session_v1.go` - Type definitions and ABI encoding functions @@ -811,21 +598,7 @@ The implementation uses Ethereum ABI encoding for deterministic hashing and sign - `sessionData` as `string` - Amount encoded as string representation of decimal for precision - Returns Keccak256 hash of ABI-encoded data -- Used in `submit_deposit_state` and `rebalance_app_sessions` to verify participant signatures - -#### `GenerateRebalanceBatchIDV1(sessionVersions []AppSessionVersionV1) (string, error)` -- Generates a deterministic batch ID for rebalancing operations using ABI encoding -- Encodes: array of tuples (bytes32 sessionID, uint64 version) -- Returns Keccak256 hash as hex string -- Used in `rebalance_app_sessions` to create a unique identifier for the batch -- Ensures deterministic IDs based on participating sessions and their versions - -#### `GenerateRebalanceTransactionIDV1(batchID, sessionID, asset string) (string, error)` -- Generates a deterministic transaction ID for rebalance transactions using ABI encoding -- Encodes: batchID (bytes32), sessionID (bytes32), asset (string) -- Returns Keccak256 hash as hex string -- Used in `rebalance_app_sessions` to create unique transaction IDs -- Ensures each session-asset combination has a unique transaction ID within the batch +- Used in `submit_deposit_state` to verify participant signatures #### `GenerateSessionKeyStateIDV1(userAddress, sessionKey string, version uint64) (string, error)` - Generates a deterministic ID from user_address, session_key, and version @@ -932,7 +705,6 @@ handler := app_session_v1.NewHandler( router.Register(rpc.AppSessionsV1CreateAppSessionMethod, handler.CreateAppSession) router.Register(rpc.AppSessionsV1SubmitDepositStateMethod, handler.SubmitDepositState) router.Register(rpc.AppSessionsV1SubmitAppStateMethod, handler.SubmitAppState) -router.Register(rpc.AppSessionsV1RebalanceAppSessionsMethod, handler.RebalanceAppSessions) router.Register(rpc.AppSessionsV1GetAppSessionsMethod, handler.GetAppSessions) router.Register(rpc.AppSessionsV1GetAppDefinitionMethod, handler.GetAppDefinition) router.Register(rpc.AppSessionsV1SubmitSessionKeyStateMethod, handler.SubmitSessionKeyState) @@ -1011,7 +783,6 @@ Following `channel_v1` structure: | Amount handling | Varies | **String representation for precision** | | Quorum validation | Not implemented | **Weighted signature quorum** | | Deposit validation | Basic | **Asset matching + amount validation** | -| Multi-session rebalancing | Not available | **Atomic cross-session rebalancing** | | Architecture | Mixed concerns | **Clean separation** | | File structure | Single file | **Separate file per endpoint** | diff --git a/nitronode/api/app_session_v1/create_app_session.go b/nitronode/api/app_session_v1/create_app_session.go index c00973c9e..694f412ae 100644 --- a/nitronode/api/app_session_v1/create_app_session.go +++ b/nitronode/api/app_session_v1/create_app_session.go @@ -1,10 +1,8 @@ package app_session_v1 import ( - "strings" "time" - "github.com/ethereum/go-ethereum/common/hexutil" "github.com/layer-3/nitrolite/pkg/app" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/log" @@ -75,7 +73,7 @@ func (h *Handler) CreateAppSession(c *rpc.Context) { // derivation, and stored participant list all see the canonical (lowercase, 0x-prefixed) // representation regardless of how the caller cased the address or whether they // included the 0x prefix. - var totalWeights uint8 + var totalWeights uint16 participantWeights := make(map[string]uint8) for i, participant := range reqPayload.Definition.Participants { participantWallet, err := core.NormalizeHexAddress(participant.WalletAddress) @@ -89,12 +87,12 @@ func (h *Handler) CreateAppSession(c *rpc.Context) { c.Fail(rpc.Errorf("duplicate participant address: %s", participant.WalletAddress), "") return } - totalWeights += participant.SignatureWeight + totalWeights += uint16(participant.SignatureWeight) participantWeights[participantWallet] = participant.SignatureWeight appDef.Participants[i].WalletAddress = participantWallet } - if reqPayload.Definition.Quorum > totalWeights { + if uint16(reqPayload.Definition.Quorum) > totalWeights { c.Fail(rpc.Errorf("target quorum (%d) cannot be greater than total sum of weights (%d)", reqPayload.Definition.Quorum, totalWeights), "") return @@ -121,54 +119,6 @@ func (h *Handler) CreateAppSession(c *rpc.Context) { } err = h.useStoreInTx(func(tx Store) error { - if h.appRegistryEnabled { - registeredApp, err := tx.GetApp(appDef.ApplicationID) - if err != nil { - return rpc.Errorf("failed to look up application: %v", err) - } - - // App must be registered regardless of CreationApprovalNotRequired flag. - if registeredApp == nil { - return rpc.Errorf("application %s is not registered", appDef.ApplicationID) - } - - if !registeredApp.App.CreationApprovalNotRequired { - if reqPayload.OwnerSig == "" { - return rpc.Errorf("owner_sig is required for this application") - } - - sigBytes, err := hexutil.Decode(reqPayload.OwnerSig) - if err != nil { - return rpc.Errorf("failed to decode signature: %v", err) - } - if len(sigBytes) == 0 { - return rpc.Errorf("empty owner_sig after decode") - } - - sigType := app.AppSessionSignerTypeV1(sigBytes[0]) - appSessionSignerValidator := app.NewAppSessionKeySigValidatorV1( - func(sessionKeyAddr string) (string, error) { - return tx.GetAppSessionKeyOwner(sessionKeyAddr, appSessionID, appDef.ApplicationID) - }, - ) - recoveredOwnerWallet, err := appSessionSignerValidator.Recover(packedRequest, sigBytes) - if err != nil { - h.metrics.IncAppSessionUpdateSigValidation(appSessionID, sigType, false) - return rpc.Errorf("failed to recover user wallet: %v", err) - } - h.metrics.IncAppSessionUpdateSigValidation(appSessionID, sigType, true) - - if !strings.EqualFold(recoveredOwnerWallet, registeredApp.App.OwnerWallet) { - return rpc.Errorf("invalid owner signature: signer %s is not the app owner", recoveredOwnerWallet) - } - } - - err = h.actionGateway.AllowAction(tx, registeredApp.App.OwnerWallet, core.GatedActionAppSessionCreation) - if err != nil { - return rpc.NewError(err) - } - } - if err := h.verifyQuorum(tx, appSessionID, appDef.ApplicationID, participantWeights, appDef.Quorum, packedRequest, reqPayload.QuorumSigs); err != nil { return err } 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 db4681c14..0b8602b10 100644 --- a/nitronode/api/app_session_v1/create_app_session_test.go +++ b/nitronode/api/app_session_v1/create_app_session_test.go @@ -29,14 +29,12 @@ func TestCreateAppSession_Success(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Create a real test wallet for participant1 @@ -78,9 +76,6 @@ func TestCreateAppSession_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() mockStore.On("CreateAppSession", mock.MatchedBy(func(session any) bool { return true // Accept any app session for now })).Return(nil).Once() @@ -134,14 +129,12 @@ func TestCreateAppSession_QuorumWithMultipleSignatures(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Create real test wallets for participant1 and participant2 @@ -193,9 +186,6 @@ func TestCreateAppSession_QuorumWithMultipleSignatures(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() mockStore.On("CreateAppSession", mock.Anything).Return(nil).Once() // Create RPC context @@ -241,14 +231,12 @@ func TestCreateAppSession_ZeroNonce(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) participant1 := "0x1111111111111111111111111111111111111111" @@ -304,14 +292,12 @@ func TestCreateAppSession_QuorumExceedsTotalWeights(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Test data @@ -375,14 +361,12 @@ func TestCreateAppSession_NoSignatures(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Test data @@ -440,14 +424,12 @@ func TestCreateAppSession_SignatureFromNonParticipant(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Create a wallet that is NOT a participant @@ -481,10 +463,6 @@ func TestCreateAppSession_SignatureFromNonParticipant(t *testing.T) { QuorumSigs: []string{sig}, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() - // Create RPC context payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -522,14 +500,12 @@ func TestCreateAppSession_QuorumNotMet(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Create a real wallet for participant1 @@ -577,10 +553,6 @@ func TestCreateAppSession_QuorumNotMet(t *testing.T) { }, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() - // Create RPC context payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -618,14 +590,12 @@ func TestCreateAppSession_DuplicateSignatures(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Create a real wallet for participant1 @@ -669,10 +639,6 @@ func TestCreateAppSession_DuplicateSignatures(t *testing.T) { }, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() - // Create RPC context payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -711,14 +677,12 @@ func TestCreateAppSession_InvalidSignatureHex(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Test data @@ -739,10 +703,6 @@ func TestCreateAppSession_InvalidSignatureHex(t *testing.T) { QuorumSigs: []string{"not-valid-hex"}, // Invalid hex string } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() - // Create RPC context payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -780,14 +740,12 @@ func TestCreateAppSession_SignatureRecoveryFailure(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) // Test data @@ -809,10 +767,6 @@ func TestCreateAppSession_SignatureRecoveryFailure(t *testing.T) { QuorumSigs: []string{"0xa100000000"}, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", CreationApprovalNotRequired: true}, - }, nil).Once() - // Create RPC context payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -835,9 +789,8 @@ func TestCreateAppSession_SignatureRecoveryFailure(t *testing.T) { mockStore.AssertExpectations(t) } -func TestCreateAppSession_AppNotRegistered(t *testing.T) { +func TestCreateAppSession_TotalWeightsOver255(t *testing.T) { mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { return fn(mockStore) } @@ -849,43 +802,46 @@ func TestCreateAppSession_AppNotRegistered(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) + // Two participants each with weight 200; real total = 400, wraps to 144 in uint8. + // Quorum = 200 is achievable (wallet1 alone covers it) but 200 > 144 would have + // triggered a false rejection before the fix. wallet1 := NewTestAppSessionWallet(t) participant1 := wallet1.Address + participant2 := "0x2222222222222222222222222222222222222222" appDef := app.AppDefinitionV1{ - ApplicationID: "unregistered-app", + ApplicationID: "test-app", Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, + {WalletAddress: participant1, SignatureWeight: 200}, + {WalletAddress: participant2, SignatureWeight: 200}, }, - Quorum: 1, + Quorum: 200, Nonce: 12345, } sig1 := wallet1.SignCreateRequest(t, appDef, "") reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ Definition: rpc.AppDefinitionV1{ - Application: "unregistered-app", + Application: "test-app", Participants: []rpc.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, + {WalletAddress: participant1, SignatureWeight: 200}, + {WalletAddress: participant2, SignatureWeight: 200}, }, - Quorum: 1, + Quorum: 200, Nonce: "12345", }, QuorumSigs: []string{sig1}, } - // GetApp returns nil (not found) - mockStore.On("GetApp", "unregistered-app").Return(nil, nil).Once() + mockStore.On("CreateAppSession", mock.Anything).Return(nil).Once() payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -897,92 +853,19 @@ func TestCreateAppSession_AppNotRegistered(t *testing.T) { handler.CreateAppSession(ctx) - assert.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "not registered") - - mockStore.AssertExpectations(t) -} - -func TestCreateAppSession_OwnerSigRequired(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, - ) - - wallet1 := NewTestAppSessionWallet(t) - participant1 := wallet1.Address - - appDef := app.AppDefinitionV1{ - ApplicationID: "restricted-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, - }, - Quorum: 1, - Nonce: 12345, - } - sig1 := wallet1.SignCreateRequest(t, appDef, "") - - reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ - Definition: rpc.AppDefinitionV1{ - Application: "restricted-app", - Participants: []rpc.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, - }, - Quorum: 1, - Nonce: "12345", - }, - QuorumSigs: []string{sig1}, - // No OwnerSig provided - } - - // App requires approval (CreationApprovalNotRequired = false) - mockStore.On("GetApp", "restricted-app").Return(&app.AppInfoV1{ - App: app.AppV1{ - ID: "restricted-app", - OwnerWallet: "0xowneraddr", - CreationApprovalNotRequired: false, - }, - }, nil).Once() - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppSessionsV1CreateAppSessionMethod), payload), + require.NotNil(t, ctx.Response) + if respErr := ctx.Response.Error(); respErr != nil { + t.Fatalf("Unexpected error: total weights 400 with quorum 200 must be accepted, got: %v", respErr) } - - handler.CreateAppSession(ctx) - - assert.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "owner_sig is required") - + assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) mockStore.AssertExpectations(t) } -func TestCreateAppSession_OwnerSigSuccess(t *testing.T) { +// TestCreateAppSession_DuplicateParticipantAcrossCases verifies that two participant +// addresses that differ only in letter case are detected as duplicates. Without address +// normalization the duplicate-check map would key on the raw representation and accept +// the same wallet twice. +func TestCreateAppSession_DuplicateParticipantAcrossCases(t *testing.T) { mockStore := new(MockStore) storeTxProvider := func(fn StoreTxHandler) error { @@ -996,62 +879,30 @@ func TestCreateAppSession_OwnerSigSuccess(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) - // Create participant and owner wallets - wallet1 := NewTestAppSessionWallet(t) - ownerWallet := NewTestAppSessionWallet(t) - participant1 := wallet1.Address - - appDef := app.AppDefinitionV1{ - ApplicationID: "restricted-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, - }, - Quorum: 1, - Nonce: 12345, - } - sessionData := `{"game": "poker"}` - - // Participant signs for quorum - sig1 := wallet1.SignCreateRequest(t, appDef, sessionData) - // Owner signs for approval - ownerSig := ownerWallet.SignCreateRequest(t, appDef, sessionData) + lower := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + upper := "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ Definition: rpc.AppDefinitionV1{ - Application: "restricted-app", + Application: "test-app", Participants: []rpc.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, + {WalletAddress: lower, SignatureWeight: 1}, + {WalletAddress: upper, SignatureWeight: 1}, }, Quorum: 1, Nonce: "12345", }, - QuorumSigs: []string{sig1}, - SessionData: sessionData, - OwnerSig: ownerSig, + QuorumSigs: []string{"0xdeadbeef"}, } - // App requires approval — owner wallet matches - mockStore.On("GetApp", "restricted-app").Return(&app.AppInfoV1{ - App: app.AppV1{ - ID: "restricted-app", - OwnerWallet: ownerWallet.Address, - CreationApprovalNotRequired: false, - }, - }, nil).Once() - mockStore.On("CreateAppSession", mock.MatchedBy(func(session any) bool { - return true - })).Return(nil).Once() - payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -1062,30 +913,17 @@ func TestCreateAppSession_OwnerSigSuccess(t *testing.T) { handler.CreateAppSession(ctx) - assert.NotNil(t, ctx.Response) - - if respErr := ctx.Response.Error(); respErr != nil { - t.Fatalf("Unexpected error response: %v", respErr) - } - - assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) - - var resp rpc.AppSessionsV1CreateAppSessionResponse - err = ctx.Response.Payload.Translate(&resp) - require.NoError(t, err) - - assert.NotEmpty(t, resp.AppSessionID) - assert.Equal(t, "1", resp.Version) - assert.Equal(t, app.AppSessionStatusOpen.String(), resp.Status) - - mockStore.AssertExpectations(t) + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "duplicate participant address") + mockStore.AssertNotCalled(t, "CreateAppSession", mock.Anything) } -// TestCreateAppSession_AppRegistryDisabled verifies that when appRegistryEnabled=false, -// app lookup, owner signature validation, and AllowAction are all skipped. -func TestCreateAppSession_AppRegistryDisabled(t *testing.T) { +// TestCreateAppSession_TotalWeightsWrapToZero tests the 128+128=256 case where uint8 wraps +// to exactly 0 — the most damaging overflow (any quorum > 0 appears unreachable). +func TestCreateAppSession_TotalWeightsWrapToZero(t *testing.T) { mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { return fn(mockStore) } @@ -1097,16 +935,15 @@ func TestCreateAppSession_AppRegistryDisabled(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xnode", - false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) + // 128+128=256 wraps to 0 in uint8 — any quorum > 0 would appear unreachable. wallet1 := NewTestAppSessionWallet(t) participant1 := wallet1.Address participant2 := "0x2222222222222222222222222222222222222222" @@ -1114,33 +951,28 @@ func TestCreateAppSession_AppRegistryDisabled(t *testing.T) { appDef := app.AppDefinitionV1{ ApplicationID: "test-app", Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, - {WalletAddress: participant2, SignatureWeight: 1}, + {WalletAddress: participant1, SignatureWeight: 128}, + {WalletAddress: participant2, SignatureWeight: 128}, }, - Quorum: 1, + Quorum: 128, Nonce: 12345, } - sessionData := `{"test": "data"}` - sig1 := wallet1.SignCreateRequest(t, appDef, sessionData) + sig1 := wallet1.SignCreateRequest(t, appDef, "") reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ Definition: rpc.AppDefinitionV1{ Application: "test-app", Participants: []rpc.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 1}, - {WalletAddress: participant2, SignatureWeight: 1}, + {WalletAddress: participant1, SignatureWeight: 128}, + {WalletAddress: participant2, SignatureWeight: 128}, }, - Quorum: 1, + Quorum: 128, Nonce: "12345", }, - QuorumSigs: []string{sig1}, - SessionData: sessionData, + QuorumSigs: []string{sig1}, } - // Only CreateAppSession should be called — NO GetApp, NO AllowAction - mockStore.On("CreateAppSession", mock.MatchedBy(func(session any) bool { - return true - })).Return(nil).Once() + mockStore.On("CreateAppSession", mock.Anything).Return(nil).Once() payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -1152,63 +984,49 @@ func TestCreateAppSession_AppRegistryDisabled(t *testing.T) { handler.CreateAppSession(ctx) - assert.NotNil(t, ctx.Response) + require.NotNil(t, ctx.Response) if respErr := ctx.Response.Error(); respErr != nil { - t.Fatalf("Unexpected error response: %v", respErr) + t.Fatalf("total weights 256 (128+128) with quorum 128 must be accepted, got: %v", respErr) } assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) - - var resp rpc.AppSessionsV1CreateAppSessionResponse - err = ctx.Response.Payload.Translate(&resp) - require.NoError(t, err) - assert.NotEmpty(t, resp.AppSessionID) - assert.Equal(t, "1", resp.Version) - assert.Equal(t, app.AppSessionStatusOpen.String(), resp.Status) - - // Strict: GetApp and AllowAction must NOT have been called - mockStore.AssertNotCalled(t, "GetApp", mock.Anything) mockStore.AssertExpectations(t) } -// TestCreateAppSession_DuplicateParticipantAcrossCases verifies that two participant -// addresses that differ only in letter case are detected as duplicates. Without address -// normalization the duplicate-check map would key on the raw representation and accept -// the same wallet twice. -func TestCreateAppSession_DuplicateParticipantAcrossCases(t *testing.T) { +// TestCreateAppSession_QuorumExceedsTotalWeights_Rejected verifies that a quorum genuinely +// larger than the real total weight is rejected. Uses small weights (100+100=200) because +// Quorum is uint8 and cannot exceed 255, so this guard cannot be exercised with total > 255. +func TestCreateAppSession_QuorumExceedsTotalWeights_Rejected(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, + new(MockAssetStore), + NewMockChannelSigner(), + core.NewStateAdvancerV1(new(MockAssetStore)), + new(MockStatePacker), "0xnode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) - lower := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - upper := "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + // Real total = 200+200 = 400; quorum = 255 (max uint8 but still < 400, so valid). + // quorum cannot exceed 255 because the wire type is uint8 — so we can't test quorum=401. + // Instead test that quorum=255 (which is < 400) is accepted. + // To test actual rejection: use quorum=255 with total weights=100+100=200 (uint8-range). + participant1 := "0x1111111111111111111111111111111111111111" + participant2 := "0x2222222222222222222222222222222222222222" reqPayload := rpc.AppSessionsV1CreateAppSessionRequest{ Definition: rpc.AppDefinitionV1{ Application: "test-app", Participants: []rpc.AppParticipantV1{ - {WalletAddress: lower, SignatureWeight: 1}, - {WalletAddress: upper, SignatureWeight: 1}, + {WalletAddress: participant1, SignatureWeight: 100}, + {WalletAddress: participant2, SignatureWeight: 100}, }, - Quorum: 1, + Quorum: 255, // quorum (255) > real total (200) → must be rejected Nonce: "12345", }, QuorumSigs: []string{"0xdeadbeef"}, @@ -1226,8 +1044,7 @@ func TestCreateAppSession_DuplicateParticipantAcrossCases(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() - require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "duplicate participant address") - mockStore.AssertNotCalled(t, "GetApp", mock.Anything) - mockStore.AssertNotCalled(t, "CreateAppSession", mock.Anything) + require.Error(t, respErr) + assert.Contains(t, respErr.Error(), "quorum") + mockStore.AssertExpectations(t) } diff --git a/nitronode/api/app_session_v1/get_app_sessions.go b/nitronode/api/app_session_v1/get_app_sessions.go index f942085f3..e0a67a982 100644 --- a/nitronode/api/app_session_v1/get_app_sessions.go +++ b/nitronode/api/app_session_v1/get_app_sessions.go @@ -23,6 +23,14 @@ func (h *Handler) GetAppSessions(c *rpc.Context) { return } + // A provided app_session_id must be a well-formed hash. Rejecting it here + // stops an empty or malformed id from silently falling through to an + // unfiltered list (the store skips the id filter when it is empty). + if req.AppSessionID != nil && !core.IsValidHash(*req.AppSessionID, false) { + c.Fail(rpc.Errorf("invalid app_session_id"), "") + return + } + if req.Participant != nil { normalizedParticipant, err := core.NormalizeHexAddress(*req.Participant) if err != nil { diff --git a/nitronode/api/app_session_v1/get_app_sessions_test.go b/nitronode/api/app_session_v1/get_app_sessions_test.go index 4f0c06eb3..532daacd7 100644 --- a/nitronode/api/app_session_v1/get_app_sessions_test.go +++ b/nitronode/api/app_session_v1/get_app_sessions_test.go @@ -160,7 +160,7 @@ func TestGetAppSessions_SuccessWithAppSessionID(t *testing.T) { } // Test data - sessionID := "session1" + sessionID := "0x3333333333333333333333333333333333333333333333333333333333333333" participant := "0x1234567890123456789012345678901234567890" sessions := []app.AppSessionV1{ @@ -189,7 +189,7 @@ func TestGetAppSessions_SuccessWithAppSessionID(t *testing.T) { // Mock expectations mockStore.On("GetAppSessions", &sessionID, (*string)(nil), app.AppSessionStatusVoid, &core.PaginationParams{}).Return(sessions, metadata, nil) - mockStore.On("GetParticipantAllocations", "session1").Return(map[string]map[string]decimal.Decimal{}, nil) + mockStore.On("GetParticipantAllocations", sessionID).Return(map[string]map[string]decimal.Decimal{}, nil) // Create RPC request reqPayload := rpc.AppSessionsV1GetAppSessionsRequest{ @@ -271,6 +271,45 @@ func TestGetAppSessions_MissingRequiredParams(t *testing.T) { mockStore.AssertExpectations(t) } +func TestGetAppSessions_InvalidAppSessionID(t *testing.T) { + mockStore := new(MockStore) + mockSigner := NewMockChannelSigner() + mockAssetStore := new(MockAssetStore) + + handler := &Handler{ + useStoreInTx: func(fn StoreTxHandler) error { + return fn(mockStore) + }, + signer: mockSigner, + nodeAddress: mockSigner.PublicKey().Address().String(), + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: new(MockStatePacker), + metrics: metrics.NewNoopRuntimeMetricExporter(), + } + + // An empty or malformed app_session_id must be rejected, not silently + // dropped into an unfiltered list. + for _, id := range []string{"", "0xabc", "session1"} { + invalidID := id + reqPayload := rpc.AppSessionsV1GetAppSessionsRequest{AppSessionID: &invalidID} + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "app_sessions.v1.get_app_sessions", Payload: payload}, + } + + handler.GetAppSessions(ctx) + + require.NotNil(t, ctx.Response.Error(), "id %q should be rejected", id) + assert.Contains(t, ctx.Response.Error().Error(), "invalid app_session_id") + } + + // A malformed id must never reach the store. + mockStore.AssertNotCalled(t, "GetAppSessions") +} + func TestGetAppSessions_WithStatusFilter(t *testing.T) { // Setup mockStore := new(MockStore) @@ -411,6 +450,77 @@ func TestGetAppSessions_StoreError(t *testing.T) { mockStore.AssertExpectations(t) } +// TestGetAppSessions_NilLimit verifies that omitting pagination.limit (nil) uses the default +// and succeeds without error. +func TestGetAppSessions_NilLimit(t *testing.T) { + mockStore := new(MockStore) + + handler := &Handler{ + useStoreInTx: func(fn StoreTxHandler) error { return fn(mockStore) }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + } + + participant := "0x1234567890123456789012345678901234567890" + + // Pagination present but Limit omitted — should reach the store with default behavior. + mockStore.On("GetAppSessions", (*string)(nil), &participant, app.AppSessionStatusVoid, &core.PaginationParams{Limit: nil}). + Return([]app.AppSessionV1{}, core.PaginationMetadata{Page: 1, PerPage: 50, PageCount: 0, TotalCount: 0}, nil) + + reqPayload := rpc.AppSessionsV1GetAppSessionsRequest{ + Participant: &participant, + Pagination: &rpc.PaginationParamsV1{}, // Limit is nil + } + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "app_sessions.v1.get_app_sessions", Payload: payload}, + } + + handler.GetAppSessions(ctx) + + require.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) +} + +// TestGetAppSessions_ZeroLimit verifies that pagination.limit == 0 is treated as absent +// (coerced to the default limit) and succeeds without error. +func TestGetAppSessions_ZeroLimit(t *testing.T) { + mockStore := new(MockStore) + + handler := &Handler{ + useStoreInTx: func(fn StoreTxHandler) error { return fn(mockStore) }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + } + + participant := "0x1234567890123456789012345678901234567890" + zero := uint32(0) + + // limit=0 passes through to the store; GetOffsetAndLimit coerces it to DefaultLimit. + mockStore.On("GetAppSessions", (*string)(nil), &participant, app.AppSessionStatusVoid, &core.PaginationParams{Limit: &zero}). + Return([]app.AppSessionV1{}, core.PaginationMetadata{Page: 1, PerPage: 10, PageCount: 0, TotalCount: 0}, nil) + + reqPayload := rpc.AppSessionsV1GetAppSessionsRequest{ + Participant: &participant, + Pagination: &rpc.PaginationParamsV1{ + Limit: &zero, + }, + } + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "app_sessions.v1.get_app_sessions", Payload: payload}, + } + + handler.GetAppSessions(ctx) + + require.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) +} + // TestGetAppSessions_NormalizesParticipant verifies the participant filter is normalized // before being passed to the store. func TestGetAppSessions_NormalizesParticipant(t *testing.T) { 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 997ebca90..1eaefb8f5 100644 --- a/nitronode/api/app_session_v1/get_last_key_states.go +++ b/nitronode/api/app_session_v1/get_last_key_states.go @@ -2,6 +2,7 @@ package app_session_v1 import ( "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" ) @@ -23,7 +24,7 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { return } - var limit, offset uint32 + var paginationParams core.PaginationParams 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 @@ -33,14 +34,14 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { 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 - } + paginationParams.Offset = req.Pagination.Offset + paginationParams.Limit = req.Pagination.Limit } - if limit == 0 || limit > rpc.GetLastKeyStatesPageLimit { + // GetOffsetAndLimit caps the limit and clamps the offset so the later + // int(offset) conversion in the store never wraps negative. An explicit + // limit of 0 still falls back to the page limit. + offset, limit := paginationParams.GetOffsetAndLimit(rpc.GetLastKeyStatesPageLimit, rpc.GetLastKeyStatesPageLimit) + if limit == 0 { limit = rpc.GetLastKeyStatesPageLimit } diff --git a/nitronode/api/app_session_v1/handler.go b/nitronode/api/app_session_v1/handler.go index ac30997c4..4a13482ec 100644 --- a/nitronode/api/app_session_v1/handler.go +++ b/nitronode/api/app_session_v1/handler.go @@ -27,17 +27,14 @@ import ( 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 maxSessionKeysPerUser int } @@ -45,30 +42,25 @@ type Handler struct { func NewHandler( useStoreInTx StoreTxProvider, assetStore AssetStore, - actionGateway ActionGateway, signer *core.ChannelDefaultSigner, stateAdvancer core.StateAdvancer, statePacker core.StatePacker, nodeAddress string, - appRegistryEnabled bool, m metrics.RuntimeMetricExporter, - maxParticipants, maxSessionData, maxSessionKeyIDs, maxSignedUpdates int, + maxParticipants, maxSessionData, maxSessionKeyIDs 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, maxSessionKeysPerUser: maxSessionKeysPerUser, } } @@ -76,7 +68,7 @@ func NewHandler( func (h *Handler) verifyQuorum(tx Store, appSessionId, applicationID string, participantWeights map[string]uint8, requiredQuorum uint8, data []byte, signatures []string) error { // Verify signatures and calculate quorum signedWeights := make(map[string]bool) - var achievedQuorum uint8 + var achievedQuorum uint16 appSessionSignerValidator := app.NewAppSessionKeySigValidatorV1( func(sessionKeyAddr string) (string, error) { @@ -112,12 +104,12 @@ func (h *Handler) verifyQuorum(tx Store, appSessionId, applicationID string, par // Add weight if not already counted if !signedWeights[userWallet] { signedWeights[userWallet] = true - achievedQuorum += weight + achievedQuorum += uint16(weight) } } // Check if quorum is met - if achievedQuorum < requiredQuorum { + if achievedQuorum < uint16(requiredQuorum) { return rpc.Errorf("quorum not met: achieved %d, required %d", achievedQuorum, requiredQuorum) } diff --git a/nitronode/api/app_session_v1/interface.go b/nitronode/api/app_session_v1/interface.go index 6a1185aae..82a653a12 100644 --- a/nitronode/api/app_session_v1/interface.go +++ b/nitronode/api/app_session_v1/interface.go @@ -3,7 +3,6 @@ package app_session_v1 import ( "time" - "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" @@ -12,9 +11,6 @@ import ( // Store defines the persistence layer interface for app session management. type Store interface { - // App registry operations - GetApp(appID string) (*app.AppInfoV1, error) - // App session operations CreateAppSession(session app.AppSessionV1) error GetAppSession(sessionID string) (*app.AppSessionV1, error) @@ -68,13 +64,6 @@ type Store interface { // Channel Session key state operations ValidateChannelSessionKeyForAsset(wallet, sessionKey, asset, metadataHash string) (bool, error) - - action_gateway.Store -} - -type ActionGateway interface { - // AllowAction checks if a user is allowed to perform a specific gated action based on their past activity and allowances. - AllowAction(tx action_gateway.Store, userAddress string, gatedAction core.GatedAction) error } // StoreTxHandler is a function that executes Store operations within a transaction. diff --git a/nitronode/api/app_session_v1/rebalance_app_sessions.go b/nitronode/api/app_session_v1/rebalance_app_sessions.go deleted file mode 100644 index 9f1b45ed7..000000000 --- a/nitronode/api/app_session_v1/rebalance_app_sessions.go +++ /dev/null @@ -1,355 +0,0 @@ -package app_session_v1 - -import ( - "fmt" - "time" - - "github.com/shopspring/decimal" - - "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" -) - -// RebalanceAppSessions processes multi-session rebalancing operations atomically. -// Rebalancing redistributes funds across multiple app sessions in a single atomic operation, -// potentially involving multiple assets. Each asset's balance changes must sum to zero (conservation). -func (h *Handler) RebalanceAppSessions(c *rpc.Context) { - ctx := c.Context - logger := log.FromContext(ctx) - - var reqPayload rpc.AppSessionsV1RebalanceAppSessionsRequest - if err := c.Request.Payload.Translate(&reqPayload); err != nil { - c.Fail(err, "failed to parse parameters") - return - } - - if len(reqPayload.SignedUpdates) > h.maxSignedUpdates { - c.Fail(rpc.Errorf("signed_updates array exceeds maximum length of %d", h.maxSignedUpdates), "") - return - } - - if len(reqPayload.SignedUpdates) < 2 { - c.Fail(rpc.Errorf("rebalancing requires at least 2 sessions"), "") - return - } - logger.Debug("processing app session rebalancing request", "sessionCount", len(reqPayload.SignedUpdates)) - - // Parse and validate all app state updates - updates := make([]app.SignedAppStateUpdateV1, len(reqPayload.SignedUpdates)) - seenSessions := make(map[string]bool) - - for i, signedUpdate := range reqPayload.SignedUpdates { - if len(signedUpdate.AppStateUpdate.SessionData) > h.maxSessionData { - c.Fail(rpc.Errorf("signed_updates[%d].session_data exceeds maximum length of %d", i, h.maxSessionData), "") - return - } - - update, err := unmapSignedAppStateUpdateV1(&signedUpdate) - if err != nil { - c.Fail(err, fmt.Sprintf("failed to parse app state update %d", i)) - return - } - - // Validate intent is rebalance - if update.AppStateUpdate.Intent != app.AppStateUpdateIntentRebalance { - c.Fail(rpc.Errorf("all updates must have 'rebalance' intent, got '%s' for session %s", - update.AppStateUpdate.Intent.String(), update.AppStateUpdate.AppSessionID), "") - return - } - - // Only one app state update per app session is allowed - if seenSessions[update.AppStateUpdate.AppSessionID] { - c.Fail(rpc.Errorf("duplicate session in rebalance: %s", update.AppStateUpdate.AppSessionID), "") - return - } - seenSessions[update.AppStateUpdate.AppSessionID] = true - - updates[i] = update - - logger.Debug("parsed rebalance update", - "sessionID", update.AppStateUpdate.AppSessionID, - "version", update.AppStateUpdate.Version, - "allocations", len(update.AppStateUpdate.Allocations)) - } - - var batchID string - err := h.useStoreInTx(func(tx Store) error { - // applicationID is the shared application of all rebalanced sessions; enforced below. - var applicationID string - // Generate deterministic batch ID from session IDs and versions - sessionVersions := make([]app.AppSessionVersionV1, len(updates)) - for i, u := range updates { - sessionVersions[i] = app.AppSessionVersionV1{ - SessionID: u.AppStateUpdate.AppSessionID, - Version: u.AppStateUpdate.Version, - } - } - var err error - batchID, err = app.GenerateRebalanceBatchIDV1(sessionVersions) - if err != nil { - return rpc.Errorf("failed to generate batch ID: %v", err) - } - - // Track all balance changes per session per participant per asset - balanceChanges := make(map[string]map[string]map[string]decimal.Decimal) // sessionID -> participant -> asset -> change - assetTotalDiff := make(map[string]decimal.Decimal) // asset -> total change - - // Validate and process each session - for i, update := range updates { - appSession, err := tx.GetAppSession(update.AppStateUpdate.AppSessionID) - if err != nil { - return rpc.Errorf("failed to get app session %s: %v", update.AppStateUpdate.AppSessionID, err) - } - if appSession == nil { - return rpc.Errorf("app session not found: %s", update.AppStateUpdate.AppSessionID) - } - - // All rebalanced sessions must belong to the same application. - // Quoting keeps legacy (untagged, "") sessions visually - // distinguishable from tagged ones in the error message. - if i == 0 { - applicationID = appSession.ApplicationID - } else if appSession.ApplicationID != applicationID { - return rpc.Errorf("cannot rebalance app sessions from different applications: '%s' vs '%s'", applicationID, appSession.ApplicationID) - } - if h.appRegistryEnabled { - registeredApp, err := tx.GetApp(appSession.ApplicationID) - if err != nil { - return rpc.Errorf("failed to look up application: %v", err) - } - if registeredApp == nil { - return rpc.Errorf("application %s is not registered", appSession.ApplicationID) - } - - err = h.actionGateway.AllowAction(tx, registeredApp.App.OwnerWallet, update.AppStateUpdate.Intent.GatedAction()) - if err != nil { - return rpc.NewError(err) - } - } - if len(update.QuorumSigs) > len(appSession.Participants) { - return rpc.Errorf("quorum_sigs count (%d) exceeds participants count (%d)", len(update.QuorumSigs), len(appSession.Participants)) - } - if appSession.Status == app.AppSessionStatusClosed { - return rpc.Errorf("app session %s is already closed", update.AppStateUpdate.AppSessionID) - } - if update.AppStateUpdate.Version != appSession.Version+1 { - return rpc.Errorf("invalid version for session %s: expected %d, got %d", - update.AppStateUpdate.AppSessionID, appSession.Version+1, update.AppStateUpdate.Version) - } - - // Verify quorum - participantWeights := getParticipantWeights(appSession.Participants) - if len(update.QuorumSigs) == 0 { - return rpc.Errorf("no signatures provided for session %s", update.AppStateUpdate.AppSessionID) - } - - packedStateUpdate, err := app.PackAppStateUpdateV1(update.AppStateUpdate) - if err != nil { - return rpc.Errorf("failed to pack app state update for session %s: %v", update.AppStateUpdate.AppSessionID, err) - } - - if err := h.verifyQuorum(tx, update.AppStateUpdate.AppSessionID, appSession.ApplicationID, participantWeights, appSession.Quorum, packedStateUpdate, update.QuorumSigs); err != nil { - return rpc.Errorf("quorum verification failed for session %s: %v", update.AppStateUpdate.AppSessionID, err) - } - - // Get current allocations - currentAllocations, err := tx.GetParticipantAllocations(update.AppStateUpdate.AppSessionID) - if err != nil { - return rpc.Errorf("failed to get current allocations for session %s: %v", update.AppStateUpdate.AppSessionID, err) - } - - // Build map of new allocations - newAllocations := make(map[string]map[string]decimal.Decimal) // participant -> asset -> amount - for _, alloc := range update.AppStateUpdate.Allocations { - // Validate participant exists - if _, ok := participantWeights[alloc.Participant]; !ok { - return rpc.Errorf("allocation to non-participant %s in session %s", alloc.Participant, update.AppStateUpdate.AppSessionID) - } - - if alloc.Amount.IsNegative() { - return rpc.Errorf("negative allocation: %s for asset %s in session %s", - alloc.Amount, alloc.Asset, update.AppStateUpdate.AppSessionID) - } - - // Reject duplicate (participant, asset) entries - if newAllocations[alloc.Participant] != nil { - if _, exists := newAllocations[alloc.Participant][alloc.Asset]; exists { - return rpc.Errorf("duplicate allocation for participant %s, asset %s in session %s", - alloc.Participant, alloc.Asset, update.AppStateUpdate.AppSessionID) - } - } - - if newAllocations[alloc.Participant] == nil { - newAllocations[alloc.Participant] = make(map[string]decimal.Decimal) - } - newAllocations[alloc.Participant][alloc.Asset] = alloc.Amount - } - - // Calculate balance changes for this session per participant - sessionChanges := make(map[string]map[string]decimal.Decimal) // participant -> asset -> change - - // Process all assets in current allocations - for participant, assets := range currentAllocations { - for asset, currentAmount := range assets { - newAmount := decimal.Zero - if newAllocations[participant] != nil { - newAmount = newAllocations[participant][asset] - } - - change := newAmount.Sub(currentAmount) - if !change.IsZero() { - if sessionChanges[participant] == nil { - sessionChanges[participant] = make(map[string]decimal.Decimal) - } - sessionChanges[participant][asset] = change - assetTotalDiff[asset] = assetTotalDiff[asset].Add(change) - } - } - } - - // Process any new assets in new allocations - for participant, assets := range newAllocations { - for asset, newAmount := range assets { - if currentAllocations[participant] == nil || currentAllocations[participant][asset].IsZero() { - if !newAmount.IsZero() { - if sessionChanges[participant] == nil { - sessionChanges[participant] = make(map[string]decimal.Decimal) - } - sessionChanges[participant][asset] = newAmount - assetTotalDiff[asset] = assetTotalDiff[asset].Add(newAmount) - } - } - } - } - - // Store session changes - balanceChanges[update.AppStateUpdate.AppSessionID] = sessionChanges - - // Update app session - appSession.Version++ - if update.AppStateUpdate.SessionData != "" { - appSession.SessionData = update.AppStateUpdate.SessionData - } - appSession.UpdatedAt = time.Now() - - if err := tx.UpdateAppSession(*appSession); err != nil { - return rpc.Errorf("failed to update app session %s: %v", update.AppStateUpdate.AppSessionID, err) - } - } - - // Validate conservation: sum of changes must be zero for each asset - for asset, total := range assetTotalDiff { - if !total.IsZero() { - return rpc.Errorf("conservation violation for asset %s: total change is %s (must be 0)", - asset, total.String()) - } - } - - // Record ledger entries per participant - for sessionID, participantChanges := range balanceChanges { - for participant, assetChanges := range participantChanges { - for asset, diff := range assetChanges { - if diff.IsZero() { - continue - } - - // Record ledger entry with user wallet (participant) - if err := tx.RecordLedgerEntry(participant, sessionID, asset, diff); err != nil { - return rpc.Errorf("failed to record ledger entry for session %s: %v", sessionID, err) - } - } - } - } - - // Calculate aggregate changes per session per asset and record transactions - sessionAssetChanges := make(map[string]map[string]decimal.Decimal) // sessionID -> asset -> aggregated change - for sessionID, participantChanges := range balanceChanges { - sessionAssetChanges[sessionID] = make(map[string]decimal.Decimal) - for _, assetChanges := range participantChanges { - for asset, diff := range assetChanges { - sessionAssetChanges[sessionID][asset] = sessionAssetChanges[sessionID][asset].Add(diff) - } - } - } - - // Record one transaction per session per asset - for sessionID, assetChanges := range sessionAssetChanges { - for asset, diff := range assetChanges { - if diff.IsZero() { - continue - } - - // Record transaction for the aggregated change - var fromAccount, toAccount string - var amount decimal.Decimal - - if diff.IsPositive() { - // Session gaining funds: batch -> session - fromAccount = batchID - toAccount = sessionID - amount = diff - } else { - // Session losing funds: session -> batch - fromAccount = sessionID - toAccount = batchID - amount = diff.Abs() - } - - txID, err := app.GenerateRebalanceTransactionIDV1(batchID, sessionID, asset) - if err != nil { - return rpc.Errorf("failed to generate transaction ID for session %s: %v", sessionID, err) - } - - transaction := core.NewTransaction( - txID, - asset, - core.TransactionTypeRebalance, - fromAccount, - toAccount, - nil, // No sender state for app sessions - nil, // No receiver state for app sessions - amount, - ) - - if err := tx.RecordTransaction(*transaction, applicationID); err != nil { - return rpc.Errorf("failed to record transaction for session %s: %v", sessionID, err) - } - - logger.Info("recorded transaction", - "txID", transaction.ID, - "txType", transaction.TxType.String(), - "from", transaction.FromAccount, - "to", transaction.ToAccount, - "asset", transaction.Asset, - "amount", transaction.Amount.String()) - } - } - - logger.Info("processed app session rebalancing", - "batchID", batchID, - "sessionCount", len(updates), - "assetCount", len(assetTotalDiff)) - - return nil - }) - - if err != nil { - logger.Error("failed to process rebalancing", "error", err) - c.Fail(err, "failed to process rebalancing") - return - } - - resp := rpc.AppSessionsV1RebalanceAppSessionsResponse{ - BatchID: batchID, - } - - payload, err := rpc.NewPayload(resp) - if err != nil { - c.Fail(err, "failed to create response") - return - } - - c.Succeed(c.Request.Method, payload) -} diff --git a/nitronode/api/app_session_v1/rebalance_app_sessions_test.go b/nitronode/api/app_session_v1/rebalance_app_sessions_test.go deleted file mode 100644 index 264bcb2ba..000000000 --- a/nitronode/api/app_session_v1/rebalance_app_sessions_test.go +++ /dev/null @@ -1,1358 +0,0 @@ -package app_session_v1 - -import ( - "context" - "testing" - - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/layer-3/nitrolite/nitronode/metrics" - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/rpc" -) - -// assertSuccess checks if the RPC context has a successful response -func assertSuccess(t *testing.T, ctx *rpc.Context) { - require.NotNil(t, ctx.Response) - if respErr := ctx.Response.Error(); respErr != nil { - t.Fatalf("Unexpected error response: %v", respErr) - } - assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) -} - -// assertError checks if the RPC context has an error response with the expected message -func assertError(t *testing.T, ctx *rpc.Context, expectedMessage string) { - require.NotNil(t, ctx.Response) - err := ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), expectedMessage) -} - -func TestRebalanceAppSessions_Success_TwoSessions(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - // Create test wallets with real keys - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 5, - SessionData: `{"data":"session1"}`, - } - - session2 := &app.AppSessionV1{ - SessionID: sessionID2, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet2.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 3, - SessionData: `{"data":"session2"}`, - } - - // Session 1: currently has 200 USDC, will have 100 USDC (loses 100) - currentAllocations1 := map[string]map[string]decimal.Decimal{ - wallet1.Address: { - "USDC": decimal.NewFromInt(200), - }, - } - - // Session 2: currently has 50 USDC, will have 150 USDC (gains 100) - currentAllocations2 := map[string]map[string]decimal.Decimal{ - wallet2.Address: { - "USDC": decimal.NewFromInt(50), - }, - } - - // Build app state updates for signing - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 6, - Allocations: []app.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - }, - SessionData: `{"data":"session1_updated"}`, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 4, - Allocations: []app.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: decimal.NewFromInt(150)}, - }, - SessionData: `{"data":"session2_updated"}`, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "6", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: "100"}, - }, - SessionData: `{"data":"session1_updated"}`, - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "4", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: "150"}, - }, - SessionData: `{"data":"session2_updated"}`, - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - // Mock expectations for session 1 - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - mockStore.On("GetParticipantAllocations", sessionID1).Return(currentAllocations1, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { - return session.SessionID == sessionID1 && - session.Version == 6 && - session.SessionData == `{"data":"session1_updated"}` - })).Return(nil).Once() - - // Mock expectations for session 2 - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID2).Return(session2, nil) - mockStore.On("GetParticipantAllocations", sessionID2).Return(currentAllocations2, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { - return session.SessionID == sessionID2 && - session.Version == 4 && - session.SessionData == `{"data":"session2_updated"}` - })).Return(nil).Once() - - // Mock ledger entry and transaction recording - mockStore.On("RecordLedgerEntry", wallet1.Address, sessionID1, "USDC", decimal.NewFromInt(-100)).Return(nil) - mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil) - mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool { - return tx.TxType == core.TransactionTypeRebalance && tx.Asset == "USDC" - }), mock.Anything).Return(nil).Twice() - - // Create RPC context - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - assertSuccess(t, ctx) - mockStore.AssertExpectations(t) - - // Verify response contains batch_id - var response rpc.AppSessionsV1RebalanceAppSessionsResponse - err = ctx.Response.Payload.Translate(&response) - require.NoError(t, err) - assert.NotEmpty(t, response.BatchID) -} - -func TestRebalanceAppSessions_Success_MultiAsset(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - // Create test wallets with real keys - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 1, - } - - session2 := &app.AppSessionV1{ - SessionID: sessionID2, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet2.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 1, - } - - // Session 1: 200 USDC, 1 ETH -> 100 USDC, 1.5 ETH (loses 100 USDC, gains 0.5 ETH) - currentAllocations1 := map[string]map[string]decimal.Decimal{ - wallet1.Address: { - "USDC": decimal.NewFromInt(200), - "ETH": decimal.NewFromInt(1), - }, - } - - // Session 2: 50 USDC, 2 ETH -> 150 USDC, 1.5 ETH (gains 100 USDC, loses 0.5 ETH) - currentAllocations2 := map[string]map[string]decimal.Decimal{ - wallet2.Address: { - "USDC": decimal.NewFromInt(50), - "ETH": decimal.NewFromInt(2), - }, - } - - // Build app state updates for signing - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - Allocations: []app.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - {Participant: wallet1.Address, Asset: "ETH", Amount: decimal.RequireFromString("1.5")}, - }, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - Allocations: []app.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: decimal.NewFromInt(150)}, - {Participant: wallet2.Address, Asset: "ETH", Amount: decimal.RequireFromString("1.5")}, - }, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: "100"}, - {Participant: wallet1.Address, Asset: "ETH", Amount: "1.5"}, - }, - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: "150"}, - {Participant: wallet2.Address, Asset: "ETH", Amount: "1.5"}, - }, - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - // Mock expectations - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - mockStore.On("GetParticipantAllocations", sessionID1).Return(currentAllocations1, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool { - return s.SessionID == sessionID1 && s.Version == 2 - })).Return(nil).Once() - - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID2).Return(session2, nil) - mockStore.On("GetParticipantAllocations", sessionID2).Return(currentAllocations2, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool { - return s.SessionID == sessionID2 && s.Version == 2 - })).Return(nil).Once() - - // Ledger entries - mockStore.On("RecordLedgerEntry", wallet1.Address, sessionID1, "USDC", decimal.NewFromInt(-100)).Return(nil) - mockStore.On("RecordLedgerEntry", wallet1.Address, sessionID1, "ETH", decimal.RequireFromString("0.5")).Return(nil) - mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil) - mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "ETH", decimal.RequireFromString("-0.5")).Return(nil) - mockStore.On("RecordTransaction", mock.Anything, mock.Anything).Return(nil).Times(4) // 2 assets x 2 sessions - - // Create RPC context - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - assertSuccess(t, ctx) - mockStore.AssertExpectations(t) -} - -func TestRebalanceAppSessions_Error_InsufficientSessions(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - - appStateUpdate := app.AppStateUpdateV1{ - AppSessionID: "0x1111111111111111111111111111111111111111111111111111111111111111", - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: "0x1111111111111111111111111111111111111111111111111111111111111111", - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig1}, - }, - }, - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "rebalancing requires at least 2 sessions") -} - -func TestRebalanceAppSessions_Error_InvalidIntent(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: "0x1111111111111111111111111111111111111111111111111111111111111111", - Intent: app.AppStateUpdateIntentOperate, // Wrong intent - Version: 2, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: "0x2222222222222222222222222222222222222222222222222222222222222222", - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: "0x1111111111111111111111111111111111111111111111111111111111111111", - Intent: app.AppStateUpdateIntentOperate, // Wrong intent - Version: "2", - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: "0x2222222222222222222222222222222222222222222222222222222222222222", - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "all updates must have 'rebalance' intent") -} - -func TestRebalanceAppSessions_Error_DuplicateSession(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID := "0x1111111111111111111111111111111111111111111111111111111111111111" - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID, // Duplicate - Intent: app.AppStateUpdateIntentRebalance, - Version: 3, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID, // Duplicate - Intent: app.AppStateUpdateIntentRebalance, - Version: "3", - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "duplicate session in rebalance") -} - -func TestRebalanceAppSessions_Error_ConservationViolation(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - // Create test wallets with real keys - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 1, - } - - session2 := &app.AppSessionV1{ - SessionID: sessionID2, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet2.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 1, - } - - currentAllocations1 := map[string]map[string]decimal.Decimal{ - wallet1.Address: { - "USDC": decimal.NewFromInt(200), - }, - } - - currentAllocations2 := map[string]map[string]decimal.Decimal{ - wallet2.Address: { - "USDC": decimal.NewFromInt(50), - }, - } - - // Build app state updates for signing - // Session 1 loses 100 USDC, Session 2 gains 200 USDC (not conserved!) - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - Allocations: []app.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - }, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - Allocations: []app.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: decimal.NewFromInt(250)}, // Conservation violation - }, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: "100"}, - }, - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: "250"}, // Conservation violation - }, - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - // Mock expectations - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - mockStore.On("GetParticipantAllocations", sessionID1).Return(currentAllocations1, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool { - return s.SessionID == sessionID1 && s.Version == 2 - })).Return(nil).Once() - - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID2).Return(session2, nil) - mockStore.On("GetParticipantAllocations", sessionID2).Return(currentAllocations2, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool { - return s.SessionID == sessionID2 && s.Version == 2 - })).Return(nil).Once() - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "conservation violation") - mockStore.AssertExpectations(t) -} - -func TestRebalanceAppSessions_Error_SessionNotFound(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - // Mock first session returns nil (not found) - mockStore.On("GetAppSession", sessionID1).Return(nil, nil) - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "app session not found") - mockStore.AssertExpectations(t) -} - -func TestRebalanceAppSessions_Error_ClosedSession(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Status: app.AppSessionStatusClosed, // Closed - Version: 1, - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1.Address, SignatureWeight: 1}, - {WalletAddress: wallet2.Address, SignatureWeight: 1}, - }, - Quorum: 1, - } - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "already closed") - mockStore.AssertExpectations(t) -} - -func TestRebalanceAppSessions_Error_InvalidVersion(t *testing.T) { - // Setup - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Status: app.AppSessionStatusOpen, - Version: 5, // Current version is 5 - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1.Address, SignatureWeight: 1}, - {WalletAddress: wallet2.Address, SignatureWeight: 1}, - }, - Quorum: 1, - } - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 10, // Wrong version (should be 6) - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "10", // Wrong version (should be 6) - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - // Execute - handler.RebalanceAppSessions(ctx) - - // Assert - // Error case - assertError(t, ctx, "invalid version") - mockStore.AssertExpectations(t) -} - -// TestRebalanceAppSessions_AppRegistryDisabled verifies that when appRegistryEnabled=false, -// app lookup and AllowAction are skipped but rebalance still succeeds. -func TestRebalanceAppSessions_AppRegistryDisabled(t *testing.T) { - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - false, // appRegistryEnabled=false - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{{WalletAddress: wallet1.Address, SignatureWeight: 10}}, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 5, - } - - session2 := &app.AppSessionV1{ - SessionID: sessionID2, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{{WalletAddress: wallet2.Address, SignatureWeight: 10}}, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 3, - } - - currentAllocations1 := map[string]map[string]decimal.Decimal{ - wallet1.Address: {"USDC": decimal.NewFromInt(200)}, - } - currentAllocations2 := map[string]map[string]decimal.Decimal{ - wallet2.Address: {"USDC": decimal.NewFromInt(50)}, - } - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 6, - Allocations: []app.AppAllocationV1{{Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)}}, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 4, - Allocations: []app.AppAllocationV1{{Participant: wallet2.Address, Asset: "USDC", Amount: decimal.NewFromInt(150)}}, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "6", - Allocations: []rpc.AppAllocationV1{{Participant: wallet1.Address, Asset: "USDC", Amount: "100"}}, - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "4", - Allocations: []rpc.AppAllocationV1{{Participant: wallet2.Address, Asset: "USDC", Amount: "150"}}, - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - // NO GetApp mock — it should not be called - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - mockStore.On("GetParticipantAllocations", sessionID1).Return(currentAllocations1, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { - return session.SessionID == sessionID1 && session.Version == 6 - })).Return(nil).Once() - - mockStore.On("GetAppSession", sessionID2).Return(session2, nil) - mockStore.On("GetParticipantAllocations", sessionID2).Return(currentAllocations2, nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { - return session.SessionID == sessionID2 && session.Version == 4 - })).Return(nil).Once() - - mockStore.On("RecordLedgerEntry", wallet1.Address, sessionID1, "USDC", decimal.NewFromInt(-100)).Return(nil) - mockStore.On("RecordLedgerEntry", wallet2.Address, sessionID2, "USDC", decimal.NewFromInt(100)).Return(nil) - mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool { - return tx.TxType == core.TransactionTypeRebalance && tx.Asset == "USDC" - }), mock.Anything).Return(nil).Twice() - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - handler.RebalanceAppSessions(ctx) - - assertSuccess(t, ctx) - - // Strict: GetApp must NOT have been called - mockStore.AssertNotCalled(t, "GetApp", mock.Anything) - mockStore.AssertExpectations(t) -} - -func TestRebalanceAppSessions_Error_DuplicateAllocation(t *testing.T) { - mockStore := new(MockStore) - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - true, - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 1, - } - - session2 := &app.AppSessionV1{ - SessionID: sessionID2, - ApplicationID: "test-app", - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet2.Address, SignatureWeight: 10}, - }, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 1, - } - - currentAllocations1 := map[string]map[string]decimal.Decimal{ - wallet1.Address: {"USDC": decimal.NewFromInt(200)}, - } - - currentAllocations2 := map[string]map[string]decimal.Decimal{ - wallet2.Address: {"USDC": decimal.NewFromInt(50)}, - } - - // Session 1 has duplicate (wallet1, USDC) allocations - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - Allocations: []app.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - {Participant: wallet1.Address, Asset: "USDC", Amount: decimal.NewFromInt(50)}, // duplicate - }, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 2, - Allocations: []app.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - }, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet1.Address, Asset: "USDC", Amount: "100"}, - {Participant: wallet1.Address, Asset: "USDC", Amount: "50"}, // duplicate - }, - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "2", - Allocations: []rpc.AppAllocationV1{ - {Participant: wallet2.Address, Asset: "USDC", Amount: "100"}, - }, - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - mockStore.On("GetApp", mock.Anything).Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil) - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - mockStore.On("GetParticipantAllocations", sessionID1).Return(currentAllocations1, nil) - // Session 2 mocks in case session 1 processing doesn't fail first - mockStore.On("GetAppSession", sessionID2).Return(session2, nil).Maybe() - mockStore.On("GetParticipantAllocations", sessionID2).Return(currentAllocations2, nil).Maybe() - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - handler.RebalanceAppSessions(ctx) - - assertError(t, ctx, "duplicate allocation") - - mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) -} - -func TestRebalanceAppSessions_Error_DifferentApplications(t *testing.T) { - mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - storeTxProvider, - nil, - &MockActionGateway{}, - nil, - nil, - nil, - "0xNode", - false, // registry disabled — simpler: skip GetApp path - metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, - ) - - wallet1 := NewTestAppSessionWallet(t) - wallet2 := NewTestAppSessionWallet(t) - - sessionID1 := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID2 := "0x2222222222222222222222222222222222222222222222222222222222222222" - - session1 := &app.AppSessionV1{ - SessionID: sessionID1, - ApplicationID: "app-one", - Participants: []app.AppParticipantV1{{WalletAddress: wallet1.Address, SignatureWeight: 10}}, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 5, - } - session2 := &app.AppSessionV1{ - SessionID: sessionID2, - ApplicationID: "app-two", // Different application - Participants: []app.AppParticipantV1{{WalletAddress: wallet2.Address, SignatureWeight: 10}}, - Quorum: 10, - Status: app.AppSessionStatusOpen, - Version: 3, - } - - appStateUpdate1 := app.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: 6, - } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate1) - - appStateUpdate2 := app.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: 4, - } - sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate2) - - reqPayload := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: []rpc.SignedAppStateUpdateV1{ - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID1, - Intent: app.AppStateUpdateIntentRebalance, - Version: "6", - }, - QuorumSigs: []string{sig1}, - }, - { - AppStateUpdate: rpc.AppStateUpdateV1{ - AppSessionID: sessionID2, - Intent: app.AppStateUpdateIntentRebalance, - Version: "4", - }, - QuorumSigs: []string{sig2}, - }, - }, - } - - mockStore.On("GetAppSession", sessionID1).Return(session1, nil) - mockStore.On("GetAppSession", sessionID2).Return(session2, nil) - // Session 1 is fully processed (tx rolls back on failure); session 2 trips the cross-app check. - emptyAllocations := map[string]map[string]decimal.Decimal{} - mockStore.On("GetParticipantAllocations", sessionID1).Return(emptyAllocations, nil).Maybe() - mockStore.On("UpdateAppSession", mock.Anything).Return(nil).Maybe() - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "app_sessions.v1.rebalance_app_sessions", payload), - } - - handler.RebalanceAppSessions(ctx) - - assertError(t, ctx, "cannot rebalance app sessions from different applications") - - // Ledger/transaction writes happen only after all per-session validation completes, - // so the cross-app failure on session 2 must prevent them entirely. - mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) - mockStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) -} diff --git a/nitronode/api/app_session_v1/submit_app_state.go b/nitronode/api/app_session_v1/submit_app_state.go index cdf4652c1..833fed87a 100644 --- a/nitronode/api/app_session_v1/submit_app_state.go +++ b/nitronode/api/app_session_v1/submit_app_state.go @@ -66,20 +66,6 @@ func (h *Handler) SubmitAppState(c *rpc.Context) { return rpc.Errorf("app session is already closed") } - if h.appRegistryEnabled { - registeredApp, err := tx.GetApp(appSession.ApplicationID) - if err != nil { - return rpc.Errorf("failed to look up application: %v", err) - } - if registeredApp == nil { - return rpc.Errorf("application %s is not registered", appSession.ApplicationID) - } - err = h.actionGateway.AllowAction(tx, registeredApp.App.OwnerWallet, appStateUpd.Intent.GatedAction()) - if err != nil { - return rpc.NewError(err) - } - } - if len(reqPayload.QuorumSigs) > len(appSession.Participants) { return rpc.Errorf("quorum_sigs count (%d) exceeds participants count (%d)", len(reqPayload.QuorumSigs), len(appSession.Participants)) } 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 04e2fed90..510291710 100644 --- a/nitronode/api/app_session_v1/submit_app_state_test.go +++ b/nitronode/api/app_session_v1/submit_app_state_test.go @@ -31,14 +31,12 @@ func TestSubmitAppState_OperateIntent_NoRedistribution_Success(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -100,9 +98,6 @@ func TestSubmitAppState_OperateIntent_NoRedistribution_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) @@ -147,14 +142,12 @@ func TestSubmitAppState_OperateIntent_WithRedistribution_Success(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -218,9 +211,6 @@ func TestSubmitAppState_OperateIntent_WithRedistribution_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) @@ -270,14 +260,12 @@ func TestSubmitAppState_WithdrawIntent_Success(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, nodeAddress, - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -328,9 +316,6 @@ func TestSubmitAppState_WithdrawIntent_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) @@ -411,14 +396,12 @@ func TestSubmitAppState_WithdrawIntent_ReceiverHomeChannelChallenged_NoNodeSig(t handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, nodeAddress, - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -465,9 +448,6 @@ func TestSubmitAppState_WithdrawIntent_ReceiverHomeChannelChallenged_NoNodeSig(t QuorumSigs: []string{sig1}, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) @@ -537,14 +517,12 @@ func TestSubmitAppState_WithdrawIntent_ReceiverWithEscrowLock_Rejected(t *testin handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -595,9 +573,6 @@ func TestSubmitAppState_WithdrawIntent_ReceiverWithEscrowLock_Rejected(t *testin } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) @@ -658,14 +633,12 @@ func TestSubmitAppState_CloseIntent_Success(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, nodeAddress, - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -723,9 +696,6 @@ func TestSubmitAppState_CloseIntent_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) @@ -825,14 +795,12 @@ func TestSubmitAppState_CloseIntent_AllocationMismatch_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -883,9 +851,6 @@ func TestSubmitAppState_CloseIntent_AllocationMismatch_Rejected(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil).Maybe() @@ -925,14 +890,12 @@ func TestSubmitAppState_OperateIntent_MissingAllocation_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -992,9 +955,6 @@ func TestSubmitAppState_OperateIntent_MissingAllocation_Rejected(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) @@ -1039,14 +999,12 @@ func TestSubmitAppState_WithdrawIntent_MissingAllocation_Rejected(t *testing.T) handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1098,9 +1056,6 @@ func TestSubmitAppState_WithdrawIntent_MissingAllocation_Rejected(t *testing.T) } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil).Maybe() @@ -1151,14 +1106,12 @@ func TestSubmitAppState_DepositIntent_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1207,14 +1160,12 @@ func TestSubmitAppState_ClosedSession_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1237,9 +1188,6 @@ func TestSubmitAppState_ClosedSession_Rejected(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) // Create RPC context @@ -1278,14 +1226,12 @@ func TestSubmitAppState_InvalidVersion_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1313,9 +1259,6 @@ func TestSubmitAppState_InvalidVersion_Rejected(t *testing.T) { } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) // Create RPC context @@ -1354,14 +1297,12 @@ func TestSubmitAppState_SessionNotFound_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1415,14 +1356,12 @@ func TestSubmitAppState_OperateIntent_InvalidDecimalPrecision_Rejected(t *testin handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1479,9 +1418,6 @@ func TestSubmitAppState_OperateIntent_InvalidDecimalPrecision_Rejected(t *testin } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) @@ -1524,14 +1460,12 @@ func TestSubmitAppState_WithdrawIntent_InvalidDecimalPrecision_Rejected(t *testi handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1584,9 +1518,6 @@ func TestSubmitAppState_WithdrawIntent_InvalidDecimalPrecision_Rejected(t *testi } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) @@ -1629,14 +1560,12 @@ func TestSubmitAppState_OperateIntent_RedistributeToNewParticipant_Success(t *te handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1698,9 +1627,6 @@ func TestSubmitAppState_OperateIntent_RedistributeToNewParticipant_Success(t *te } // Mock expectations - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) @@ -1736,9 +1662,7 @@ func TestSubmitAppState_OperateIntent_RedistributeToNewParticipant_Success(t *te mockAssetStore.AssertExpectations(t) } -// TestSubmitAppState_AppRegistryDisabled verifies that when appRegistryEnabled=false, -// app lookup and AllowAction are skipped but the operate intent still succeeds. -func TestSubmitAppState_AppRegistryDisabled(t *testing.T) { +func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T) { mockStore := new(MockStore) storeTxProvider := func(fn StoreTxHandler) error { return fn(mockStore) @@ -1751,77 +1675,60 @@ func TestSubmitAppState_AppRegistryDisabled(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{Err: errors.New("should not be called")}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - false, // appRegistryEnabled=false metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" wallet1 := NewTestAppSessionWallet(t) participant1 := wallet1.Address - participant2 := "0x2222222222222222222222222222222222222222" existingSession := &app.AppSessionV1{ SessionID: appSessionID, ApplicationID: "test-app", Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 5}, - {WalletAddress: participant2, SignatureWeight: 5}, + {WalletAddress: participant1, SignatureWeight: 10}, }, - Quorum: 5, - Status: app.AppSessionStatusOpen, - Version: 1, - SessionData: `{"state":"initial"}`, + Quorum: 10, + Status: app.AppSessionStatusOpen, + Version: 1, } currentAllocations := map[string]map[string]decimal.Decimal{ participant1: {"USDC": decimal.NewFromInt(100)}, - participant2: {"USDC": decimal.NewFromInt(50)}, - } - - sessionBalances := map[string]decimal.Decimal{ - "USDC": decimal.NewFromInt(150), } + // Duplicate (participant1, USDC) entries in withdraw intent appStateUpdateCore := app.AppStateUpdateV1{ AppSessionID: appSessionID, - Intent: app.AppStateUpdateIntentOperate, + Intent: app.AppStateUpdateIntentWithdraw, Version: 2, Allocations: []app.AppAllocationV1{ - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - {Participant: participant2, Asset: "USDC", Amount: decimal.NewFromInt(50)}, + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(60)}, + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(40)}, // duplicate }, - SessionData: `{"state":"updated"}`, } sig1 := wallet1.SignAppStateUpdate(t, appStateUpdateCore) reqPayload := rpc.AppSessionsV1SubmitAppStateRequest{ AppStateUpdate: rpc.AppStateUpdateV1{ AppSessionID: appSessionID, - Intent: app.AppStateUpdateIntentOperate, + Intent: app.AppStateUpdateIntentWithdraw, Version: "2", Allocations: []rpc.AppAllocationV1{ - {Participant: participant1, Asset: "USDC", Amount: "100"}, - {Participant: participant2, Asset: "USDC", Amount: "50"}, + {Participant: participant1, Asset: "USDC", Amount: "60"}, + {Participant: participant1, Asset: "USDC", Amount: "40"}, // duplicate }, - SessionData: `{"state":"updated"}`, }, QuorumSigs: []string{sig1}, } - // NO GetApp mock — it should not be called mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) - mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) - mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) - mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { - return session.Version == 2 && session.SessionData == `{"state":"updated"}` - })).Return(nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -1834,17 +1741,14 @@ func TestSubmitAppState_AppRegistryDisabled(t *testing.T) { handler.SubmitAppState(ctx) require.NotNil(t, ctx.Response) - if respErr := ctx.Response.Error(); respErr != nil { - t.Fatalf("Unexpected error response: %v", respErr) - } - assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "expected error for duplicate allocation") + assert.Contains(t, respErr.Error(), "duplicate allocation") - // Strict: GetApp must NOT have been called - mockStore.AssertNotCalled(t, "GetApp", mock.Anything) - mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } -func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T) { +func TestSubmitAppState_CloseIntent_DuplicateAllocation_Rejected(t *testing.T) { mockStore := new(MockStore) storeTxProvider := func(fn StoreTxHandler) error { return fn(mockStore) @@ -1857,14 +1761,12 @@ func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" @@ -1886,14 +1788,14 @@ func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T participant1: {"USDC": decimal.NewFromInt(100)}, } - // Duplicate (participant1, USDC) entries in withdraw intent + // Duplicate (participant1, USDC) entries in close intent appStateUpdateCore := app.AppStateUpdateV1{ AppSessionID: appSessionID, - Intent: app.AppStateUpdateIntentWithdraw, + Intent: app.AppStateUpdateIntentClose, Version: 2, Allocations: []app.AppAllocationV1{ - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(60)}, - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(40)}, // duplicate + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, // duplicate }, } sig1 := wallet1.SignAppStateUpdate(t, appStateUpdateCore) @@ -1901,21 +1803,19 @@ func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T reqPayload := rpc.AppSessionsV1SubmitAppStateRequest{ AppStateUpdate: rpc.AppStateUpdateV1{ AppSessionID: appSessionID, - Intent: app.AppStateUpdateIntentWithdraw, + Intent: app.AppStateUpdateIntentClose, Version: "2", Allocations: []rpc.AppAllocationV1{ - {Participant: participant1, Asset: "USDC", Amount: "60"}, - {Participant: participant1, Asset: "USDC", Amount: "40"}, // duplicate + {Participant: participant1, Asset: "USDC", Amount: "100"}, + {Participant: participant1, Asset: "USDC", Amount: "100"}, // duplicate }, }, QuorumSigs: []string{sig1}, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) + mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil).Maybe() payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -1935,7 +1835,8 @@ func TestSubmitAppState_WithdrawIntent_DuplicateAllocation_Rejected(t *testing.T mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } -func TestSubmitAppState_CloseIntent_DuplicateAllocation_Rejected(t *testing.T) { +func TestSubmitAppState_OperateIntent_DuplicateAllocation_Rejected(t *testing.T) { + // Setup mockStore := new(MockStore) storeTxProvider := func(fn StoreTxHandler) error { return fn(mockStore) @@ -1948,43 +1849,47 @@ func TestSubmitAppState_CloseIntent_DuplicateAllocation_Rejected(t *testing.T) { handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" wallet1 := NewTestAppSessionWallet(t) participant1 := wallet1.Address + participant2 := "0x2222222222222222222222222222222222222222" existingSession := &app.AppSessionV1{ SessionID: appSessionID, ApplicationID: "test-app", Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 10}, + {WalletAddress: participant1, SignatureWeight: 5}, + {WalletAddress: participant2, SignatureWeight: 5}, }, - Quorum: 10, + Quorum: 5, Status: app.AppSessionStatusOpen, Version: 1, } currentAllocations := map[string]map[string]decimal.Decimal{ - participant1: {"USDC": decimal.NewFromInt(100)}, + participant1: {"USDC": decimal.NewFromInt(50)}, + participant2: {"USDC": decimal.NewFromInt(50)}, } - // Duplicate (participant1, USDC) entries in close intent + // Craft a malicious payload with duplicate (participant, asset) entries. + // The first entry inflates the sum to pass balance validation while + // the second overwrites the per-participant map to zero out balances. appStateUpdateCore := app.AppStateUpdateV1{ AppSessionID: appSessionID, - Intent: app.AppStateUpdateIntentClose, + Intent: app.AppStateUpdateIntentOperate, Version: 2, Allocations: []app.AppAllocationV1{ - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, // duplicate + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, // inflates sum + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(0)}, // duplicate overwrites to 0 + {Participant: participant2, Asset: "USDC", Amount: decimal.NewFromInt(0)}, }, } sig1 := wallet1.SignAppStateUpdate(t, appStateUpdateCore) @@ -1992,22 +1897,25 @@ func TestSubmitAppState_CloseIntent_DuplicateAllocation_Rejected(t *testing.T) { reqPayload := rpc.AppSessionsV1SubmitAppStateRequest{ AppStateUpdate: rpc.AppStateUpdateV1{ AppSessionID: appSessionID, - Intent: app.AppStateUpdateIntentClose, + Intent: app.AppStateUpdateIntentOperate, Version: "2", Allocations: []rpc.AppAllocationV1{ {Participant: participant1, Asset: "USDC", Amount: "100"}, - {Participant: participant1, Asset: "USDC", Amount: "100"}, // duplicate + {Participant: participant1, Asset: "USDC", Amount: "0"}, // duplicate + {Participant: participant2, Asset: "USDC", Amount: "0"}, }, }, QuorumSigs: []string{sig1}, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() + sessionBalances := map[string]decimal.Decimal{ + "USDC": decimal.NewFromInt(100), + } + mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) - mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil).Maybe() + mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) + mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -2024,11 +1932,15 @@ func TestSubmitAppState_CloseIntent_DuplicateAllocation_Rejected(t *testing.T) { require.NotNil(t, respErr, "expected error for duplicate allocation") assert.Contains(t, respErr.Error(), "duplicate allocation") + // RecordLedgerEntry must never be called — the request should be rejected before ledger writes mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } -func TestSubmitAppState_OperateIntent_DuplicateAllocation_Rejected(t *testing.T) { - // Setup +// TestSubmitAppState_VerifyQuorumWeightOver255 checks that verifyQuorum correctly accepts a +// signing coalition whose combined weight exceeds 255. With a uint8 accumulator the sum wraps +// modulo 256 (e.g. 200+200=400 becomes 144), causing a valid coalition to be spuriously +// rejected. The accumulator must be at least uint16. +func TestSubmitAppState_VerifyQuorumWeightOver255(t *testing.T) { mockStore := new(MockStore) storeTxProvider := func(fn StoreTxHandler) error { return fn(mockStore) @@ -2041,52 +1953,57 @@ func TestSubmitAppState_OperateIntent_DuplicateAllocation_Rejected(t *testing.T) handler := NewHandler( storeTxProvider, mockAssetStore, - &MockActionGateway{}, mockSigner, core.NewStateAdvancerV1(mockAssetStore), mockStatePacker, "0xNode", - true, metrics.NewNoopRuntimeMetricExporter(), - 32, 1024, 256, 16, 100, + 32, 1024, 256, 100, ) appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + + // Two participants each with weight 200; together 400 > 255, wraps to 144 in uint8. + // Quorum = 200: a valid coalition of both signers must be accepted. wallet1 := NewTestAppSessionWallet(t) + wallet2 := NewTestAppSessionWallet(t) participant1 := wallet1.Address - participant2 := "0x2222222222222222222222222222222222222222" + participant2 := wallet2.Address existingSession := &app.AppSessionV1{ SessionID: appSessionID, ApplicationID: "test-app", Participants: []app.AppParticipantV1{ - {WalletAddress: participant1, SignatureWeight: 5}, - {WalletAddress: participant2, SignatureWeight: 5}, + {WalletAddress: participant1, SignatureWeight: 200}, + {WalletAddress: participant2, SignatureWeight: 200}, }, - Quorum: 5, - Status: app.AppSessionStatusOpen, - Version: 1, + Quorum: 200, + Status: app.AppSessionStatusOpen, + Version: 1, + SessionData: "", } currentAllocations := map[string]map[string]decimal.Decimal{ - participant1: {"USDC": decimal.NewFromInt(50)}, + participant1: {"USDC": decimal.NewFromInt(100)}, participant2: {"USDC": decimal.NewFromInt(50)}, } + sessionBalances := map[string]decimal.Decimal{ + "USDC": decimal.NewFromInt(150), + } - // Craft a malicious payload with duplicate (participant, asset) entries. - // The first entry inflates the sum to pass balance validation while - // the second overwrites the per-participant map to zero out balances. - appStateUpdateCore := app.AppStateUpdateV1{ + appStateUpdate := app.AppStateUpdateV1{ AppSessionID: appSessionID, Intent: app.AppStateUpdateIntentOperate, Version: 2, Allocations: []app.AppAllocationV1{ - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, // inflates sum - {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(0)}, // duplicate overwrites to 0 - {Participant: participant2, Asset: "USDC", Amount: decimal.NewFromInt(0)}, + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, + {Participant: participant2, Asset: "USDC", Amount: decimal.NewFromInt(50)}, }, + SessionData: "", } - sig1 := wallet1.SignAppStateUpdate(t, appStateUpdateCore) + // Both participants sign — combined weight 400, wraps to 144 in uint8. + sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate) + sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate) reqPayload := rpc.AppSessionsV1SubmitAppStateRequest{ AppStateUpdate: rpc.AppStateUpdateV1{ @@ -2095,24 +2012,126 @@ func TestSubmitAppState_OperateIntent_DuplicateAllocation_Rejected(t *testing.T) Version: "2", Allocations: []rpc.AppAllocationV1{ {Participant: participant1, Asset: "USDC", Amount: "100"}, - {Participant: participant1, Asset: "USDC", Amount: "0"}, // duplicate - {Participant: participant2, Asset: "USDC", Amount: "0"}, + {Participant: participant2, Asset: "USDC", Amount: "50"}, }, + SessionData: "", }, - QuorumSigs: []string{sig1}, + QuorumSigs: []string{sig1, sig2}, } + mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) + mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) + mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) + mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) + mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool { + return s.Version == 2 + })).Return(nil) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, string(rpc.AppSessionsV1SubmitAppStateMethod), payload), + } + + handler.SubmitAppState(ctx) + + require.NotNil(t, ctx.Response) + if respErr := ctx.Response.Error(); respErr != nil { + t.Fatalf("combined weight 400 with quorum 200 must pass verifyQuorum, got: %v", respErr) + } + assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) + mockStore.AssertExpectations(t) +} + +// TestSubmitAppState_VerifyQuorumWrapToZero tests the 128+128=256 boundary case in +// verifyQuorum: uint8 wraps to 0, making any quorum > 0 appear unreachable. +func TestSubmitAppState_VerifyQuorumWrapToZero(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, + mockSigner, + core.NewStateAdvancerV1(mockAssetStore), + mockStatePacker, + "0xNode", + metrics.NewNoopRuntimeMetricExporter(), + 32, 1024, 256, 100, + ) + + appSessionID := "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef" + + // 128+128=256 wraps to 0 in uint8; quorum=128 would be considered unreachable in old code. + wallet1 := NewTestAppSessionWallet(t) + wallet2 := NewTestAppSessionWallet(t) + participant1 := wallet1.Address + participant2 := wallet2.Address + + existingSession := &app.AppSessionV1{ + SessionID: appSessionID, + ApplicationID: "test-app", + Participants: []app.AppParticipantV1{ + {WalletAddress: participant1, SignatureWeight: 128}, + {WalletAddress: participant2, SignatureWeight: 128}, + }, + Quorum: 128, + Status: app.AppSessionStatusOpen, + Version: 1, + SessionData: "", + } + + currentAllocations := map[string]map[string]decimal.Decimal{ + participant1: {"USDC": decimal.NewFromInt(100)}, + participant2: {"USDC": decimal.NewFromInt(50)}, + } sessionBalances := map[string]decimal.Decimal{ - "USDC": decimal.NewFromInt(100), + "USDC": decimal.NewFromInt(150), + } + + appStateUpdate := app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentOperate, + Version: 2, + Allocations: []app.AppAllocationV1{ + {Participant: participant1, Asset: "USDC", Amount: decimal.NewFromInt(100)}, + {Participant: participant2, Asset: "USDC", Amount: decimal.NewFromInt(50)}, + }, + SessionData: "", + } + // Both sign — combined weight 256, wraps to 0 in uint8. + sig1 := wallet1.SignAppStateUpdate(t, appStateUpdate) + sig2 := wallet2.SignAppStateUpdate(t, appStateUpdate) + + reqPayload := rpc.AppSessionsV1SubmitAppStateRequest{ + AppStateUpdate: rpc.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentOperate, + Version: "2", + Allocations: []rpc.AppAllocationV1{ + {Participant: participant1, Asset: "USDC", Amount: "100"}, + {Participant: participant2, Asset: "USDC", Amount: "50"}, + }, + SessionData: "", + }, + QuorumSigs: []string{sig1, sig2}, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingSession, nil) mockStore.On("GetParticipantAllocations", appSessionID).Return(currentAllocations, nil) mockStore.On("GetAppSessionBalances", appSessionID).Return(sessionBalances, nil) mockAssetStore.On("GetAssetDecimals", "USDC").Return(uint8(6), nil) + mockStore.On("UpdateAppSession", mock.MatchedBy(func(s app.AppSessionV1) bool { + return s.Version == 2 + })).Return(nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -2125,10 +2144,9 @@ func TestSubmitAppState_OperateIntent_DuplicateAllocation_Rejected(t *testing.T) handler.SubmitAppState(ctx) require.NotNil(t, ctx.Response) - respErr := ctx.Response.Error() - require.NotNil(t, respErr, "expected error for duplicate allocation") - assert.Contains(t, respErr.Error(), "duplicate allocation") - - // RecordLedgerEntry must never be called — the request should be rejected before ledger writes - mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + if respErr := ctx.Response.Error(); respErr != nil { + t.Fatalf("combined weight 256 (128+128) with quorum 128 must pass verifyQuorum, got: %v", respErr) + } + assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) + mockStore.AssertExpectations(t) } diff --git a/nitronode/api/app_session_v1/submit_deposit_state.go b/nitronode/api/app_session_v1/submit_deposit_state.go index 15436e9c7..ecc516d9b 100644 --- a/nitronode/api/app_session_v1/submit_deposit_state.go +++ b/nitronode/api/app_session_v1/submit_deposit_state.go @@ -78,21 +78,6 @@ func (h *Handler) SubmitDepositState(c *rpc.Context) { return rpc.Errorf("no signatures provided") } - if h.appRegistryEnabled { - registeredApp, err := tx.GetApp(appSession.ApplicationID) - if err != nil { - return rpc.Errorf("failed to look up application: %v", err) - } - if registeredApp == nil { - return rpc.Errorf("application %s is not registered", appSession.ApplicationID) - } - - err = h.actionGateway.AllowAction(tx, registeredApp.App.OwnerWallet, appStateUpd.Intent.GatedAction()) - if err != nil { - return rpc.NewError(err) - } - } - // Lock the user's state to prevent concurrent modifications _, err = tx.LockUserState(userState.UserWallet, userState.Asset) if err != nil { @@ -122,16 +107,19 @@ func (h *Handler) SubmitDepositState(c *rpc.Context) { return rpc.Errorf("missing user signature on user state") } + // An active home channel (confirmed by CheckActiveChannel above) always has a + // persisted latest state; deposit is not an initialization path. A nil result here + // means the local state materialization is inconsistent, so reject instead of + // synthesizing a void state and validating against history that was never persisted. currentState, err := tx.GetLastUserState(userState.UserWallet, userState.Asset, false) if err != nil { return rpc.Errorf("failed to get last user state: %v", err) } if currentState == nil { - currentState = core.NewVoidState(userState.Asset, userState.UserWallet) - } else { - if err := tx.EnsureNoOngoingStateTransitions(userState.UserWallet, userState.Asset); err != nil { - return rpc.Errorf("ongoing state transitions check failed: %v", err) - } + return rpc.Errorf("last user state not found") + } + if err := tx.EnsureNoOngoingStateTransitions(userState.UserWallet, userState.Asset); err != nil { + return rpc.Errorf("ongoing state transitions check failed: %v", err) } if err := h.stateAdvancer.ValidateAdvancement(*currentState, userState); err != nil { @@ -185,6 +173,13 @@ func (h *Handler) SubmitDepositState(c *rpc.Context) { incomingAllocations := make(map[string]map[string]decimal.Decimal) for _, alloc := range appStateUpd.Allocations { + // Validate participant before any amount-diff logic so non-participant + // allocations are rejected even when the amount is zero (which would + // otherwise bypass the check below). + if _, ok := participantWeights[alloc.Participant]; !ok { + return rpc.Errorf("allocation to non-participant %s", alloc.Participant) + } + if alloc.Amount.IsNegative() { return rpc.Errorf("negative allocation: %s for asset %s", alloc.Amount, alloc.Asset) } @@ -211,16 +206,18 @@ func (h *Handler) SubmitDepositState(c *rpc.Context) { } currentAmount := participantAllocs[alloc.Asset] + // Reject spurious zero allocations for (participant, asset) pairs with + // no existing balance: they move no funds but pollute the signed + // allocation set, keeping it from being a canonical snapshot. + if alloc.Amount.IsZero() && currentAmount.IsZero() { + return rpc.Errorf("zero allocation for participant %s, asset %s with no existing balance", alloc.Participant, alloc.Asset) + } + if alloc.Amount.LessThan(currentAmount) { return rpc.Errorf("decreased allocation for %s for participant %s", alloc.Asset, alloc.Participant) } if alloc.Amount.GreaterThan(currentAmount) { - // Validate participant - if _, ok := participantWeights[alloc.Participant]; !ok { - return rpc.Errorf("allocation to non-participant %s", alloc.Participant) - } - // Validate that allocation asset matches user state asset if alloc.Asset != userState.Asset { return rpc.Errorf("app session deposit allocation for asset '%s' does not match user channel state asset '%s'", alloc.Asset, userState.Asset) @@ -249,19 +246,27 @@ func (h *Handler) SubmitDepositState(c *rpc.Context) { if currentAmount.IsZero() { continue } - if asset == userState.Asset { - // Skip asset being deposited to avoid double-checking - continue - } - // Check if this participant+asset is included in the incoming request + // Every existing nonzero allocation must be present in the signed + // update so the resulting allocation set is a complete snapshot. incomingAmount, found := incomingAllocations[participant][asset] if !found { return rpc.Errorf("deposit intent missing allocation for participant %s, asset %s with current amount %s", participant, asset, currentAmount.String()) } - // Verify amounts match exactly + if asset == userState.Asset { + // Deposited asset: allocations may only grow (the deposit + // itself, already validated and recorded in the loop above), + // never shrink. + if incomingAmount.LessThan(currentAmount) { + return rpc.Errorf("deposit intent cannot decrease allocation for participant %s, asset %s: current %s, provided %s", + participant, asset, currentAmount.String(), incomingAmount.String()) + } + continue + } + + // Non-deposited assets must match current state exactly. if !incomingAmount.Equal(currentAmount) { return rpc.Errorf("deposit intent requires non-deposited asset allocations to match current state: participant %s, asset %s, current %s, provided %s", participant, asset, currentAmount.String(), incomingAmount.String()) 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 76615416b..c1c34a8ad 100644 --- a/nitronode/api/app_session_v1/submit_deposit_state_test.go +++ b/nitronode/api/app_session_v1/submit_deposit_state_test.go @@ -2,7 +2,6 @@ package app_session_v1 import ( "context" - "errors" "strings" "testing" "time" @@ -71,20 +70,17 @@ func TestSubmitDepositState_Success(t *testing.T) { handler := &Handler{ assetStore: mockAssetStore, - actionGateway: &MockActionGateway{}, stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), statePacker: mockStatePacker, useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, - signer: mockSigner, - nodeAddress: nodeAddress, - appRegistryEnabled: true, - metrics: metrics.NewNoopRuntimeMetricExporter(), - maxParticipants: 32, - maxSessionData: 1024, - maxSessionKeyIDs: 256, - maxSignedUpdates: 16, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } // Test data - create one key for both app session and channel state signing @@ -197,9 +193,6 @@ func TestSubmitDepositState_Success(t *testing.T) { mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() // Mock allocations check - empty initially @@ -275,6 +268,109 @@ func TestSubmitDepositState_Success(t *testing.T) { mockStore.AssertExpectations(t) } +func TestSubmitDepositState_MissingLastUserState_Rejected(t *testing.T) { + // An active home channel always has a persisted latest state; if GetLastUserState + // returns nil despite CheckActiveChannel succeeding, the handler must reject with a + // clean "last user state not found" rather than synthesizing a void state. + mockStore := new(MockStore) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + } + + userRawSigner := NewMockSigner() + participant1 := strings.ToLower(userRawSigner.PublicKey().Address().String()) + participant2 := "0x2222222222222222222222222222222222222222" + asset := "USDC" + homeChannelID := "0xHomeChannel123" + depositAmount := decimal.NewFromInt(100) + appSessionID := "0xAppSession123" + + existingAppSession := &app.AppSessionV1{ + SessionID: appSessionID, + ApplicationID: "test-app", + Participants: []app.AppParticipantV1{ + {WalletAddress: participant1, SignatureWeight: 1}, + {WalletAddress: participant2, SignatureWeight: 1}, + }, + Quorum: 1, + Nonce: 12345, + Status: app.AppSessionStatusOpen, + Version: 1, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + // Build an incoming commit state referencing the app session. + baseState := core.State{ + ID: core.GetStateID(participant1, asset, 1, 1), + Transition: core.Transition{Type: core.TransitionTypeVoid}, + Asset: asset, + UserWallet: participant1, + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + TokenAddress: "0xTokenAddress", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), + UserNetFlow: decimal.NewFromInt(500), + }, + } + incomingUserState := baseState.NextState() + _, err := incomingUserState.ApplyCommitTransition(appSessionID, depositAmount) + require.NoError(t, err) + // Signature is never verified: the handler rejects before signature validation. + dummySig := "0x01" + incomingUserState.UserSig = &dummySig + + mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() + mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() + mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() + mockStore.On("GetLastUserState", participant1, asset, false).Return(nil, nil).Once() + + appStateUpdate := rpc.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: "2", + Allocations: []rpc.AppAllocationV1{ + {Participant: participant1, Asset: asset, Amount: depositAmount.String()}, + }, + } + + reqPayload := rpc.AppSessionsV1SubmitDepositStateRequest{ + AppStateUpdate: appStateUpdate, + QuorumSigs: []string{"0xdeadbeef"}, + UserState: toRPCState(*incomingUserState), + } + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, string(rpc.AppSessionsV1SubmitDepositStateMethod), payload), + } + + handler.SubmitDepositState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "last user state not found") + + // Must reject without advancing past the missing-state check. + mockStore.AssertNotCalled(t, "EnsureNoOngoingStateTransitions", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) + mockStore.AssertExpectations(t) +} + func TestSubmitDepositState_InvalidTransitionType(t *testing.T) { // Setup mockStore := new(MockStore) @@ -285,20 +381,17 @@ func TestSubmitDepositState_InvalidTransitionType(t *testing.T) { handler := &Handler{ assetStore: mockAssetStore, - actionGateway: &MockActionGateway{}, stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), statePacker: mockStatePacker, useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, - signer: mockSigner, - nodeAddress: nodeAddress, - appRegistryEnabled: true, - metrics: metrics.NewNoopRuntimeMetricExporter(), - maxParticipants: 32, - maxSessionData: 1024, - maxSessionKeyIDs: 256, - maxSignedUpdates: 16, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } // Test data @@ -383,9 +476,6 @@ func TestSubmitDepositState_InvalidTransitionType(t *testing.T) { Status: app.AppSessionStatusOpen, Version: 1, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Maybe() @@ -430,20 +520,17 @@ func TestSubmitDepositState_QuorumNotMet(t *testing.T) { handler := &Handler{ assetStore: mockAssetStore, - actionGateway: &MockActionGateway{}, stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), statePacker: mockStatePacker, useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, - signer: mockSigner, - nodeAddress: nodeAddress, - appRegistryEnabled: true, - metrics: metrics.NewNoopRuntimeMetricExporter(), - maxParticipants: 32, - maxSessionData: 1024, - maxSessionKeyIDs: 256, - maxSignedUpdates: 16, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } // Test data - create one key for both app session and channel state signing @@ -547,9 +634,6 @@ func TestSubmitDepositState_QuorumNotMet(t *testing.T) { mockStore.On("EnsureNoOngoingStateTransitions", participant1, asset).Return(nil).Once() mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() // Create RPC request @@ -583,9 +667,7 @@ func TestSubmitDepositState_QuorumNotMet(t *testing.T) { mockStore.AssertExpectations(t) } -// TestSubmitDepositState_AppRegistryDisabled verifies that when appRegistryEnabled=false, -// app lookup and AllowAction are skipped but deposit still succeeds. -func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { +func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { mockStore := new(MockStore) mockSigner := NewMockChannelSigner() nodeAddress := mockSigner.PublicKey().Address().String() @@ -594,20 +676,17 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { handler := &Handler{ assetStore: mockAssetStore, - actionGateway: &MockActionGateway{Err: errors.New("should not be called")}, stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), statePacker: mockStatePacker, useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, - signer: mockSigner, - nodeAddress: nodeAddress, - appRegistryEnabled: false, // disabled - metrics: metrics.NewNoopRuntimeMetricExporter(), - maxParticipants: 32, - maxSessionData: 1024, - maxSessionKeyIDs: 256, - maxSignedUpdates: 16, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } userRawSigner := NewMockSigner() @@ -617,7 +696,6 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { participant2 := "0x2222222222222222222222222222222222222222" asset := "USDC" homeChannelID := "0xHomeChannel123" - depositAmount := decimal.NewFromInt(100) appSessionID := "0xAppSession123" existingAppSession := &app.AppSessionV1{ @@ -627,34 +705,24 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { {WalletAddress: participant1, SignatureWeight: 1}, {WalletAddress: participant2, SignatureWeight: 1}, }, - Quorum: 1, - Nonce: 12345, - Status: app.AppSessionStatusOpen, - Version: 1, - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + Quorum: 1, + Nonce: 12345, + Status: app.AppSessionStatusOpen, + Version: 1, } currentUserState := core.State{ - ID: core.GetStateID(participant1, asset, 1, 1), - Transition: core.Transition{ - Type: core.TransitionTypeVoid, - }, - Asset: asset, - UserWallet: participant1, - Epoch: 1, - Version: 1, + ID: core.GetStateID(participant1, asset, 1, 1), + Transition: core.Transition{Type: core.TransitionTypeVoid}, + Asset: asset, UserWallet: participant1, 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), + TokenAddress: "0xTokenAddress", BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), UserNetFlow: decimal.NewFromInt(500), }, } + depositAmount := decimal.NewFromInt(100) incomingUserState := currentUserState.NextState() _, err := incomingUserState.ApplyCommitTransition(appSessionID, depositAmount) require.NoError(t, err) @@ -665,14 +733,15 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { userSigStr := userSig.String() incomingUserState.UserSig = &userSigStr + // Duplicate (participant1, USDC) allocations — first inflates sum, second overwrites appStateUpdateCore := app.AppStateUpdateV1{ AppSessionID: appSessionID, Intent: app.AppStateUpdateIntentDeposit, Version: 2, Allocations: []app.AppAllocationV1{ {Participant: participant1, Asset: asset, Amount: depositAmount}, + {Participant: participant1, Asset: asset, Amount: decimal.Zero}, // duplicate }, - SessionData: `{"updated": "data"}`, } packedAppUpdate, _ := app.PackAppStateUpdateV1(appStateUpdateCore) appSigBytes, _ := appWalletSigner.Sign(packedAppUpdate) @@ -684,31 +753,24 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { Version: "2", Allocations: []rpc.AppAllocationV1{ {Participant: participant1, Asset: asset, Amount: depositAmount.String()}, + {Participant: participant1, Asset: asset, Amount: "0"}, // duplicate }, - SessionData: `{"updated": "data"}`, } - // NO GetApp mock — it should not be called + mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, 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) mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() - mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("GetParticipantAllocations", appSessionID).Return( map[string]map[string]decimal.Decimal{}, nil, ).Once() - mockStore.On("RecordLedgerEntry", participant1, appSessionID, asset, depositAmount).Return(nil).Once() - mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { - return session.SessionID == appSessionID && session.Version == 2 - })).Return(nil).Once() - mockStore.On("StoreUserState", mock.MatchedBy(func(state core.State) bool { - return state.UserWallet == participant1 && state.NodeSig != nil - }), mock.Anything).Return(nil).Once() - mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool { - return tx.TxType == core.TransactionTypeCommit && tx.Amount.Equal(depositAmount) - }), mock.Anything).Return(nil).Once() + + // The first (non-duplicate) allocation may be processed before the duplicate is detected, + // so RecordLedgerEntry might be called for the first entry. Allow it. + mockStore.On("RecordLedgerEntry", participant1, appSessionID, asset, depositAmount).Return(nil).Maybe() rpcState := toRPCState(*incomingUserState) reqPayload := rpc.AppSessionsV1SubmitDepositStateRequest{ @@ -727,18 +789,141 @@ func TestSubmitDepositState_AppRegistryDisabled(t *testing.T) { handler.SubmitDepositState(ctx) - assert.NotNil(t, ctx.Response) - if respErr := ctx.Response.Error(); respErr != nil { - t.Fatalf("Unexpected error response: %v", respErr) + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "expected error for duplicate allocation") + assert.Contains(t, respErr.Error(), "duplicate allocation") +} + +func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { + mockStore := new(MockStore) + mockSigner := NewMockChannelSigner() + nodeAddress := mockSigner.PublicKey().Address().String() + mockAssetStore := new(MockAssetStore) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + assetStore: mockAssetStore, + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } - assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) - // Strict: GetApp must NOT have been called - mockStore.AssertNotCalled(t, "GetApp", mock.Anything) - mockStore.AssertExpectations(t) + userRawSigner := NewMockSigner() + channelWalletSigner, _ := core.NewChannelDefaultSigner(userRawSigner) + appWalletSigner, _ := app.NewAppSessionWalletSignerV1(userRawSigner) + participant1 := strings.ToLower(userRawSigner.PublicKey().Address().String()) + participant2 := "0x2222222222222222222222222222222222222222" + asset := "USDC" + homeChannelID := "0xHomeChannel123" + appSessionID := "0xAppSession123" + + existingAppSession := &app.AppSessionV1{ + SessionID: appSessionID, + ApplicationID: "test-app", + Participants: []app.AppParticipantV1{ + {WalletAddress: participant1, SignatureWeight: 1}, + {WalletAddress: participant2, SignatureWeight: 1}, + }, + Quorum: 1, + Nonce: 12345, + Status: app.AppSessionStatusOpen, + Version: 1, + } + + // Amount with 7 decimal places for USDC (which has 6) + invalidAmount, _ := decimal.NewFromString("100.1234567") + depositAmount := invalidAmount + + currentUserState := core.State{ + ID: core.GetStateID(participant1, asset, 1, 1), + Transition: core.Transition{Type: core.TransitionTypeVoid}, + Asset: asset, UserWallet: participant1, Epoch: 1, Version: 1, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + TokenAddress: "0xTokenAddress", BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), UserNetFlow: decimal.NewFromInt(500), + }, + } + + incomingUserState := currentUserState.NextState() + _, err := incomingUserState.ApplyCommitTransition(appSessionID, depositAmount) + require.NoError(t, err) + + mockStatePacker.On("PackState", mock.Anything).Return([]byte("packed"), nil) + packedUserState, _ := mockStatePacker.PackState(*incomingUserState) + userSig, _ := channelWalletSigner.Sign(packedUserState) + userSigStr := userSig.String() + incomingUserState.UserSig = &userSigStr + + appStateUpdateCore := app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 2, + Allocations: []app.AppAllocationV1{ + {Participant: participant1, Asset: asset, Amount: invalidAmount}, + }, + } + packedAppUpdate, _ := app.PackAppStateUpdateV1(appStateUpdateCore) + appSigBytes, _ := appWalletSigner.Sign(packedAppUpdate) + appSigHex := hexutil.Encode(appSigBytes) + + appStateUpdate := rpc.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: "2", + Allocations: []rpc.AppAllocationV1{ + {Participant: participant1, Asset: asset, Amount: "100.1234567"}, + }, + } + + mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() + mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, 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) + mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() + mockStore.On("GetParticipantAllocations", appSessionID).Return( + map[string]map[string]decimal.Decimal{}, nil, + ).Once() + + rpcState := toRPCState(*incomingUserState) + reqPayload := rpc.AppSessionsV1SubmitDepositStateRequest{ + AppStateUpdate: appStateUpdate, + QuorumSigs: []string{appSigHex}, + UserState: rpcState, + } + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, string(rpc.AppSessionsV1SubmitDepositStateMethod), payload), + } + + handler.SubmitDepositState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "Expected error for invalid decimal precision") + assert.Contains(t, respErr.Error(), "amount exceeds maximum decimal precision") } -func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { +// TestSubmitDepositState_NonParticipantZeroAllocation_Rejected verifies that a +// zero-amount allocation to a non-participant is rejected. Previously the +// participant check sat inside the amount-increase branch, so a zero amount +// bypassed it and let a non-participant ride along in the signed allocation set. +func TestSubmitDepositState_NonParticipantZeroAllocation_Rejected(t *testing.T) { mockStore := new(MockStore) mockSigner := NewMockChannelSigner() nodeAddress := mockSigner.PublicKey().Address().String() @@ -747,20 +932,17 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { handler := &Handler{ assetStore: mockAssetStore, - actionGateway: &MockActionGateway{}, stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), statePacker: mockStatePacker, useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, - signer: mockSigner, - nodeAddress: nodeAddress, - appRegistryEnabled: true, - metrics: metrics.NewNoopRuntimeMetricExporter(), - maxParticipants: 32, - maxSessionData: 1024, - maxSessionKeyIDs: 256, - maxSignedUpdates: 16, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } userRawSigner := NewMockSigner() @@ -768,6 +950,7 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { appWalletSigner, _ := app.NewAppSessionWalletSignerV1(userRawSigner) participant1 := strings.ToLower(userRawSigner.PublicKey().Address().String()) participant2 := "0x2222222222222222222222222222222222222222" + nonParticipant := "0x3333333333333333333333333333333333333333" asset := "USDC" homeChannelID := "0xHomeChannel123" appSessionID := "0xAppSession123" @@ -807,14 +990,15 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { userSigStr := userSig.String() incomingUserState.UserSig = &userSigStr - // Duplicate (participant1, USDC) allocations — first inflates sum, second overwrites + // Non-participant zero allocation listed first so it is rejected before any + // ledger entry is recorded for the legitimate deposit. appStateUpdateCore := app.AppStateUpdateV1{ AppSessionID: appSessionID, Intent: app.AppStateUpdateIntentDeposit, Version: 2, Allocations: []app.AppAllocationV1{ + {Participant: nonParticipant, Asset: asset, Amount: decimal.Zero}, {Participant: participant1, Asset: asset, Amount: depositAmount}, - {Participant: participant1, Asset: asset, Amount: decimal.Zero}, // duplicate }, } packedAppUpdate, _ := app.PackAppStateUpdateV1(appStateUpdateCore) @@ -826,14 +1010,11 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { Intent: app.AppStateUpdateIntentDeposit, Version: "2", Allocations: []rpc.AppAllocationV1{ + {Participant: nonParticipant, Asset: asset, Amount: "0"}, {Participant: participant1, Asset: asset, Amount: depositAmount.String()}, - {Participant: participant1, Asset: asset, Amount: "0"}, // duplicate }, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() @@ -844,9 +1025,139 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { mockStore.On("GetParticipantAllocations", appSessionID).Return( map[string]map[string]decimal.Decimal{}, nil, ).Once() + // No ledger entry should be recorded — rejection happens before it. + mockStore.On("RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() - // The first (non-duplicate) allocation may be processed before the duplicate is detected, - // so RecordLedgerEntry might be called for the first entry. Allow it. + rpcState := toRPCState(*incomingUserState) + reqPayload := rpc.AppSessionsV1SubmitDepositStateRequest{ + AppStateUpdate: appStateUpdate, + QuorumSigs: []string{appSigHex}, + UserState: rpcState, + } + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, string(rpc.AppSessionsV1SubmitDepositStateMethod), payload), + } + + handler.SubmitDepositState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr, "expected error for non-participant allocation") + assert.Contains(t, respErr.Error(), "non-participant") + mockStore.AssertNotCalled(t, "RecordLedgerEntry", nonParticipant, mock.Anything, mock.Anything, mock.Anything) +} + +// TestSubmitDepositState_MissingDepositedAssetAllocation_Rejected verifies that an +// existing nonzero allocation in the deposited asset cannot be omitted from the +// signed update. Previously the completeness check skipped the deposited asset, +// so such an allocation could silently vanish from the canonical snapshot. +func TestSubmitDepositState_MissingDepositedAssetAllocation_Rejected(t *testing.T) { + mockStore := new(MockStore) + mockSigner := NewMockChannelSigner() + nodeAddress := mockSigner.PublicKey().Address().String() + mockAssetStore := new(MockAssetStore) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + assetStore: mockAssetStore, + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, + } + + userRawSigner := NewMockSigner() + channelWalletSigner, _ := core.NewChannelDefaultSigner(userRawSigner) + appWalletSigner, _ := app.NewAppSessionWalletSignerV1(userRawSigner) + participant1 := strings.ToLower(userRawSigner.PublicKey().Address().String()) + participant2 := "0x2222222222222222222222222222222222222222" + asset := "USDC" + homeChannelID := "0xHomeChannel123" + appSessionID := "0xAppSession123" + + existingAppSession := &app.AppSessionV1{ + SessionID: appSessionID, + ApplicationID: "test-app", + Participants: []app.AppParticipantV1{ + {WalletAddress: participant1, SignatureWeight: 1}, + {WalletAddress: participant2, SignatureWeight: 1}, + }, + Quorum: 1, + Nonce: 12345, + Status: app.AppSessionStatusOpen, + Version: 1, + } + + currentUserState := core.State{ + ID: core.GetStateID(participant1, asset, 1, 1), + Transition: core.Transition{Type: core.TransitionTypeVoid}, + Asset: asset, UserWallet: participant1, Epoch: 1, Version: 1, + HomeChannelID: &homeChannelID, + HomeLedger: core.Ledger{ + TokenAddress: "0xTokenAddress", BlockchainID: 1, + UserBalance: decimal.NewFromInt(500), UserNetFlow: decimal.NewFromInt(500), + }, + } + + depositAmount := decimal.NewFromInt(100) + incomingUserState := currentUserState.NextState() + _, err := incomingUserState.ApplyCommitTransition(appSessionID, depositAmount) + require.NoError(t, err) + + mockStatePacker.On("PackState", mock.Anything).Return([]byte("packed"), nil) + packedUserState, _ := mockStatePacker.PackState(*incomingUserState) + userSig, _ := channelWalletSigner.Sign(packedUserState) + userSigStr := userSig.String() + incomingUserState.UserSig = &userSigStr + + // Incoming update carries only participant1's deposit and omits participant2's + // existing nonzero balance in the same (deposited) asset. + appStateUpdateCore := app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 2, + Allocations: []app.AppAllocationV1{ + {Participant: participant1, Asset: asset, Amount: depositAmount}, + }, + } + packedAppUpdate, _ := app.PackAppStateUpdateV1(appStateUpdateCore) + appSigBytes, _ := appWalletSigner.Sign(packedAppUpdate) + appSigHex := hexutil.Encode(appSigBytes) + + appStateUpdate := rpc.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: "2", + Allocations: []rpc.AppAllocationV1{ + {Participant: participant1, Asset: asset, Amount: depositAmount.String()}, + }, + } + + mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() + mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, 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) + mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() + // participant2 already holds 50 USDC (the deposited asset) in the session. + mockStore.On("GetParticipantAllocations", appSessionID).Return( + map[string]map[string]decimal.Decimal{ + participant2: {asset: decimal.NewFromInt(50)}, + }, nil, + ).Once() mockStore.On("RecordLedgerEntry", participant1, appSessionID, asset, depositAmount).Return(nil).Maybe() rpcState := toRPCState(*incomingUserState) @@ -868,11 +1179,16 @@ func TestSubmitDepositState_DuplicateAllocation_Rejected(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() - require.NotNil(t, respErr, "expected error for duplicate allocation") - assert.Contains(t, respErr.Error(), "duplicate allocation") + require.NotNil(t, respErr, "expected error for omitted deposited-asset allocation") + assert.Contains(t, respErr.Error(), "missing allocation") + mockStore.AssertNotCalled(t, "UpdateAppSession", mock.Anything) } -func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { +// TestSubmitDepositState_SpuriousZeroAllocation_Rejected verifies that a valid +// participant cannot include a zero allocation for a (participant, asset) pair +// with no existing balance. Such an allocation moves no funds but would pollute +// the signed allocation set, keeping it from being a canonical snapshot. +func TestSubmitDepositState_SpuriousZeroAllocation_Rejected(t *testing.T) { mockStore := new(MockStore) mockSigner := NewMockChannelSigner() nodeAddress := mockSigner.PublicKey().Address().String() @@ -881,20 +1197,17 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { handler := &Handler{ assetStore: mockAssetStore, - actionGateway: &MockActionGateway{}, stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), statePacker: mockStatePacker, useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, - signer: mockSigner, - nodeAddress: nodeAddress, - appRegistryEnabled: true, - metrics: metrics.NewNoopRuntimeMetricExporter(), - maxParticipants: 32, - maxSessionData: 1024, - maxSessionKeyIDs: 256, - maxSignedUpdates: 16, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, } userRawSigner := NewMockSigner() @@ -919,10 +1232,6 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { Version: 1, } - // Amount with 7 decimal places for USDC (which has 6) - invalidAmount, _ := decimal.NewFromString("100.1234567") - depositAmount := invalidAmount - currentUserState := core.State{ ID: core.GetStateID(participant1, asset, 1, 1), Transition: core.Transition{Type: core.TransitionTypeVoid}, @@ -934,6 +1243,7 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { }, } + depositAmount := decimal.NewFromInt(100) incomingUserState := currentUserState.NextState() _, err := incomingUserState.ApplyCommitTransition(appSessionID, depositAmount) require.NoError(t, err) @@ -944,12 +1254,16 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { userSigStr := userSig.String() incomingUserState.UserSig = &userSigStr + // participant2 is a valid participant but has no existing balance for the + // asset; its zero allocation is listed first so rejection happens before any + // ledger entry is recorded for the legitimate deposit. appStateUpdateCore := app.AppStateUpdateV1{ AppSessionID: appSessionID, Intent: app.AppStateUpdateIntentDeposit, Version: 2, Allocations: []app.AppAllocationV1{ - {Participant: participant1, Asset: asset, Amount: invalidAmount}, + {Participant: participant2, Asset: asset, Amount: decimal.Zero}, + {Participant: participant1, Asset: asset, Amount: depositAmount}, }, } packedAppUpdate, _ := app.PackAppStateUpdateV1(appStateUpdateCore) @@ -961,13 +1275,11 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { Intent: app.AppStateUpdateIntentDeposit, Version: "2", Allocations: []rpc.AppAllocationV1{ - {Participant: participant1, Asset: asset, Amount: "100.1234567"}, + {Participant: participant2, Asset: asset, Amount: "0"}, + {Participant: participant1, Asset: asset, Amount: depositAmount.String()}, }, } - mockStore.On("GetApp", "test-app").Return(&app.AppInfoV1{ - App: app.AppV1{ID: "test-app", OwnerWallet: "0x0000000000000000000000000000000000000001"}, - }, nil).Maybe() mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, nil).Once() mockStore.On("CheckActiveChannel", participant1, asset).Return("0x03", core.ChannelStatusOpen, nil).Once() @@ -978,6 +1290,8 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { mockStore.On("GetParticipantAllocations", appSessionID).Return( map[string]map[string]decimal.Decimal{}, nil, ).Once() + // No ledger entry should be recorded — rejection happens before it. + mockStore.On("RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil).Maybe() rpcState := toRPCState(*incomingUserState) reqPayload := rpc.AppSessionsV1SubmitDepositStateRequest{ @@ -998,6 +1312,156 @@ func TestSubmitDepositState_InvalidDecimalPrecision_Rejected(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() - require.NotNil(t, respErr, "Expected error for invalid decimal precision") - assert.Contains(t, respErr.Error(), "amount exceeds maximum decimal precision") + require.NotNil(t, respErr, "expected error for spurious zero allocation") + assert.Contains(t, respErr.Error(), "zero allocation") + mockStore.AssertNotCalled(t, "RecordLedgerEntry", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// TestSubmitDepositState_VerifyQuorumWeightOver255 ensures verifyQuorum handles combined +// participant weights exceeding 255 correctly through the SubmitDepositState path. +func TestSubmitDepositState_VerifyQuorumWeightOver255(t *testing.T) { + mockStore := new(MockStore) + mockSigner := NewMockChannelSigner() + nodeAddress := mockSigner.PublicKey().Address().String() + mockAssetStore := new(MockAssetStore) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + assetStore: mockAssetStore, + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + signer: mockSigner, + nodeAddress: nodeAddress, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxParticipants: 32, + maxSessionData: 1024, + maxSessionKeyIDs: 256, + } + + // Participant1 signs both channel state and app state update (weight 200). + // Participant2 also signs the app state update (weight 200). + // Combined weight 400 > 255; quorum 200. Old uint8 code would wrap to 144, reject. + userRawSigner1 := NewMockSigner() + userRawSigner2 := NewMockSigner() + channelWalletSigner1, _ := core.NewChannelDefaultSigner(userRawSigner1) + appWalletSigner1, _ := app.NewAppSessionWalletSignerV1(userRawSigner1) + appWalletSigner2, _ := app.NewAppSessionWalletSignerV1(userRawSigner2) + participant1 := strings.ToLower(userRawSigner1.PublicKey().Address().String()) + participant2 := strings.ToLower(userRawSigner2.PublicKey().Address().String()) + asset := "USDC" + homeChannelID := "0xHomeChannel123" + depositAmount := decimal.NewFromInt(100) + appSessionID := "0xAppSession456" + + existingAppSession := &app.AppSessionV1{ + SessionID: appSessionID, + ApplicationID: "test-app", + Participants: []app.AppParticipantV1{ + {WalletAddress: participant1, SignatureWeight: 200}, + {WalletAddress: participant2, SignatureWeight: 200}, + }, + Quorum: 200, + Nonce: 12345, + Status: app.AppSessionStatusOpen, + Version: 1, + } + + currentUserState := core.State{ + ID: core.GetStateID(participant1, asset, 1, 1), + Transition: core.Transition{ + Type: core.TransitionTypeVoid, + }, + Asset: asset, + UserWallet: participant1, + 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), + }, + } + + incomingUserState := currentUserState.NextState() + _, err := incomingUserState.ApplyCommitTransition(appSessionID, depositAmount) + require.NoError(t, err) + + mockStatePacker.On("PackState", mock.Anything).Return([]byte("packed"), nil) + packedUserState, _ := mockStatePacker.PackState(*incomingUserState) + userSig, _ := channelWalletSigner1.Sign(packedUserState) + userSigStr := userSig.String() + incomingUserState.UserSig = &userSigStr + + appStateUpdateCore := app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 2, + Allocations: []app.AppAllocationV1{ + {Participant: participant1, Asset: asset, Amount: depositAmount}, + }, + } + packedAppUpdate, _ := app.PackAppStateUpdateV1(appStateUpdateCore) + appSigBytes1, _ := appWalletSigner1.Sign(packedAppUpdate) + appSigHex1 := hexutil.Encode(appSigBytes1) + appSigBytes2, _ := appWalletSigner2.Sign(packedAppUpdate) + appSigHex2 := hexutil.Encode(appSigBytes2) + + appStateUpdate := rpc.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: "2", + Allocations: []rpc.AppAllocationV1{{Participant: participant1, Asset: asset, Amount: depositAmount.String()}}, + } + + mockStore.On("LockUserState", participant1, asset).Return(decimal.Zero, 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) + mockAssetStore.On("GetTokenDecimals", uint64(1), "0xTokenAddress").Return(uint8(6), nil).Maybe() + mockStore.On("GetAppSession", appSessionID).Return(existingAppSession, nil).Once() + mockStore.On("GetParticipantAllocations", appSessionID).Return( + map[string]map[string]decimal.Decimal{}, nil, + ).Once() + mockStore.On("RecordLedgerEntry", participant1, appSessionID, asset, depositAmount).Return(nil).Once() + mockStore.On("UpdateAppSession", mock.MatchedBy(func(session app.AppSessionV1) bool { + return session.SessionID == appSessionID && session.Version == 2 + })).Return(nil).Once() + mockStore.On("StoreUserState", mock.MatchedBy(func(state core.State) bool { + return state.UserWallet == participant1 && state.NodeSig != nil + }), mock.Anything).Return(nil).Once() + mockStore.On("RecordTransaction", mock.MatchedBy(func(tx core.Transaction) bool { + return tx.TxType == core.TransactionTypeCommit && tx.Amount.Equal(depositAmount) + }), mock.Anything).Return(nil).Once() + + rpcState := toRPCState(*incomingUserState) + reqPayload := rpc.AppSessionsV1SubmitDepositStateRequest{ + AppStateUpdate: appStateUpdate, + QuorumSigs: []string{appSigHex1, appSigHex2}, // combined weight 400 + UserState: rpcState, + } + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, string(rpc.AppSessionsV1SubmitDepositStateMethod), payload), + } + + handler.SubmitDepositState(ctx) + + require.NotNil(t, ctx.Response) + if respErr := ctx.Response.Error(); respErr != nil { + t.Fatalf("combined weight 400 with quorum 200 must pass verifyQuorum in SubmitDepositState, got: %v", respErr) + } + assert.Equal(t, rpc.MsgTypeResp, ctx.Response.Type) + mockStore.AssertExpectations(t) } 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 62f5cf9c8..ec884fa85 100644 --- a/nitronode/api/app_session_v1/submit_session_key_state.go +++ b/nitronode/api/app_session_v1/submit_session_key_state.go @@ -77,14 +77,14 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { return } for _, id := range coreState.ApplicationIDs { - if id != strings.ToLower(id) { - c.Fail(rpc.Errorf("invalid_session_key_state: application_ids must be lowercase, got: %s", id), "") + if !app.IsValidApplicationID(id) { + c.Fail(rpc.Errorf("invalid_session_key_state: application_ids must contain only lowercase letters, digits, dashes, and underscores (max 66 chars), got: %s", id), "") return } } for _, id := range coreState.AppSessionIDs { - if id != strings.ToLower(id) { - c.Fail(rpc.Errorf("invalid_session_key_state: app_session_ids must be lowercase, got: %s", id), "") + if !core.IsValidHash(id, true) { + c.Fail(rpc.Errorf("invalid_session_key_state: app_session_ids must be 0x-prefixed 32-byte lowercase hex hashes, got: %s", id), "") return } } @@ -92,19 +92,47 @@ 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 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 + // A submit with expires_at after now activates, extends, or rotates the key and requires + // both signatures. A submit with expires_at <= now is a revocation: it only deactivates an + // existing delegation, so the wallet's user_sig alone authorizes it. Requiring session_key_sig + // on the revocation path would let a lost, unavailable, or malicious session key veto its own + // revocation, stranding the user until the prior expires_at naturally passes. now is captured + // here and reused for the cap/version logic inside the transaction so the active/inactive + // decision is consistent across both. + now := time.Now() + if coreState.ExpiresAt.After(now) { + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") + return + } + // 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 + } + } else { + // Revocation only deactivates an existing delegation. Version 1 means there is no prior + // delegation, so reject it before LockSessionKeyState can seed a permanent ownership + // claim for a session_key the caller never proved possession of: a legitimate revoke is + // always version >= 2, since registration at version 1 is future-dated and requires both + // signatures. + if coreState.Version == 1 { + c.Fail(rpc.Errorf("invalid_session_key_state: cannot revoke a session key with no prior delegation"), "") + return + } + // Validate only the wallet's UserSig. Any session_key_sig present is ignored, so clear + // it before persisting to keep stored revocation rows canonical. + if err := app.ValidateAppSessionKeyStateUserSigV1(coreState); err != nil { + c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") + return + } + coreState.SessionKeySig = "" } - // Validate version and store the session key state - now := time.Now() + // revoked is true only when this submit transitions an active key to inactive, + // so the revocation log is not emitted for inactive-to-inactive updates. + var revoked bool err = h.useStoreInTx(func(tx Store) error { // 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 @@ -142,6 +170,7 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // (pg_advisory_xact_lock(hashtext(user_address))) before counting. prevActive := latestVersion > 0 && latestExpiresAt.After(now) submittedActive := coreState.ExpiresAt.After(now) + revoked = prevActive && !submittedActive if !prevActive && submittedActive && h.maxSessionKeysPerUser > 0 { count, err := tx.CountSessionKeysForUser(coreState.UserAddress) if err != nil { @@ -174,7 +203,7 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) - if !coreState.ExpiresAt.After(now) { + if revoked { logger.Info("session key revoked", "userAddress", coreState.UserAddress, "sessionKey", coreState.SessionKey, 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 d12253961..fa9849155 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 @@ -82,7 +82,6 @@ func TestSubmitSessionKeyState_Success(t *testing.T) { maxSessionKeyIDs: 10, maxParticipants: 32, maxSessionData: 1024, - maxSignedUpdates: 16, } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) @@ -292,7 +291,10 @@ func TestSubmitSessionKeyState_InvalidUserAddress(t *testing.T) { assert.Contains(t, respErr.Error(), "invalid user_address") } -func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { +// A version-1 revoke has no prior delegation to deactivate; allowing it would let a wallet +// seed a permanent (session_key, kind) ownership claim for a key it never proved possession +// of. It must be rejected before LockSessionKeyState runs, so no seed row is ever written. +func TestSubmitSessionKeyState_RevokeFirstSubmit_Rejected(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) @@ -307,14 +309,9 @@ func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { maxSessionKeyIDs: 10, } - // expires_at in the past expresses a revoke: the same monotonic version sequence - // is preserved, the auth path filters expires_at > now so the key is deactivated. expiresAt := time.Now().Add(-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, time.Time{}, nil) - mockStore.On("StoreAppSessionKeyState", mock.AnythingOfType("app.AppSessionKeyStateV1")).Return(nil) - payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -326,8 +323,11 @@ func TestSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { handler.SubmitSessionKeyState(ctx) require.NotNil(t, ctx.Response) - assert.Nil(t, ctx.Response.Error()) - mockStore.AssertExpectations(t) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "no prior delegation") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "StoreAppSessionKeyState", mock.Anything) } // Covers the typical revocation path: an active key (latestVersion > 0, prev expires_at in @@ -656,7 +656,11 @@ func TestSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing.T) { mockStore.AssertNotCalled(t, "CountSessionKeysForUser", mock.Anything) } -func TestSubmitSessionKeyState_RejectsNonLowercaseApplicationID(t *testing.T) { +// submitStateExpectingError builds a request with the given application/session IDs and +// asserts the handler rejects it at the validation boundary with errSubstr, without ever +// reaching the store. +func submitStateExpectingError(t *testing.T, applicationIDs, appSessionIDs []string, errSubstr string) { + t.Helper() mockStore := new(MockStore) handler := &Handler{ useStoreInTx: func(handler StoreTxHandler) error { @@ -675,8 +679,8 @@ func TestSubmitSessionKeyState_RejectsNonLowercaseApplicationID(t *testing.T) { UserAddress: userAddress, SessionKey: sessionKeyAddress, Version: "1", - ApplicationIDs: []string{"App-1"}, - AppSessionIDs: []string{}, + ApplicationIDs: applicationIDs, + AppSessionIDs: appSessionIDs, ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), UserSig: "0xdeadbeef", }, @@ -695,12 +699,36 @@ func TestSubmitSessionKeyState_RejectsNonLowercaseApplicationID(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "application_ids must be lowercase, got: App-1") + assert.Contains(t, respErr.Error(), errSubstr) mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } -func TestSubmitSessionKeyState_RejectsNonLowercaseAppSessionID(t *testing.T) { +func TestSubmitSessionKeyState_RejectsInvalidApplicationID(t *testing.T) { + const errSubstr = "application_ids must contain only lowercase letters, digits, dashes, and underscores" + // Uppercase, illegal character, and over the 66-char column width all fail the regex. + submitStateExpectingError(t, []string{"App-1"}, nil, errSubstr) + submitStateExpectingError(t, []string{"bad id"}, nil, errSubstr) + submitStateExpectingError(t, []string{strings.Repeat("a", 67)}, nil, errSubstr) +} + +func TestSubmitSessionKeyState_RejectsInvalidAppSessionID(t *testing.T) { + const errSubstr = "app_session_ids must be 0x-prefixed 32-byte lowercase hex hashes" + // Non-hash strings, short/long hex, and well-formed-but-uppercase hex all fail + // the single lowercase-canonical check via the same error path. + submitStateExpectingError(t, nil, []string{"Session-ABC"}, errSubstr) + submitStateExpectingError(t, nil, []string{"0x1234"}, errSubstr) + submitStateExpectingError(t, nil, []string{strings.Repeat("z", 64)}, errSubstr) + submitStateExpectingError(t, nil, []string{"0x" + strings.Repeat("A", 64)}, errSubstr) +} + +func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { mockStore := new(MockStore) + userSigner := NewMockSigner() + differentSigner := NewMockSigner() // sign with a different key + 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) @@ -709,25 +737,46 @@ func TestSubmitSessionKeyState_RejectsNonLowercaseAppSessionID(t *testing.T) { maxSessionKeyIDs: 10, } + 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, sessionKeySigner) + + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), + } + + handler.SubmitSessionKeyState(ctx) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "user_sig does not match user_address") +} + +func TestSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) { + mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) - sessionKeyAddress := "0x3333333333333333333333333333333333333333" - reqPayload := rpc.AppSessionsV1SubmitSessionKeyStateRequest{ - State: rpc.AppSessionKeyStateV1{ - UserAddress: userAddress, - SessionKey: sessionKeyAddress, - Version: "1", - ApplicationIDs: []string{}, - AppSessionIDs: []string{"Session-ABC"}, - ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), - UserSig: "0xdeadbeef", + 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), @@ -738,14 +787,13 @@ func TestSubmitSessionKeyState_RejectsNonLowercaseAppSessionID(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "app_session_ids must be lowercase, got: Session-ABC") + assert.Contains(t, respErr.Error(), "session_key must differ from user_address") mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } -func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { +func TestSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() - differentSigner := NewMockSigner() // sign with a different key userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) sessionKeySigner := NewMockSigner() sessionKeyAddress := strings.ToLower(sessionKeySigner.PublicKey().Address().String()) @@ -759,13 +807,11 @@ 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, sessionKeySigner) + // 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), @@ -776,13 +822,17 @@ func TestSubmitSessionKeyState_SignatureMismatch(t *testing.T) { require.NotNil(t, ctx.Response) respErr := ctx.Response.Error() require.NotNil(t, respErr) - assert.Contains(t, respErr.Error(), "user_sig does not match user_address") + assert.Contains(t, respErr.Error(), "session_key_sig is required") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } -func TestSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) { +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 { @@ -793,8 +843,8 @@ func TestSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) } 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) + // 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) @@ -808,11 +858,14 @@ func TestSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *testing.T) 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") + 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_RejectsMissingSessionKeySig(t *testing.T) { +// Wallet-only revocation: a submit with a past expires_at and NO session_key_sig is accepted +// on the strength of user_sig alone. This is the core remediation — a lost, unavailable, or +// uncooperative session key can no longer veto revocation of its own delegation. +func TestSubmitSessionKeyState_RevokeUserSigOnly_Succeeds(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) @@ -827,12 +880,17 @@ func TestSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { 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) + expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) + // keySigner=nil → SessionKeySig stays empty; the revocation path must not require it. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, nil, nil, expiresAt, userSigner, nil) + + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(1, prevActiveExpiresAt, 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), @@ -841,13 +899,13 @@ func TestSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) { 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) + assert.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) } -func TestSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { +// On the revocation path a present-but-mismatched session_key_sig is ignored, not validated. +// The same signature would be rejected on the active path (see RejectsMismatchedSessionKeySig). +func TestSubmitSessionKeyState_RevokeIgnoresMismatchedSessionKeySig(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) @@ -863,12 +921,58 @@ func TestSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { 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) + expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) + // SessionKeySig signed by an unrelated key — would fail the active path, ignored on revoke. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, nil, nil, expiresAt, userSigner, otherSigner) + + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindAppSession).Return(1, prevActiveExpiresAt, nil) + // The ignored session_key_sig must be cleared before persisting so stored revocation + // rows never retain unverified client input. + mockStore.On("StoreAppSessionKeyState", mock.MatchedBy(func(s app.AppSessionKeyStateV1) bool { + return s.SessionKeySig == "" + })).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) +} + +// Even on the revocation path the wallet's user_sig must be valid: a revoke signed by a key +// other than the user_address is rejected, so revocation stays a wallet-only right (not anyone's). +func TestSubmitSessionKeyState_RevokeInvalidUserSig_Rejected(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + differentSigner := 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(-time.Hour).Truncate(time.Second) + // user_sig produced by a key other than userAddress; no session_key_sig. + reqPayload := buildSignedSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, nil, nil, expiresAt, differentSigner, nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) + ctx := &rpc.Context{ Context: context.Background(), Request: rpc.NewRequest(1, rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), payload), @@ -879,7 +983,7 @@ func TestSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing.T) { 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") + assert.Contains(t, respErr.Error(), "user_sig does not match user_address") mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } diff --git a/nitronode/api/app_session_v1/testing.go b/nitronode/api/app_session_v1/testing.go index 858f7a18a..3d31e953f 100644 --- a/nitronode/api/app_session_v1/testing.go +++ b/nitronode/api/app_session_v1/testing.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/mock" "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" @@ -155,55 +154,11 @@ func (m *MockStore) GetAppSessionKeyOwner(sessionKey, appSessionId, applicationI return args.String(0), args.Error(1) } -func (m *MockStore) GetApp(appID string) (*app.AppInfoV1, error) { - args := m.Called(appID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*app.AppInfoV1), args.Error(1) -} - func (m *MockStore) ValidateChannelSessionKeyForAsset(wallet, sessionKey, asset, metadataHash string) (bool, error) { args := m.Called(wallet, sessionKey, asset, metadataHash) return args.Bool(0), args.Error(1) } -func (m *MockStore) GetAppCount(ownerWallet string) (uint64, error) { - args := m.Called(ownerWallet) - return args.Get(0).(uint64), args.Error(1) -} - -func (m *MockStore) GetTotalUserStaked(wallet string) (decimal.Decimal, error) { - args := m.Called(wallet) - return args.Get(0).(decimal.Decimal), args.Error(1) -} - -func (m *MockStore) RecordAction(wallet string, gatedAction core.GatedAction) error { - args := m.Called(wallet, gatedAction) - return args.Error(0) -} - -func (m *MockStore) GetUserActionCount(wallet string, gatedAction core.GatedAction, window time.Duration) (uint64, error) { - args := m.Called(wallet, gatedAction, window) - return args.Get(0).(uint64), args.Error(1) -} - -func (m *MockStore) GetUserActionCounts(userWallet string, window time.Duration) (map[core.GatedAction]uint64, error) { - args := m.Called(userWallet, window) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(map[core.GatedAction]uint64), args.Error(1) -} - -type MockActionGateway struct { - Err error -} - -func (m *MockActionGateway) AllowAction(_ action_gateway.Store, _ string, _ core.GatedAction) error { - return m.Err -} - // MockSigValidator is a mock implementation of the SigValidator interface type MockSigValidator struct { mock.Mock diff --git a/nitronode/api/app_session_v1/utils.go b/nitronode/api/app_session_v1/utils.go index 974553b31..08ad6952d 100644 --- a/nitronode/api/app_session_v1/utils.go +++ b/nitronode/api/app_session_v1/utils.go @@ -154,18 +154,6 @@ func unmapAppStateUpdateV1(upd *rpc.AppStateUpdateV1) (app.AppStateUpdateV1, err }, nil } -func unmapSignedAppStateUpdateV1(signedUpd *rpc.SignedAppStateUpdateV1) (app.SignedAppStateUpdateV1, error) { - appStateUpd, err := unmapAppStateUpdateV1(&signedUpd.AppStateUpdate) - if err != nil { - return app.SignedAppStateUpdateV1{}, err - } - - return app.SignedAppStateUpdateV1{ - AppStateUpdate: appStateUpd, - QuorumSigs: signedUpd.QuorumSigs, - }, nil -} - // getParticipantWeights creates a map of participant wallet addresses to their weights. func getParticipantWeights(participants []app.AppParticipantV1) map[string]uint8 { weights := make(map[string]uint8, len(participants)) diff --git a/nitronode/api/apps_v1/get_apps.go b/nitronode/api/apps_v1/get_apps.go deleted file mode 100644 index 65fd7a2b9..000000000 --- a/nitronode/api/apps_v1/get_apps.go +++ /dev/null @@ -1,80 +0,0 @@ -package apps_v1 - -import ( - "strconv" - - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/rpc" -) - -// GetApps retrieves registered applications with optional filtering. -func (h *Handler) GetApps(c *rpc.Context) { - var req rpc.AppsV1GetAppsRequest - if err := c.Request.Payload.Translate(&req); err != nil { - c.Fail(err, "failed to parse parameters") - return - } - - if req.OwnerWallet != nil { - normalizedOwnerWallet, err := core.NormalizeHexAddress(*req.OwnerWallet) - if err != nil { - c.Fail(rpc.Errorf("invalid owner_wallet: %v", err), "") - return - } - req.OwnerWallet = &normalizedOwnerWallet - } - - var paginationParams core.PaginationParams - if req.Pagination != nil { - paginationParams.Offset = req.Pagination.Offset - paginationParams.Limit = req.Pagination.Limit - paginationParams.Sort = req.Pagination.Sort - } - - apps, metadata, err := h.store.GetApps(req.AppID, req.OwnerWallet, &paginationParams) - if err != nil { - c.Fail(err, "failed to retrieve apps") - return - } - - response := rpc.AppsV1GetAppsResponse{ - Apps: make([]rpc.AppInfoV1, len(apps)), - Metadata: mapPaginationMetadataV1(metadata), - } - - for i, a := range apps { - response.Apps[i] = mapAppInfoV1(a) - } - - payload, err := rpc.NewPayload(response) - if err != nil { - c.Fail(err, "failed to create response") - return - } - - c.Succeed(c.Request.Method, payload) -} - -func mapAppInfoV1(info app.AppInfoV1) rpc.AppInfoV1 { - return rpc.AppInfoV1{ - AppV1: rpc.AppV1{ - ID: info.App.ID, - OwnerWallet: info.App.OwnerWallet, - Metadata: info.App.Metadata, - Version: strconv.FormatUint(info.App.Version, 10), - CreationApprovalNotRequired: info.App.CreationApprovalNotRequired, - }, - CreatedAt: strconv.FormatInt(info.CreatedAt.Unix(), 10), - UpdatedAt: strconv.FormatInt(info.UpdatedAt.Unix(), 10), - } -} - -func mapPaginationMetadataV1(meta core.PaginationMetadata) rpc.PaginationMetadataV1 { - return rpc.PaginationMetadataV1{ - Page: meta.Page, - PerPage: meta.PerPage, - TotalCount: meta.TotalCount, - PageCount: meta.PageCount, - } -} diff --git a/nitronode/api/apps_v1/get_apps_test.go b/nitronode/api/apps_v1/get_apps_test.go deleted file mode 100644 index 988bd6eed..000000000 --- a/nitronode/api/apps_v1/get_apps_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package apps_v1 - -import ( - "context" - "testing" - - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/rpc" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestGetApps_Success(t *testing.T) { - mockStore := &MockStore{ - getAppsFn: func(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - return []app.AppInfoV1{ - {App: app.AppV1{ID: "app-1", OwnerWallet: "0x1111111111111111111111111111111111111111", Metadata: "0x00", Version: 1}}, - {App: app.AppV1{ID: "app-2", OwnerWallet: "0x2222", Metadata: "0x00", Version: 1}}, - }, core.PaginationMetadata{TotalCount: 2, Page: 1, PerPage: 50}, nil - }, - } - - handler := NewHandler(mockStore, nil, nil, 4096) - - reqPayload := rpc.AppsV1GetAppsRequest{} - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1GetAppsMethod), payload), - } - - handler.GetApps(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) - - var resp rpc.AppsV1GetAppsResponse - require.NoError(t, ctx.Response.Payload.Translate(&resp)) - assert.Len(t, resp.Apps, 2) - assert.Equal(t, "app-1", resp.Apps[0].ID) - assert.Equal(t, "app-2", resp.Apps[1].ID) - assert.Equal(t, uint32(2), resp.Metadata.TotalCount) -} - -func TestGetApps_EmptyResults(t *testing.T) { - mockStore := &MockStore{ - getAppsFn: func(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - return []app.AppInfoV1{}, core.PaginationMetadata{TotalCount: 0}, nil - }, - } - - handler := NewHandler(mockStore, nil, nil, 4096) - - reqPayload := rpc.AppsV1GetAppsRequest{} - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1GetAppsMethod), payload), - } - - handler.GetApps(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) - - var resp rpc.AppsV1GetAppsResponse - require.NoError(t, ctx.Response.Payload.Translate(&resp)) - assert.Empty(t, resp.Apps) - assert.Equal(t, uint32(0), resp.Metadata.TotalCount) -} - -func TestGetApps_FilterByAppID(t *testing.T) { - mockStore := &MockStore{ - getAppsFn: func(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - require.NotNil(t, appID) - assert.Equal(t, "app-1", *appID) - return []app.AppInfoV1{ - {App: app.AppV1{ID: "app-1", OwnerWallet: "0x1111111111111111111111111111111111111111", Version: 1}}, - }, core.PaginationMetadata{TotalCount: 1, Page: 1, PerPage: 50}, nil - }, - } - - handler := NewHandler(mockStore, nil, nil, 4096) - - aid := "app-1" - reqPayload := rpc.AppsV1GetAppsRequest{AppID: &aid} - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1GetAppsMethod), payload), - } - - handler.GetApps(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) - - var resp rpc.AppsV1GetAppsResponse - require.NoError(t, ctx.Response.Payload.Translate(&resp)) - assert.Len(t, resp.Apps, 1) - assert.Equal(t, "app-1", resp.Apps[0].ID) -} - -func TestGetApps_FilterByOwnerWallet(t *testing.T) { - mockStore := &MockStore{ - getAppsFn: func(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - require.NotNil(t, ownerWallet) - assert.Equal(t, "0x1111111111111111111111111111111111111111", *ownerWallet) - return []app.AppInfoV1{ - {App: app.AppV1{ID: "app-1", OwnerWallet: "0x1111111111111111111111111111111111111111", Version: 1}}, - }, core.PaginationMetadata{TotalCount: 1, Page: 1, PerPage: 50}, nil - }, - } - - handler := NewHandler(mockStore, nil, nil, 4096) - - owner := "0x1111111111111111111111111111111111111111" - reqPayload := rpc.AppsV1GetAppsRequest{OwnerWallet: &owner} - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1GetAppsMethod), payload), - } - - handler.GetApps(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) - - var resp rpc.AppsV1GetAppsResponse - require.NoError(t, ctx.Response.Payload.Translate(&resp)) - assert.Len(t, resp.Apps, 1) - assert.Equal(t, "0x1111111111111111111111111111111111111111", resp.Apps[0].OwnerWallet) -} - -// TestGetApps_NormalizesOwnerWallet verifies that an owner_wallet filter submitted in -// mixed case is normalized to canonical lowercase before being passed to the store. -func TestGetApps_NormalizesOwnerWallet(t *testing.T) { - canonicalOwner := "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd" - mixedCaseOwner := "0xABCDEFabcdefABCDEFabcdefABCDEFabcdefABCD" - - mockStore := &MockStore{ - getAppsFn: func(appID *string, ownerWallet *string, _ *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - require.NotNil(t, ownerWallet) - assert.Equal(t, canonicalOwner, *ownerWallet) - return []app.AppInfoV1{}, core.PaginationMetadata{}, nil - }, - } - - handler := NewHandler(mockStore, nil, nil, 4096) - - reqPayload := rpc.AppsV1GetAppsRequest{OwnerWallet: &mixedCaseOwner} - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1GetAppsMethod), payload), - } - - handler.GetApps(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) -} diff --git a/nitronode/api/apps_v1/handler.go b/nitronode/api/apps_v1/handler.go deleted file mode 100644 index 280e6e453..000000000 --- a/nitronode/api/apps_v1/handler.go +++ /dev/null @@ -1,20 +0,0 @@ -package apps_v1 - -// Handler manages app registry operations and provides RPC endpoints. -type Handler struct { - store Store - useStoreInTx StoreTxProvider - actionGateway ActionGateway - - maxAppMetadataLen int -} - -// NewHandler creates a new Handler instance with the provided dependencies. -func NewHandler(store Store, useStoreInTx StoreTxProvider, actionGateway ActionGateway, maxAppMetadataLen int) *Handler { - return &Handler{ - store: store, - useStoreInTx: useStoreInTx, - actionGateway: actionGateway, - maxAppMetadataLen: maxAppMetadataLen, - } -} diff --git a/nitronode/api/apps_v1/interface.go b/nitronode/api/apps_v1/interface.go deleted file mode 100644 index 106b18c36..000000000 --- a/nitronode/api/apps_v1/interface.go +++ /dev/null @@ -1,33 +0,0 @@ -package apps_v1 - -import ( - "github.com/layer-3/nitrolite/nitronode/action_gateway" - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/core" -) - -// StoreTxHandler is a function that executes Store operations within a transaction. -// If the handler returns an error, the transaction is rolled back; otherwise it's committed. -type StoreTxHandler func(Store) error - -// StoreTxProvider wraps Store operations in a database transaction. -// It accepts a StoreTxHandler and manages transaction lifecycle (begin, commit, rollback). -// Returns an error if the handler fails or the transaction cannot be committed. -type StoreTxProvider func(StoreTxHandler) error - -// Store defines the persistence layer interface for user data management. -// All methods should be implemented to work within database transactions. -type Store interface { - // CreateApp registers a new application. Returns an error if the app ID already exists. - CreateApp(entry app.AppV1) error - - // GetApps retrieves applications with optional filtering. - GetApps(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) - - action_gateway.Store -} - -type ActionGateway interface { - // AllowAppRegistration checks if a user is allowed to register a new application based on their past activity and allowances. - AllowAppRegistration(tx action_gateway.Store, userAddress string) error -} diff --git a/nitronode/api/apps_v1/submit_app_version.go b/nitronode/api/apps_v1/submit_app_version.go deleted file mode 100644 index d0876daa5..000000000 --- a/nitronode/api/apps_v1/submit_app_version.go +++ /dev/null @@ -1,109 +0,0 @@ -package apps_v1 - -import ( - "strconv" - "strings" - - "github.com/ethereum/go-ethereum/common/hexutil" - - "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" -) - -// SubmitAppVersion updates an entry in the app registry. -func (h *Handler) SubmitAppVersion(c *rpc.Context) { - var req rpc.AppsV1SubmitAppVersionRequest - if err := c.Request.Payload.Translate(&req); err != nil { - c.Fail(err, "failed to parse parameters") - return - } - - if !app.AppIDV1Regex.MatchString(req.App.ID) { - c.Fail(rpc.Errorf("invalid app ID: should match regex %s", app.AppIDV1Regex.String()), "") - return - } - if req.App.OwnerWallet == "" { - c.Fail(nil, "owner_wallet is required") - return - } - normalizedOwnerWallet, err := core.NormalizeHexAddress(req.App.OwnerWallet) - if err != nil { - c.Fail(rpc.Errorf("invalid owner_wallet: %v", err), "") - return - } - if req.OwnerSig == "" { - c.Fail(nil, "owner_sig is required") - return - } - if len(req.App.Metadata) > h.maxAppMetadataLen { - c.Fail(rpc.Errorf("metadata exceeds maximum length of %d characters", h.maxAppMetadataLen), "") - return - } - - version, err := strconv.ParseUint(req.App.Version, 10, 64) - if err != nil { - c.Fail(err, "invalid version") - return - } - - // Only creation (version 1) is supported for now - if version != 1 { - c.Fail(nil, "only version 1 (creation) is currently supported") - return - } - - err = h.useStoreInTx(func(tx Store) error { - err := h.actionGateway.AllowAppRegistration(tx, normalizedOwnerWallet) - if err != nil { - return rpc.NewError(err) - } - - appEntry := app.AppV1{ - ID: strings.ToLower(req.App.ID), - OwnerWallet: normalizedOwnerWallet, - Metadata: req.App.Metadata, - Version: version, - CreationApprovalNotRequired: req.App.CreationApprovalNotRequired, - } - - packedApp, err := app.PackAppV1(appEntry) - if err != nil { - return rpc.Errorf("failed to pack app data: %v", err) - } - - sigBytes, err := hexutil.Decode(req.OwnerSig) - if err != nil { - return rpc.Errorf("failed to decode owner signature: %v", err) - } - - sigValidator, err := sign.NewSigValidator(sign.TypeEthereumMsg) - if err != nil { - return rpc.Errorf("failed to create signature validator: %v", err) - } - - if err := sigValidator.Verify(appEntry.OwnerWallet, packedApp, sigBytes); err != nil { - return rpc.Errorf("invalid owner signature: %v", err) - } - - if err := tx.CreateApp(appEntry); err != nil { - return rpc.Errorf("failed to create app") - } - - return nil - }) - if err != nil { - c.Fail(err, "failed to create app") - return - } - - resp := rpc.AppsV1SubmitAppVersionResponse{} - payload, err := rpc.NewPayload(resp) - if err != nil { - c.Fail(err, "failed to create response") - return - } - - c.Succeed(c.Request.Method, payload) -} diff --git a/nitronode/api/apps_v1/submit_app_version_test.go b/nitronode/api/apps_v1/submit_app_version_test.go deleted file mode 100644 index 21536bc2a..000000000 --- a/nitronode/api/apps_v1/submit_app_version_test.go +++ /dev/null @@ -1,294 +0,0 @@ -package apps_v1 - -import ( - "context" - "strings" - "testing" - - "github.com/ethereum/go-ethereum/common/hexutil" - "github.com/ethereum/go-ethereum/crypto" - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/rpc" - "github.com/layer-3/nitrolite/pkg/sign" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// testOwnerWallet generates a real ECDSA key pair, signs the packed app data, and returns -// the wallet address (lowercase hex), the hex-encoded signature, and the signer. -func testOwnerWallet(t *testing.T, appEntry app.AppV1) (address string, sig string) { - t.Helper() - - key, err := crypto.GenerateKey() - require.NoError(t, err) - - addr := strings.ToLower(crypto.PubkeyToAddress(key.PublicKey).Hex()) - privKeyHex := hexutil.Encode(crypto.FromECDSA(key)) - - signer, err := sign.NewEthereumMsgSigner(privKeyHex) - require.NoError(t, err) - - // Update the entry with the real address for packing - appEntry.OwnerWallet = addr - packed, err := app.PackAppV1(appEntry) - require.NoError(t, err) - - sigBytes, err := signer.Sign(packed) - require.NoError(t, err) - - return addr, hexutil.Encode(sigBytes) -} - -func newHandlerWithDefaults(store Store) *Handler { - storeTxProvider := func(fn StoreTxHandler) error { - return fn(store) - } - return NewHandler(store, storeTxProvider, &MockActionGateway{}, 4096) -} - -func TestSubmitAppVersion_Success(t *testing.T) { - appEntry := app.AppV1{ - ID: "test-app", - Metadata: "0x0000000000000000000000000000000000000000000000000000000000000000", - Version: 1, - } - - addr, sig := testOwnerWallet(t, appEntry) - - mockStore := &MockStore{ - createAppFn: func(entry app.AppV1) error { - assert.Equal(t, "test-app", entry.ID) - assert.Equal(t, addr, entry.OwnerWallet) - return nil - }, - } - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler(mockStore, storeTxProvider, &MockActionGateway{}, 4096) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "test-app", - OwnerWallet: addr, - Metadata: "0x0000000000000000000000000000000000000000000000000000000000000000", - Version: "1", - }, - OwnerSig: sig, - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) -} - -func TestSubmitAppVersion_MissingOwnerWallet(t *testing.T) { - mockStore := &MockStore{} - handler := newHandlerWithDefaults(mockStore) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "test-app", - Metadata: "0x00", - Version: "1", - }, - OwnerSig: "0xdeadbeef", - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "owner_wallet") -} - -func TestSubmitAppVersion_MissingOwnerSig(t *testing.T) { - mockStore := &MockStore{} - handler := newHandlerWithDefaults(mockStore) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x00", - Version: "1", - }, - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "owner_sig") -} - -func TestSubmitAppVersion_InvalidAppID(t *testing.T) { - mockStore := &MockStore{} - handler := newHandlerWithDefaults(mockStore) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "INVALID_APP!!", // doesn't match regex - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x00", - Version: "1", - }, - OwnerSig: "0xdeadbeef", - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid app ID") -} - -func TestSubmitAppVersion_InvalidVersion(t *testing.T) { - mockStore := &MockStore{} - handler := newHandlerWithDefaults(mockStore) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x00", - Version: "2", // Only version 1 is supported - }, - OwnerSig: "0xdeadbeef", - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "version 1") -} - -// TestSubmitAppVersion_NormalizesMixedCaseOwnerWallet verifies that an owner_wallet -// submitted in mixed case is normalized to the canonical lowercase form before being -// persisted and used for signature verification. -func TestSubmitAppVersion_NormalizesMixedCaseOwnerWallet(t *testing.T) { - appEntry := app.AppV1{ - ID: "test-app", - Metadata: "0x0000000000000000000000000000000000000000000000000000000000000000", - Version: 1, - } - - addr, sig := testOwnerWallet(t, appEntry) - - mockStore := &MockStore{ - createAppFn: func(entry app.AppV1) error { - assert.Equal(t, addr, entry.OwnerWallet, "owner wallet must be persisted as canonical lowercase form") - return nil - }, - } - - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler(mockStore, storeTxProvider, &MockActionGateway{}, 4096) - - mixedCaseOwner := "0x" + strings.ToUpper(strings.TrimPrefix(addr, "0x")) - require.NotEqual(t, addr, mixedCaseOwner) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "test-app", - OwnerWallet: mixedCaseOwner, - Metadata: "0x0000000000000000000000000000000000000000000000000000000000000000", - Version: "1", - }, - OwnerSig: sig, - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) -} - -func TestSubmitAppVersion_InvalidSignature(t *testing.T) { - mockStore := &MockStore{} - handler := newHandlerWithDefaults(mockStore) - - reqPayload := rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x0000000000000000000000000000000000000000000000000000000000000000", - Version: "1", - }, - OwnerSig: "0x" + strings.Repeat("ab", 65), // valid hex, wrong signature - } - - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, string(rpc.AppsV1SubmitAppVersionMethod), payload), - } - - handler.SubmitAppVersion(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "invalid owner signature") -} diff --git a/nitronode/api/apps_v1/testing.go b/nitronode/api/apps_v1/testing.go deleted file mode 100644 index e136d77b9..000000000 --- a/nitronode/api/apps_v1/testing.go +++ /dev/null @@ -1,58 +0,0 @@ -package apps_v1 - -import ( - "time" - - "github.com/layer-3/nitrolite/nitronode/action_gateway" - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/core" - "github.com/shopspring/decimal" -) - -// MockStore implements the Store interface for testing. -type MockStore struct { - createAppFn func(entry app.AppV1) error - getAppsFn func(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) -} - -func (m *MockStore) CreateApp(entry app.AppV1) error { - if m.createAppFn != nil { - return m.createAppFn(entry) - } - return nil -} - -func (m *MockStore) GetApps(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - if m.getAppsFn != nil { - return m.getAppsFn(appID, ownerWallet, pagination) - } - return nil, core.PaginationMetadata{}, nil -} - -func (m *MockStore) GetAppCount(_ string) (uint64, error) { - return 0, nil -} - -func (m *MockStore) GetTotalUserStaked(_ string) (decimal.Decimal, error) { - return decimal.Zero, nil -} - -func (m *MockStore) RecordAction(_ string, _ core.GatedAction) error { - return nil -} - -func (m *MockStore) GetUserActionCount(_ string, _ core.GatedAction, _ time.Duration) (uint64, error) { - return 0, nil -} - -func (m *MockStore) GetUserActionCounts(_ string, _ time.Duration) (map[core.GatedAction]uint64, error) { - return nil, nil -} - -type MockActionGateway struct { - Err error -} - -func (m *MockActionGateway) AllowAppRegistration(_ action_gateway.Store, _ string) error { - return m.Err -} diff --git a/nitronode/api/channel_v1/get_channels.go b/nitronode/api/channel_v1/get_channels.go index fb74b74fb..9fcdfd168 100644 --- a/nitronode/api/channel_v1/get_channels.go +++ b/nitronode/api/channel_v1/get_channels.go @@ -78,21 +78,20 @@ func (h *Handler) GetChannels(c *rpc.Context) { const defaultLimit uint32 = 100 const maxLimit uint32 = 1000 - var limit, offset uint32 + var paginationParams core.PaginationParams if req.Pagination != nil { - if req.Pagination.Limit != nil { - limit = *req.Pagination.Limit - } - if req.Pagination.Offset != nil { - offset = *req.Pagination.Offset - } + paginationParams.Offset = req.Pagination.Offset + paginationParams.Limit = req.Pagination.Limit + paginationParams.Sort = req.Pagination.Sort } + // GetOffsetAndLimit caps the limit at maxLimit and clamps the offset so the + // later int(offset) conversion in the store never wraps negative. An explicit + // limit of 0 still falls back to the default so the page-count math below + // never divides by zero. + offset, limit := paginationParams.GetOffsetAndLimit(defaultLimit, maxLimit) if limit == 0 { limit = defaultLimit } - if limit > maxLimit { - limit = maxLimit - } var channels []core.Channel var totalCount uint32 diff --git a/nitronode/api/channel_v1/get_channels_test.go b/nitronode/api/channel_v1/get_channels_test.go index 533995d58..fe98fa3dc 100644 --- a/nitronode/api/channel_v1/get_channels_test.go +++ b/nitronode/api/channel_v1/get_channels_test.go @@ -31,7 +31,6 @@ func newGetChannelsHandler(mockTxStore *MockStore) *Handler { minChallenge: uint32(3600), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } } diff --git a/nitronode/api/channel_v1/get_escrow_channel.go b/nitronode/api/channel_v1/get_escrow_channel.go index 028d52fa4..e34b17bce 100644 --- a/nitronode/api/channel_v1/get_escrow_channel.go +++ b/nitronode/api/channel_v1/get_escrow_channel.go @@ -18,6 +18,11 @@ func (h *Handler) GetEscrowChannel(c *rpc.Context) { return } + if !core.IsValidHash(req.EscrowChannelID, false) { + c.Fail(rpc.Errorf("invalid escrow_channel_id"), "") + return + } + var channel *core.Channel err := h.useStoreInTx(func(tx Store) error { var err error diff --git a/nitronode/api/channel_v1/get_escrow_channel_test.go b/nitronode/api/channel_v1/get_escrow_channel_test.go index ba63b01fb..5111986b5 100644 --- a/nitronode/api/channel_v1/get_escrow_channel_test.go +++ b/nitronode/api/channel_v1/get_escrow_channel_test.go @@ -37,12 +37,11 @@ func TestGetEscrowChannel_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data userWallet := "0x1234567890123456789012345678901234567890" - escrowChannelID := "0xEscrowChannel456" + escrowChannelID := "0x1111111111111111111111111111111111111111111111111111111111111111" escrowChannel := core.Channel{ ChannelID: escrowChannelID, @@ -117,10 +116,9 @@ func TestGetEscrowChannel_NotFound(t *testing.T) { minChallenge: 3600, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } - escrowChannelID := "0xMissingEscrowChannel" + escrowChannelID := "0x2222222222222222222222222222222222222222222222222222222222222222" mockTxStore.On("GetChannelByID", escrowChannelID).Return(nil, nil) reqPayload := rpc.ChannelsV1GetEscrowChannelRequest{EscrowChannelID: escrowChannelID} @@ -145,3 +143,42 @@ func TestGetEscrowChannel_NotFound(t *testing.T) { mockTxStore.AssertExpectations(t) } + +func TestGetEscrowChannel_InvalidID(t *testing.T) { + mockTxStore := new(MockStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: new(MockStatePacker), + useStoreInTx: func(h StoreTxHandler) error { + return h(mockTxStore) + }, + nodeSigner: nodeSigner, + nodeAddress: mockSigner.PublicKey().Address().String(), + minChallenge: 3600, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + } + + for _, id := range []string{"", "0xabc", "not-a-hash"} { + reqPayload := rpc.ChannelsV1GetEscrowChannelRequest{EscrowChannelID: id} + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "channels.v1.get_escrow_channel", Payload: payload}, + } + + handler.GetEscrowChannel(ctx) + + require.NotNil(t, ctx.Response.Error(), "id %q should be rejected", id) + assert.Contains(t, ctx.Response.Error().Error(), "invalid escrow_channel_id") + } + + // A malformed id must never reach the store. + mockTxStore.AssertNotCalled(t, "GetChannelByID") +} diff --git a/nitronode/api/channel_v1/get_home_channel_test.go b/nitronode/api/channel_v1/get_home_channel_test.go index a85d69c98..5734aa1d8 100644 --- a/nitronode/api/channel_v1/get_home_channel_test.go +++ b/nitronode/api/channel_v1/get_home_channel_test.go @@ -37,7 +37,6 @@ func TestGetHomeChannel_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data @@ -126,7 +125,6 @@ func TestGetHomeChannel_NotFound(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data diff --git a/nitronode/api/channel_v1/get_last_key_states.go b/nitronode/api/channel_v1/get_last_key_states.go index 69288cce6..7019486af 100644 --- a/nitronode/api/channel_v1/get_last_key_states.go +++ b/nitronode/api/channel_v1/get_last_key_states.go @@ -23,7 +23,7 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { return } - var limit, offset uint32 + var paginationParams core.PaginationParams 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 @@ -33,14 +33,14 @@ func (h *Handler) GetLastKeyStates(c *rpc.Context) { 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 - } + paginationParams.Offset = req.Pagination.Offset + paginationParams.Limit = req.Pagination.Limit } - if limit == 0 || limit > rpc.GetLastKeyStatesPageLimit { + // GetOffsetAndLimit caps the limit and clamps the offset so the later + // int(offset) conversion in the store never wraps negative. An explicit + // limit of 0 still falls back to the page limit. + offset, limit := paginationParams.GetOffsetAndLimit(rpc.GetLastKeyStatesPageLimit, rpc.GetLastKeyStatesPageLimit) + if limit == 0 { limit = rpc.GetLastKeyStatesPageLimit } diff --git a/nitronode/api/channel_v1/get_latest_state_test.go b/nitronode/api/channel_v1/get_latest_state_test.go index 5bebb38fd..db2256037 100644 --- a/nitronode/api/channel_v1/get_latest_state_test.go +++ b/nitronode/api/channel_v1/get_latest_state_test.go @@ -38,7 +38,6 @@ func TestGetLatestState_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data @@ -138,7 +137,6 @@ func TestGetLatestState_OnlySigned(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data diff --git a/nitronode/api/channel_v1/handler.go b/nitronode/api/channel_v1/handler.go index a9153f158..81f8ed438 100644 --- a/nitronode/api/channel_v1/handler.go +++ b/nitronode/api/channel_v1/handler.go @@ -14,7 +14,6 @@ import ( type Handler struct { useStoreInTx StoreTxProvider memoryStore MemoryStore - actionGateway ActionGateway nodeSigner *core.ChannelDefaultSigner stateAdvancer core.StateAdvancer statePacker core.StatePacker @@ -30,7 +29,6 @@ type Handler struct { func NewHandler( useStoreInTx StoreTxProvider, memoryStore MemoryStore, - actionGateway ActionGateway, nodeSigner *core.ChannelDefaultSigner, stateAdvancer core.StateAdvancer, statePacker core.StatePacker, @@ -45,7 +43,6 @@ func NewHandler( statePacker: statePacker, useStoreInTx: useStoreInTx, memoryStore: memoryStore, - actionGateway: actionGateway, nodeSigner: nodeSigner, nodeAddress: nodeAddress, minChallenge: minChallenge, @@ -62,24 +59,55 @@ func (h *Handler) getChannelSigValidator(tx Store, asset string) *core.ChannelSi }) } +// lockTransferBalances normalizes the receiver address, rejects self-transfers, +// and locks the sender's and receiver's (wallet, asset) balance rows in a +// deterministic order (ascending lowercase wallet) to avoid database deadlocks +// when two opposite-direction transfers run concurrently. It returns the +// normalized receiver wallet. +// +// Callers MUST call this before issueTransferReceiverState for a TransferSend +// transition — issueTransferReceiverState assumes the receiver row is already +// locked and does not take the lock itself. +func (h *Handler) lockTransferBalances(tx Store, senderState core.State) (string, error) { + receiverWallet, err := core.NormalizeHexAddress(senderState.Transition.AccountID) + if err != nil { + return "", rpc.Errorf("invalid receiver wallet address: %v", err) + } + if strings.EqualFold(senderState.UserWallet, receiverWallet) { + return "", rpc.Errorf("sender and receiver wallets are the same") + } + + // Lock both rows in ascending lowercase-wallet order so concurrent + // opposite-direction transfers acquire the same locks in the same sequence. + // LockUserState lowercases internally, so the sort key matches the lock key. + first, second := senderState.UserWallet, receiverWallet + if strings.ToLower(first) > strings.ToLower(second) { + first, second = second, first + } + if _, err := tx.LockUserState(first, senderState.Asset); err != nil { + return "", rpc.Errorf("failed to lock user state: %v", err) + } + if _, err := tx.LockUserState(second, senderState.Asset); err != nil { + return "", rpc.Errorf("failed to lock user state: %v", err) + } + return receiverWallet, nil +} + // issueTransferReceiverState creates and stores a new state for the receiver of a transfer. // It reads the receiver's current state, applies a transfer_receive transition with the same // amount and tx hash, signs it with the node's key, and persists it. -func (h *Handler) issueTransferReceiverState(ctx context.Context, tx Store, senderState core.State, applicationID string) (*core.State, error) { +// +// receiverWallet must be the normalized receiver address returned by +// lockTransferBalances, which the caller MUST invoke first — that call also +// locks the receiver's (wallet, asset) row and rejects self-transfers, so this +// function does neither itself. +func (h *Handler) issueTransferReceiverState(ctx context.Context, tx Store, senderState core.State, receiverWallet, applicationID string) (*core.State, error) { logger := log.FromContext(ctx) incomingTransition := senderState.Transition if incomingTransition.Type != core.TransitionTypeTransferSend { return nil, rpc.Errorf("incoming state doesn't have 'transfer_send' transition") } - receiverWallet, err := core.NormalizeHexAddress(incomingTransition.AccountID) - if err != nil { - return nil, rpc.Errorf("invalid receiver wallet address: %v", err) - } - - if strings.EqualFold(senderState.UserWallet, receiverWallet) { - return nil, rpc.Errorf("sender and receiver wallets are the same") - } logger = logger. WithKV("sender", senderState.UserWallet). @@ -88,11 +116,6 @@ func (h *Handler) issueTransferReceiverState(ctx context.Context, tx Store, send logger.Debug("issuing transfer receiver state") - // Lock the receiver's state to prevent concurrent modifications - if _, err := tx.LockUserState(receiverWallet, senderState.Asset); err != nil { - return nil, rpc.Errorf("failed to lock receiver state: %v", err) - } - currentState, err := tx.GetLastUserState(receiverWallet, senderState.Asset, false) if err != nil { return nil, rpc.Errorf("failed to get last %s user state for transfer receiver with address %s", senderState.Asset, receiverWallet) diff --git a/nitronode/api/channel_v1/interface.go b/nitronode/api/channel_v1/interface.go index 56b50d2ac..bd2863eb6 100644 --- a/nitronode/api/channel_v1/interface.go +++ b/nitronode/api/channel_v1/interface.go @@ -3,7 +3,6 @@ package channel_v1 import ( "time" - "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" @@ -120,13 +119,6 @@ type Store interface { // exists at its latest version for the (wallet, sessionKey) pair, includes the given asset, // and matches the metadata hash. ValidateChannelSessionKeyForAsset(wallet, sessionKey, asset, metadataHash string) (bool, error) - - action_gateway.Store -} - -type ActionGateway interface { - // AllowAction checks if a user is allowed to perform a specific gated action based on their past activity and allowances. - AllowAction(tx action_gateway.Store, userAddress string, gatedAction core.GatedAction) error } // SigValidator validates cryptographic signatures on state transitions. diff --git a/nitronode/api/channel_v1/lock_transfer_balances_test.go b/nitronode/api/channel_v1/lock_transfer_balances_test.go new file mode 100644 index 000000000..ac9a6e995 --- /dev/null +++ b/nitronode/api/channel_v1/lock_transfer_balances_test.go @@ -0,0 +1,89 @@ +package channel_v1 + +import ( + "testing" + + "github.com/shopspring/decimal" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/layer-3/nitrolite/pkg/core" +) + +// lockOrder returns the wallet arguments of every LockUserState call recorded on +// the mock, in invocation order. +func lockOrder(m *MockStore) []string { + var order []string + for _, call := range m.Calls { + if call.Method == "LockUserState" { + order = append(order, call.Arguments.String(0)) + } + } + return order +} + +func transferSenderState(sender, receiver, asset string) core.State { + return core.State{ + UserWallet: sender, + Asset: asset, + Transition: core.Transition{ + Type: core.TransitionTypeTransferSend, + AccountID: receiver, + }, + } +} + +// MF3-L18: both balance rows must be locked in a deterministic order (ascending +// lowercase wallet) regardless of transfer direction, so two opposite-direction +// transfers can't acquire row locks in opposite order and deadlock. +func TestLockTransferBalances_DeterministicOrder(t *testing.T) { + const ( + asset = "USDC" + low = "0x1111111111111111111111111111111111111111" + high = "0x2222222222222222222222222222222222222222" + ) + + t.Run("sender lower than receiver", func(t *testing.T) { + h := &Handler{} + tx := new(MockStore) + tx.On("LockUserState", mock.Anything, asset).Return(decimal.Zero, nil) + + receiver, err := h.lockTransferBalances(tx, transferSenderState(low, high, asset)) + require.NoError(t, err) + require.Equal(t, high, receiver) + require.Equal(t, []string{low, high}, lockOrder(tx)) + }) + + t.Run("sender higher than receiver", func(t *testing.T) { + h := &Handler{} + tx := new(MockStore) + tx.On("LockUserState", mock.Anything, asset).Return(decimal.Zero, nil) + + // Direction flipped: sender is the lexicographically larger wallet, but the + // locks must still be taken low-then-high — same sequence as the case above. + receiver, err := h.lockTransferBalances(tx, transferSenderState(high, low, asset)) + require.NoError(t, err) + require.Equal(t, low, receiver) + require.Equal(t, []string{low, high}, lockOrder(tx)) + }) +} + +func TestLockTransferBalances_RejectsSelfTransfer(t *testing.T) { + const wallet = "0x1111111111111111111111111111111111111111" + h := &Handler{} + tx := new(MockStore) + + // Mixed case must still be caught as a self-transfer before any lock is taken. + _, err := h.lockTransferBalances(tx, transferSenderState(wallet, "0x1111111111111111111111111111111111111111", "USDC")) + require.Error(t, err) + require.Empty(t, lockOrder(tx), "no row should be locked for a self-transfer") +} + +func TestLockTransferBalances_RejectsInvalidReceiver(t *testing.T) { + h := &Handler{} + tx := new(MockStore) + + _, err := h.lockTransferBalances(tx, transferSenderState("0x1111111111111111111111111111111111111111", "not-an-address", "USDC")) + require.Error(t, err) + require.Empty(t, lockOrder(tx), "no row should be locked when the receiver address is invalid") +} diff --git a/nitronode/api/channel_v1/request_creation.go b/nitronode/api/channel_v1/request_creation.go index 53b8e00df..57378984e 100644 --- a/nitronode/api/channel_v1/request_creation.go +++ b/nitronode/api/channel_v1/request_creation.go @@ -82,18 +82,19 @@ func (h *Handler) RequestCreation(c *rpc.Context) { applicationID := rpc.GetApplicationID(c) var nodeSig string + var receiverWallet string err = h.useStoreInTx(func(tx Store) error { - // Gate the incoming transition through the action gateway before any state - // is signed, stored, or receiver-side state is issued. Mirrors SubmitState so - // gated actions (e.g. transfer_send) cannot bypass the 24h allowance by - // piggybacking on channel creation. - if err := h.actionGateway.AllowAction(tx, incomingState.UserWallet, incomingState.Transition.Type.GatedAction()); err != nil { - return rpc.NewError(err) - } - - _, err := tx.LockUserState(incomingState.UserWallet, incomingState.Asset) - if err != nil { - return rpc.Errorf("failed to lock user state: %v", err) + if incomingState.Transition.Type == core.TransitionTypeTransferSend { + // Lock both sender and receiver balance rows up front in deterministic + // order so concurrent opposite-direction transfers can't deadlock. + receiverWallet, err = h.lockTransferBalances(tx, incomingState) + if err != nil { + return err + } + } else { + if _, err := tx.LockUserState(incomingState.UserWallet, incomingState.Asset); err != nil { + 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. @@ -234,7 +235,7 @@ func (h *Handler) RequestCreation(c *rpc.Context) { // We return Node's signature, the user is expected to submit this on blockchain. case core.TransitionTypeTransferSend: - newReceiverState, err := h.issueTransferReceiverState(ctx, tx, incomingState, applicationID) + newReceiverState, err := h.issueTransferReceiverState(ctx, tx, incomingState, receiverWallet, applicationID) if err != nil { return rpc.Errorf("failed to issue receiver state: %v", err) } diff --git a/nitronode/api/channel_v1/request_creation_test.go b/nitronode/api/channel_v1/request_creation_test.go index 0fd23fbab..0d0233799 100644 --- a/nitronode/api/channel_v1/request_creation_test.go +++ b/nitronode/api/channel_v1/request_creation_test.go @@ -2,7 +2,6 @@ package channel_v1 import ( "context" - "errors" "strconv" "testing" @@ -45,7 +44,6 @@ func TestRequestCreation_Success(t *testing.T) { maxChallenge: maxChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -202,7 +200,6 @@ func TestRequestCreation_Acknowledgement_Success(t *testing.T) { maxChallenge: maxChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -351,7 +348,6 @@ func TestRequestCreation_InvalidChallenge(t *testing.T) { maxChallenge: maxChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data @@ -454,7 +450,6 @@ func TestRequestCreation_ChallengeTooHigh(t *testing.T) { maxChallenge: maxChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data @@ -553,7 +548,6 @@ func TestRequestCreation_NonClosedChannelRejection(t *testing.T) { maxChallenge: uint32(604800), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() @@ -625,9 +619,7 @@ func TestRequestCreation_NonClosedChannelRejection(t *testing.T) { } // TestRequestCreation_TransferSend_Success verifies that a TransferSend transition -// on initial channel creation passes the action gateway and produces both -// sender-side and receiver-side state effects. Covers the happy path opposite -// TestRequestCreation_ActionGatewayRejection. +// on initial channel creation produces both sender-side and receiver-side state effects. func TestRequestCreation_TransferSend_Success(t *testing.T) { mockTxStore := new(MockStore) mockMemoryStore := new(MockMemoryStore) @@ -650,7 +642,6 @@ func TestRequestCreation_TransferSend_Success(t *testing.T) { maxChallenge: uint32(604800), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() @@ -783,107 +774,6 @@ func TestRequestCreation_TransferSend_Success(t *testing.T) { mockTxStore.AssertExpectations(t) } -// TestRequestCreation_ActionGatewayRejection verifies that an exhausted gated -// action (e.g. transfer_send) is rejected at the gateway before any state is -// signed, stored, or receiver-side state is issued. Mirrors the SubmitState -// gate so users cannot bypass the 24h allowance via initial channel creation. -func TestRequestCreation_ActionGatewayRejection(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{Err: errors.New("transfer allowance exhausted")}, - } - - userSigner := NewMockSigner() - userWallet := userSigner.PublicKey().Address().String() - receiverWallet := "0x0987654321098765432109876543210987654321" - asset := "USDC" - tokenAddress := "0xTokenAddress" - blockchainID := uint64(1) - nonce := uint64(77) - 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() - - 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{ - Type: core.TransitionTypeTransferSend, - AccountID: receiverWallet, - Amount: "100", - }, - HomeLedger: rpc.LedgerV1{ - TokenAddress: tokenAddress, - BlockchainID: "1", - UserBalance: "0", - UserNetFlow: "0", - 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(), "transfer allowance exhausted") - - // Gate fires before any state effect: nothing should be locked, stored, or recorded. - mockTxStore.AssertNotCalled(t, "LockUserState", mock.Anything, mock.Anything) - mockTxStore.AssertNotCalled(t, "HasNonClosedHomeChannel", mock.Anything, mock.Anything) - mockTxStore.AssertNotCalled(t, "GetLastUserState", mock.Anything, mock.Anything, mock.Anything) - mockTxStore.AssertNotCalled(t, "CreateChannel", mock.Anything) - mockTxStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) - mockTxStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) - mockMemoryStore.AssertExpectations(t) -} - // TestRequestCreation_RejectReusedHomeChannelID covers the case where the user's last // state has no HomeChannelID (e.g., it is a ChallengeRescue squash on a fresh epoch), // but the computed channel ID matches a previously stored channel record. The handler @@ -911,7 +801,6 @@ func TestRequestCreation_RejectReusedHomeChannelID(t *testing.T) { maxChallenge: uint32(604800), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() @@ -1028,7 +917,6 @@ func TestRequestCreation_ChannelAlreadyInitialized(t *testing.T) { maxChallenge: uint32(604800), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() diff --git a/nitronode/api/channel_v1/submit_session_key_state.go b/nitronode/api/channel_v1/submit_session_key_state.go index 5bd7db3f3..037acde60 100644 --- a/nitronode/api/channel_v1/submit_session_key_state.go +++ b/nitronode/api/channel_v1/submit_session_key_state.go @@ -2,6 +2,7 @@ package channel_v1 import ( "errors" + "fmt" "strings" "time" @@ -71,23 +72,62 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { c.Fail(rpc.Errorf("invalid_session_key_state: assets array exceeds maximum length of %d", h.maxSessionKeyIDs), "") return } - if coreState.UserSig == "" { - c.Fail(rpc.Errorf("invalid_session_key_state: user_sig is required"), "") + // An empty assets list authorizes no usable asset, so it is only meaningful as a + // revocation/deactivation state (expires_at <= now). Rejecting it for a future + // expires_at prevents an active current version that authorizes nothing. + if len(coreState.Assets) == 0 && coreState.ExpiresAt.After(time.Now()) { + c.Fail(rpc.Errorf("invalid_session_key_state: assets must be non-empty unless expires_at is in the past"), "") return } - if coreState.SessionKeySig == "" { - c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") + if err := h.validateSessionKeyAssets(coreState.Assets); err != nil { + c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") return } - - // 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), "") + if coreState.UserSig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: user_sig is required"), "") return } - // Validate version and store the session key state + // A submit with expires_at after now activates, extends, or rotates the key and requires + // both signatures. A submit with expires_at <= now is a revocation: it only deactivates an + // existing delegation, so the wallet's user_sig alone authorizes it. Requiring session_key_sig + // on the revocation path would let a lost, unavailable, or malicious session key veto its own + // revocation, stranding the user until the prior expires_at naturally passes. now is captured + // here and reused for the cap/version logic inside the transaction so the active/inactive + // decision is consistent across both. now := time.Now() + if coreState.ExpiresAt.After(now) { + if coreState.SessionKeySig == "" { + c.Fail(rpc.Errorf("invalid_session_key_state: session_key_sig is required"), "") + return + } + // 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 + } + } else { + // Revocation only deactivates an existing delegation. Version 1 means there is no prior + // delegation, so reject it before LockSessionKeyState can seed a permanent ownership + // claim for a session_key the caller never proved possession of: a legitimate revoke is + // always version >= 2, since registration at version 1 is future-dated and requires both + // signatures. + if coreState.Version == 1 { + c.Fail(rpc.Errorf("invalid_session_key_state: cannot revoke a session key with no prior delegation"), "") + return + } + // Validate only the wallet's user_sig. Any session_key_sig present is ignored, so clear + // it before persisting to keep stored revocation rows canonical. + if err := core.ValidateChannelSessionKeyStateUserSigV1(coreState); err != nil { + c.Fail(rpc.Errorf("invalid_session_key_state: %v", err), "") + return + } + coreState.SessionKeySig = "" + } + + // revoked is true only when this submit transitions an active key to inactive, + // so the revocation log is not emitted for inactive-to-inactive updates. + var revoked bool err = h.useStoreInTx(func(tx Store) error { // 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 @@ -125,6 +165,7 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { // (pg_advisory_xact_lock(hashtext(user_address))) before counting. prevActive := latestVersion > 0 && latestExpiresAt.After(now) submittedActive := coreState.ExpiresAt.After(now) + revoked = prevActive && !submittedActive if !prevActive && submittedActive && h.maxSessionKeysPerUser > 0 { count, err := tx.CountSessionKeysForUser(coreState.UserAddress) if err != nil { @@ -157,7 +198,7 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { } c.Succeed(c.Request.Method, payload) - if !coreState.ExpiresAt.After(now) { + if revoked { logger.Info("channel session key revoked", "userAddress", coreState.UserAddress, "sessionKey", coreState.SessionKey, @@ -170,3 +211,28 @@ func (h *Handler) SubmitSessionKeyState(c *rpc.Context) { "sessionKey", coreState.SessionKey, "version", coreState.Version) } + +// validateSessionKeyAssets rejects an assets list that contains a non-canonical asset, a +// duplicate, or an asset the node does not support. Assets must already be lowercased because +// they are persisted lowercased (StoreChannelSessionKeyState) and looked up lowercased +// (ValidateChannelSessionKeyForAsset); rejecting a non-canonical asset keeps the user-signed +// payload identical to what is stored. Empty assets lists are allowed; the per-asset checks +// only run on entries. +func (h *Handler) validateSessionKeyAssets(assets []string) error { + seen := make(map[string]struct{}, len(assets)) + for _, asset := range assets { + normalized := strings.ToLower(asset) + if asset != normalized { + return fmt.Errorf("non-canonical asset '%s', expected '%s'", asset, normalized) + } + if _, ok := seen[normalized]; ok { + return fmt.Errorf("duplicate asset '%s'", asset) + } + seen[normalized] = struct{}{} + + if _, err := h.memoryStore.GetAssetDecimals(normalized); err != nil { + return fmt.Errorf("unsupported asset '%s'", asset) + } + } + return nil +} 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 80ebcf0ca..462f2c970 100644 --- a/nitronode/api/channel_v1/submit_session_key_state_test.go +++ b/nitronode/api/channel_v1/submit_session_key_state_test.go @@ -59,6 +59,14 @@ func buildSignedChannelSessionKeyStateReq(t *testing.T, userAddress, sessionKey return rpc.ChannelsV1SubmitSessionKeyStateRequest{State: state} } +// allAssetsMemoryStore returns a MockMemoryStore that treats every asset as supported, so +// validateSessionKeyAssets passes for arbitrary symbols in these tests. +func allAssetsMemoryStore() *MockMemoryStore { + m := new(MockMemoryStore) + m.On("GetAssetDecimals", mock.AnythingOfType("string")).Return(uint8(6), nil) + return m +} + func TestChannelSubmitSessionKeyState_Success(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -70,12 +78,13 @@ func TestChannelSubmitSessionKeyState_Success(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - assets := []string{"USDC"} + assets := []string{"usdc"} reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) @@ -104,6 +113,7 @@ func TestChannelSubmitSessionKeyState_AssetsExceedsMax(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 2, } @@ -114,7 +124,7 @@ func TestChannelSubmitSessionKeyState_AssetsExceedsMax(t *testing.T) { UserAddress: "0x1111111111111111111111111111111111111111", SessionKey: "0x3333333333333333333333333333333333333333", Version: "1", - Assets: []string{"USDC", "ETH", "BTC"}, + Assets: []string{"usdc", "eth", "btc"}, ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), UserSig: "0xdeadbeef", }, @@ -136,6 +146,170 @@ func TestChannelSubmitSessionKeyState_AssetsExceedsMax(t *testing.T) { assert.Contains(t, respErr.Error(), "assets array exceeds maximum length of 2") } +func TestChannelSubmitSessionKeyState_RejectsDuplicateAssets(t *testing.T) { + mockStore := new(MockStore) + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + memoryStore: allAssetsMemoryStore(), + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + // Two identical assets must be rejected as a duplicate. + reqPayload := rpc.ChannelsV1SubmitSessionKeyStateRequest{ + State: rpc.ChannelSessionKeyStateV1{ + UserAddress: "0x1111111111111111111111111111111111111111", + SessionKey: "0x3333333333333333333333333333333333333333", + Version: "1", + Assets: []string{"usdc", "usdc"}, + ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), + UserSig: "0xdeadbeef", + }, + } + + 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(), "duplicate asset") +} + +func TestChannelSubmitSessionKeyState_RejectsNonCanonicalAsset(t *testing.T) { + mockStore := new(MockStore) + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + memoryStore: allAssetsMemoryStore(), + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + // Assets must be submitted lowercased; a non-canonical casing is rejected. + reqPayload := rpc.ChannelsV1SubmitSessionKeyStateRequest{ + State: rpc.ChannelSessionKeyStateV1{ + UserAddress: "0x1111111111111111111111111111111111111111", + SessionKey: "0x3333333333333333333333333333333333333333", + Version: "1", + Assets: []string{"USDC"}, + ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), + UserSig: "0xdeadbeef", + }, + } + + 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(), "non-canonical asset") +} + +func TestChannelSubmitSessionKeyState_RejectsUnsupportedAsset(t *testing.T) { + mockStore := new(MockStore) + mockMemoryStore := new(MockMemoryStore) + mockMemoryStore.On("GetAssetDecimals", "foo").Return(uint8(0), fmt.Errorf("asset 'foo' is not supported")) + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + memoryStore: mockMemoryStore, + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + reqPayload := rpc.ChannelsV1SubmitSessionKeyStateRequest{ + State: rpc.ChannelSessionKeyStateV1{ + UserAddress: "0x1111111111111111111111111111111111111111", + SessionKey: "0x3333333333333333333333333333333333333333", + Version: "1", + Assets: []string{"foo"}, + ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), + UserSig: "0xdeadbeef", + }, + } + + 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(), "unsupported asset") + mockMemoryStore.AssertExpectations(t) +} + +// An empty assets list authorizes no usable asset, so it is only meaningful as a +// revocation/deactivation state (expires_at <= now). Submitting an empty list with a future +// expires_at must be rejected so it can never become an active current version that +// authorizes nothing. The allowed past-expires_at revoke companion is +// TestChannelSubmitSessionKeyState_RevokeExistingActiveKey. +func TestChannelSubmitSessionKeyState_RejectsEmptyAssetsWithFutureExpiresAt(t *testing.T) { + mockStore := new(MockStore) + handler := &Handler{ + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockStore) + }, + memoryStore: allAssetsMemoryStore(), + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 10, + } + + reqPayload := rpc.ChannelsV1SubmitSessionKeyStateRequest{ + State: rpc.ChannelSessionKeyStateV1{ + UserAddress: "0x1111111111111111111111111111111111111111", + SessionKey: "0x3333333333333333333333333333333333333333", + Version: "1", + Assets: []string{}, + ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), + UserSig: "0xdeadbeef", + }, + } + + 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(), "assets must be non-empty unless expires_at is in the past") + // The submission is rejected before any store interaction. + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + func TestChannelSubmitSessionKeyState_AtMaxLimit(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -147,13 +321,14 @@ func TestChannelSubmitSessionKeyState_AtMaxLimit(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 2, } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) // Exactly at max (2) should pass validation - assets := []string{"USDC", "ETH"} + assets := []string{"usdc", "eth"} reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) @@ -181,6 +356,7 @@ func TestChannelSubmitSessionKeyState_InvalidUserAddress(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } @@ -212,7 +388,10 @@ func TestChannelSubmitSessionKeyState_InvalidUserAddress(t *testing.T) { assert.Contains(t, respErr.Error(), "invalid user_address") } -func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { +// A version-1 revoke has no prior delegation to deactivate; allowing it would let a wallet +// seed a permanent (session_key, kind) ownership claim for a key it never proved possession +// of. It must be rejected before LockSessionKeyState runs, so no seed row is ever written. +func TestChannelSubmitSessionKeyState_RevokeFirstSubmit_Rejected(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() userAddress := strings.ToLower(userSigner.PublicKey().Address().String()) @@ -223,20 +402,16 @@ func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } - // expires_at in the past expresses a revoke: the same monotonic version sequence - // is preserved, the auth path filters expires_at > now so the key is deactivated. expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) - assets := []string{"USDC"} + assets := []string{"usdc"} reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, assets, expiresAt, userSigner, sessionKeySigner) - mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) - mockStore.On("StoreChannelSessionKeyState", mock.AnythingOfType("core.ChannelSessionKeyStateV1")).Return(nil) - payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -248,8 +423,11 @@ func TestChannelSubmitSessionKeyState_RevokeWithPastExpiresAt(t *testing.T) { handler.SubmitSessionKeyState(ctx) require.NotNil(t, ctx.Response) - assert.Nil(t, ctx.Response.Error()) - mockStore.AssertExpectations(t) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "no prior delegation") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "StoreChannelSessionKeyState", mock.Anything) } // Covers the typical revocation path: an active key (latestVersion > 0, prev expires_at in @@ -267,13 +445,14 @@ func TestChannelSubmitSessionKeyState_RevokeExistingActiveKey(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, maxSessionKeysPerUser: 5, } expiresAt := time.Now().Add(-time.Hour).Truncate(time.Second) - assets := []string{"USDC"} + assets := []string{"usdc"} reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, assets, expiresAt, userSigner, sessionKeySigner) @@ -313,13 +492,14 @@ func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke_BelowCapAllowed(t *t useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, maxSessionKeysPerUser: 5, } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - assets := []string{"USDC"} + assets := []string{"usdc"} reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, assets, expiresAt, userSigner, sessionKeySigner) @@ -357,13 +537,14 @@ func TestChannelSubmitSessionKeyState_ReactivateAfterRevoke_AtCapRejected(t *tes useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, maxSessionKeysPerUser: 3, } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - assets := []string{"USDC"} + assets := []string{"usdc"} reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, assets, expiresAt, userSigner, sessionKeySigner) @@ -395,6 +576,7 @@ func TestChannelSubmitSessionKeyState_RejectsNegativeExpiresAt(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } @@ -433,6 +615,7 @@ func TestChannelSubmitSessionKeyState_MissingUserSig(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } @@ -442,7 +625,7 @@ func TestChannelSubmitSessionKeyState_MissingUserSig(t *testing.T) { UserAddress: "0x1111111111111111111111111111111111111111", SessionKey: "0x3333333333333333333333333333333333333333", Version: "1", - Assets: []string{}, + Assets: []string{"usdc"}, ExpiresAt: strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10), UserSig: "", }, @@ -475,6 +658,7 @@ func TestChannelSubmitSessionKeyState_VersionMismatch(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } @@ -482,7 +666,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, sessionKeySigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 3, []string{"usdc"}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) @@ -514,13 +698,14 @@ func TestChannelSubmitSessionKeyState_RejectsWhenAtUserCap(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, maxSessionKeysPerUser: 3, } expiresAt := time.Now().Add(24 * time.Hour).Truncate(time.Second) - reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"USDC"}, expiresAt, userSigner, sessionKeySigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"usdc"}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(0, time.Time{}, nil) mockStore.On("CountSessionKeysForUser", userAddress).Return(3, nil) @@ -554,6 +739,7 @@ func TestChannelSubmitSessionKeyState_AllowsUpdateForExistingKeyAtCap(t *testing useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, maxSessionKeysPerUser: 3, @@ -561,7 +747,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, sessionKeySigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 5, []string{"usdc"}, expiresAt, userSigner, sessionKeySigner) prevActiveExpiresAt := time.Now().Add(24 * time.Hour) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(4, prevActiveExpiresAt, nil) @@ -595,6 +781,7 @@ func TestChannelSubmitSessionKeyState_SignatureMismatch(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 10, } @@ -602,7 +789,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, sessionKeySigner) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"usdc"}, expiresAt, differentSigner, sessionKeySigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -629,12 +816,13 @@ func TestChannelSubmitSessionKeyState_RejectsUserAddressEqualsSessionKey(t *test useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), 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) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, userAddress, 1, []string{"usdc"}, expiresAt, userSigner, userSigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -663,13 +851,14 @@ func TestChannelSubmitSessionKeyState_RejectsMissingSessionKeySig(t *testing.T) useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), 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) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"usdc"}, expiresAt, userSigner, nil) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -699,13 +888,14 @@ func TestChannelSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing. useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), 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) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"usdc"}, expiresAt, userSigner, otherSigner) payload, err := rpc.NewPayload(reqPayload) require.NoError(t, err) @@ -723,6 +913,131 @@ func TestChannelSubmitSessionKeyState_RejectsMismatchedSessionKeySig(t *testing. mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) } +// Wallet-only revocation: a submit with a past expires_at and NO session_key_sig is accepted +// on the strength of user_sig alone. This is the core remediation — a lost, unavailable, or +// uncooperative session key can no longer veto revocation of its own delegation. +func TestChannelSubmitSessionKeyState_RevokeUserSigOnly_Succeeds(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(-time.Hour).Truncate(time.Second) + // keySigner=nil → SessionKeySig stays empty; the revocation path must not require it. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, []string{}, expiresAt, userSigner, nil) + + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(1, prevActiveExpiresAt, 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) +} + +// On the revocation path a present-but-mismatched session_key_sig is ignored, not validated. +// The same signature would be rejected on the active path (see RejectsMismatchedSessionKeySig). +func TestChannelSubmitSessionKeyState_RevokeIgnoresMismatchedSessionKeySig(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(-time.Hour).Truncate(time.Second) + // SessionKeySig signed by an unrelated key — would fail the active path, ignored on revoke. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, []string{}, expiresAt, userSigner, otherSigner) + + prevActiveExpiresAt := time.Now().Add(24 * time.Hour) + mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel).Return(1, prevActiveExpiresAt, nil) + // The ignored session_key_sig must be cleared before persisting so stored revocation + // rows never retain unverified client input. + mockStore.On("StoreChannelSessionKeyState", mock.MatchedBy(func(s core.ChannelSessionKeyStateV1) bool { + return s.SessionKeySig == "" + })).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) +} + +// Even on the revocation path the wallet's user_sig must be valid: a revoke signed by a key +// other than the user_address is rejected, so revocation stays a wallet-only right (not anyone's). +func TestChannelSubmitSessionKeyState_RevokeInvalidUserSig_Rejected(t *testing.T) { + mockStore := new(MockStore) + userSigner := NewMockSigner() + differentSigner := 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(-time.Hour).Truncate(time.Second) + // user_sig produced by a key other than userAddress; no session_key_sig. + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 2, []string{}, expiresAt, differentSigner, 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(), "does not match wallet") + mockStore.AssertNotCalled(t, "LockSessionKeyState", mock.Anything, mock.Anything, mock.Anything) +} + func TestChannelSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { mockStore := new(MockStore) userSigner := NewMockSigner() @@ -734,12 +1049,13 @@ func TestChannelSubmitSessionKeyState_RejectsForeignOwner(t *testing.T) { useStoreInTx: func(handler StoreTxHandler) error { return handler(mockStore) }, + memoryStore: allAssetsMemoryStore(), 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) + reqPayload := buildSignedChannelSessionKeyStateReq(t, userAddress, sessionKeyAddress, 1, []string{"usdc"}, expiresAt, userSigner, sessionKeySigner) mockStore.On("LockSessionKeyState", userAddress, sessionKeyAddress, database.SessionKeyKindChannel). Return(0, time.Time{}, database.ErrSessionKeyNotAllowed) diff --git a/nitronode/api/channel_v1/submit_state.go b/nitronode/api/channel_v1/submit_state.go index b5fa1e349..9887d8a4b 100644 --- a/nitronode/api/channel_v1/submit_state.go +++ b/nitronode/api/channel_v1/submit_state.go @@ -38,15 +38,19 @@ func (h *Handler) SubmitState(c *rpc.Context) { var nodeSig string incomingTransition := incomingState.Transition + var receiverWallet string err = h.useStoreInTx(func(tx Store) error { - err := h.actionGateway.AllowAction(tx, incomingState.UserWallet, incomingState.Transition.Type.GatedAction()) - if err != nil { - return rpc.NewError(err) - } - - _, err = tx.LockUserState(incomingState.UserWallet, incomingState.Asset) - if err != nil { - return rpc.Errorf("failed to lock user state: %v", err) + if incomingTransition.Type == core.TransitionTypeTransferSend { + // Lock both sender and receiver balance rows up front in deterministic + // order so concurrent opposite-direction transfers can't deadlock. + receiverWallet, err = h.lockTransferBalances(tx, incomingState) + if err != nil { + return err + } + } else { + if _, err := tx.LockUserState(incomingState.UserWallet, incomingState.Asset); err != nil { + return rpc.Errorf("failed to lock user state: %v", err) + } } approvedSigValidators, channelStatus, err := tx.CheckActiveChannel(incomingState.UserWallet, incomingState.Asset) @@ -99,10 +103,12 @@ func (h *Handler) SubmitState(c *rpc.Context) { // extraTransitions = nil // no extra transitions to reapply // } default: - // User has no previous state + // An active home channel always has its initial state stored atomically by + // request_creation. A nil state here means the channel row exists without its + // state — an invariant violation, not a first-submit. Fail closed rather than + // bootstrapping a synthetic Void state through the wrong endpoint. if currentState == nil { - logger.Debug("no previous state found, issuing a void state") - currentState = core.NewVoidState(incomingState.Asset, incomingState.UserWallet) + return rpc.Errorf("active channel has no stored state") } } @@ -121,7 +127,7 @@ func (h *Handler) SubmitState(c *rpc.Context) { // Validate user's signature if incomingState.UserSig == nil { - return rpc.Errorf("missing incoming state user signature: %v", err) + return rpc.Errorf("missing incoming state user signature") } userSigBytes, err := hexutil.Decode(*incomingState.UserSig) if err != nil { @@ -167,7 +173,7 @@ func (h *Handler) SubmitState(c *rpc.Context) { } case core.TransitionTypeTransferSend: - newReceiverState, err := h.issueTransferReceiverState(ctx, tx, incomingState, applicationID) + newReceiverState, err := h.issueTransferReceiverState(ctx, tx, incomingState, receiverWallet, applicationID) if err != nil { return rpc.Errorf("failed to issue receiver state: %v", err) } diff --git a/nitronode/api/channel_v1/submit_state_test.go b/nitronode/api/channel_v1/submit_state_test.go index fb2ff4d38..8a0a6e579 100644 --- a/nitronode/api/channel_v1/submit_state_test.go +++ b/nitronode/api/channel_v1/submit_state_test.go @@ -51,6 +51,84 @@ func TestSubmitState_InvalidUserWallet_Rejected(t *testing.T) { mockTxStore.AssertNotCalled(t, "LockUserState", mock.Anything, mock.Anything) } +func TestSubmitState_ActiveChannelMissingState_Rejected(t *testing.T) { + // An active home channel always has its initial state stored by request_creation. + // If CheckActiveChannel reports an active channel but GetLastUserState returns nil, + // SubmitState must fail closed rather than bootstrapping a synthetic Void state. + mockTxStore := new(MockStore) + mockMemoryStore := new(MockMemoryStore) + mockAssetStore := new(MockAssetStore) + mockSigner := NewMockSigner() + nodeSigner, _ := core.NewChannelDefaultSigner(mockSigner) + mockStatePacker := new(MockStatePacker) + + handler := &Handler{ + stateAdvancer: core.NewStateAdvancerV1(mockAssetStore), + statePacker: mockStatePacker, + useStoreInTx: func(handler StoreTxHandler) error { + return handler(mockTxStore) + }, + memoryStore: mockMemoryStore, + nodeSigner: nodeSigner, + nodeAddress: mockSigner.PublicKey().Address().String(), + minChallenge: uint32(3600), + metrics: metrics.NewNoopRuntimeMetricExporter(), + maxSessionKeyIDs: 256, + } + + userSigner := NewMockSigner() + senderWallet := userSigner.PublicKey().Address().String() + receiverWallet := "0x0987654321098765432109876543210987654321" + asset := "USDC" + homeChannelID := "0xHomeChannel123" + + currentState := core.State{ + ID: core.GetStateID(senderWallet, asset, 1, 1), + Asset: asset, + UserWallet: senderWallet, + 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.ApplyTransferSendTransition(receiverWallet, decimal.NewFromInt(100)) + require.NoError(t, err) + + // Reports an active channel, but no stored state exists for the (wallet, asset) slot. + // Both sender and receiver balances are locked in deterministic order (MF3-L18). + mockTxStore.On("LockUserState", senderWallet, asset).Return(decimal.Zero, nil) + mockTxStore.On("LockUserState", receiverWallet, asset).Return(decimal.Zero, nil) + mockTxStore.On("CheckActiveChannel", senderWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) + mockTxStore.On("GetLastUserState", senderWallet, asset, false).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) + + require.NotNil(t, ctx.Response) + respErr := ctx.Response.Error() + require.NotNil(t, respErr) + assert.Contains(t, respErr.Error(), "active channel has no stored state") + // Failed before signing/persistence. + mockTxStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) + mockTxStore.AssertExpectations(t) +} + func TestSubmitState_TransferSend_Success(t *testing.T) { // Setup mockTxStore := new(MockStore) @@ -78,7 +156,6 @@ func TestSubmitState_TransferSend_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive senderWallet from a user signer key @@ -257,7 +334,6 @@ func TestSubmitState_TransferSend_ReceiverHomeChannelChallenged_NoNodeSig(t *tes minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() @@ -386,7 +462,6 @@ func TestSubmitState_TransferSend_ReceiverWithEscrowLock_Rejected(t *testing.T) minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive senderWallet from a user signer key @@ -529,7 +604,6 @@ func TestSubmitState_TransferSend_SameWalletCaseInsensitive_Rejected(t *testing. minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Derive senderWallet from a real key — Address().String() returns checksummed (mixed case) @@ -573,18 +647,17 @@ func TestSubmitState_TransferSend_SameWalletCaseInsensitive_Rejected(t *testing. userSigStr := userSig.String() incomingSenderState.UserSig = &userSigStr - // 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("CheckActiveChannel", senderWallet, asset).Return("0x03", core.ChannelStatusOpen, nil) - mockTxStore.On("GetLastUserState", senderWallet, asset, false).Return(currentSenderState, nil) - mockTxStore.On("EnsureNoOngoingStateTransitions", senderWallet, asset).Return(nil) + // Mock expectations. The self-transfer check now runs inside lockTransferBalances, + // before any balance row is locked or any state is stored, so none of the store + // methods below should be reached. Marked Maybe() to document that they are + // deliberately not expected. + mockAssetStore.On("GetAssetDecimals", asset).Return(uint8(6), nil).Maybe() + mockTxStore.On("LockUserState", mock.Anything, asset).Return(decimal.Zero, nil).Maybe() + mockTxStore.On("CheckActiveChannel", senderWallet, asset).Return("0x03", core.ChannelStatusOpen, nil).Maybe() + mockTxStore.On("GetLastUserState", senderWallet, asset, false).Return(currentSenderState, nil).Maybe() + mockTxStore.On("EnsureNoOngoingStateTransitions", senderWallet, asset).Return(nil).Maybe() mockStatePacker.On("PackState", mock.Anything).Return(packedSenderState, nil).Maybe() - - // Sender state is stored before the transition-specific logic - mockTxStore.On("StoreUserState", mock.MatchedBy(func(state core.State) bool { - return state.UserWallet == senderWallet && state.NodeSig != nil - }), mock.Anything).Return(nil) + mockTxStore.On("StoreUserState", mock.Anything, mock.Anything).Return(nil).Maybe() rpcState := toRPCState(*incomingSenderState) reqPayload := rpc.ChannelsV1SubmitStateRequest{ @@ -642,7 +715,6 @@ func TestSubmitState_EscrowLock_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -801,7 +873,6 @@ func TestSubmitState_EscrowWithdraw_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -958,7 +1029,6 @@ func TestSubmitState_HomeDeposit_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -1093,7 +1163,6 @@ func TestSubmitState_HomeWithdrawal_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -1230,7 +1299,6 @@ func TestSubmitState_MutualLock_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -1389,7 +1457,6 @@ func TestSubmitState_EscrowDeposit_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -1548,7 +1615,6 @@ func TestSubmitState_Finalize_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -1699,7 +1765,6 @@ func TestSubmitState_Acknowledgement_Success(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } // Test data - derive userWallet from a user signer key @@ -1820,7 +1885,6 @@ func TestSubmitState_MutualLock_VoidHomeChannel_Rejected(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() @@ -1911,7 +1975,6 @@ func TestSubmitState_EscrowLock_VoidHomeChannel_Rejected(t *testing.T) { minChallenge: minChallenge, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() @@ -2004,7 +2067,6 @@ func TestSubmitState_ClosingChannel_Rejected(t *testing.T) { minChallenge: 3600, metrics: metrics.NewNoopRuntimeMetricExporter(), maxSessionKeyIDs: 256, - actionGateway: &MockActionGateway{}, } userSigner := NewMockSigner() diff --git a/nitronode/api/channel_v1/testing.go b/nitronode/api/channel_v1/testing.go index fed42034c..c9726520c 100644 --- a/nitronode/api/channel_v1/testing.go +++ b/nitronode/api/channel_v1/testing.go @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/mock" "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" @@ -160,34 +159,6 @@ func (m *MockStore) ValidateChannelSessionKeyForAsset(wallet, sessionKey, asset, return args.Bool(0), args.Error(1) } -func (m *MockStore) GetAppCount(ownerWallet string) (uint64, error) { - args := m.Called(ownerWallet) - return args.Get(0).(uint64), args.Error(1) -} - -func (m *MockStore) GetTotalUserStaked(wallet string) (decimal.Decimal, error) { - args := m.Called(wallet) - return args.Get(0).(decimal.Decimal), args.Error(1) -} - -func (m *MockStore) RecordAction(wallet string, gatedAction core.GatedAction) error { - args := m.Called(wallet, gatedAction) - return args.Error(0) -} - -func (m *MockStore) GetUserActionCount(wallet string, gatedAction core.GatedAction, window time.Duration) (uint64, error) { - args := m.Called(wallet, gatedAction, window) - return args.Get(0).(uint64), args.Error(1) -} - -func (m *MockStore) GetUserActionCounts(userWallet string, window time.Duration) (map[core.GatedAction]uint64, error) { - args := m.Called(userWallet, window) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(map[core.GatedAction]uint64), args.Error(1) -} - func NewMockSigner() sign.Signer { key, _ := crypto.GenerateKey() @@ -249,14 +220,6 @@ func (m *MockStatePacker) PackState(state core.State) ([]byte, error) { return args.Get(0).([]byte), args.Error(1) } -type MockActionGateway struct { - Err error -} - -func (m *MockActionGateway) AllowAction(_ action_gateway.Store, _ string, _ core.GatedAction) error { - return m.Err -} - // VerifyNodeSignature verifies that a hex-encoded channel signature was produced by the expected node address. func VerifyNodeSignature(t *testing.T, nodeAddr string, data []byte, sigHex string) { t.Helper() diff --git a/nitronode/api/node_v1/utils.go b/nitronode/api/node_v1/utils.go index 378dd5545..c49919843 100644 --- a/nitronode/api/node_v1/utils.go +++ b/nitronode/api/node_v1/utils.go @@ -10,10 +10,10 @@ import ( func mapBlockchainV1(blockchain core.Blockchain) rpc.BlockchainInfoV1 { return rpc.BlockchainInfoV1{ - Name: blockchain.Name, - BlockchainID: strconv.FormatUint(blockchain.ID, 10), - ChannelHubAddress: blockchain.ChannelHubAddress, - LockingContractAddress: blockchain.LockingContractAddress, + Name: blockchain.Name, + BlockchainID: strconv.FormatUint(blockchain.ID, 10), + ChannelHubAddress: blockchain.ChannelHubAddress, + ConfirmationDelaySecs: blockchain.ConfirmationDelaySecs, } } diff --git a/nitronode/api/rate_limits.go b/nitronode/api/rate_limits.go deleted file mode 100644 index 33577b302..000000000 --- a/nitronode/api/rate_limits.go +++ /dev/null @@ -1,59 +0,0 @@ -package api - -import ( - "time" - - "github.com/layer-3/nitrolite/pkg/rpc" -) - -// 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 { - tokens float64 - last time.Time -} - -// 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 := loadOrInitBucket(c, r.rateLimitBurst) - - now := time.Now() - 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(rpc.Errorf("rate limit exceeded"), "") - return - } - bucket.tokens-- - - c.Next() -} - -// loadOrInitBucket returns the bucket stored on the connection, allocating a -// fresh one pre-filled to burst on first use. The bucket is stored as a -// pointer; later mutations are visible without re-Set. -func loadOrInitBucket(c *rpc.Context, burst float64) *tokenBucket { - if v, ok := c.Storage.Get(rateLimitStorageKey); ok { - if b, ok := v.(*tokenBucket); ok { - return b - } - } - b := &tokenBucket{tokens: burst, last: time.Now()} - c.Storage.Set(rateLimitStorageKey, b) - return b -} diff --git a/nitronode/api/rate_limits_test.go b/nitronode/api/rate_limits_test.go deleted file mode 100644 index 45cb7c299..000000000 --- a/nitronode/api/rate_limits_test.go +++ /dev/null @@ -1,161 +0,0 @@ -package api - -import ( - "testing" - "time" - - "github.com/layer-3/nitrolite/pkg/rpc" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRateLimitMiddleware(t *testing.T) { - t.Parallel() - - newTestRouter := func(ratePerSec, burst float64) *RPCRouter { - return &RPCRouter{ - rateLimitPerSec: ratePerSec, - rateLimitBurst: burst, - } - } - - newTestContext := func(storage *rpc.SafeStorage, requestID uint64) *rpc.Context { - return &rpc.Context{ - Storage: storage, - Request: rpc.Message{RequestID: requestID}, - } - } - - isRateLimited := func(ctx *rpc.Context) bool { - err := ctx.Response.Error() - return err != nil && err.Error() == "rate limit exceeded" - } - - t.Run("allows requests within burst limit", func(t *testing.T) { - t.Parallel() - - router := newTestRouter(10, 5) - storage := rpc.NewSafeStorage() - - var allowed int - for i := 0; i < 5; i++ { - ctx := newTestContext(storage, uint64(i)) - router.RateLimitMiddleware(ctx) - if !isRateLimited(ctx) { - allowed++ - } - } - - assert.Equal(t, 5, allowed, "all requests within burst should be allowed") - }) - - t.Run("blocks requests exceeding burst", func(t *testing.T) { - t.Parallel() - - router := newTestRouter(10, 3) - storage := rpc.NewSafeStorage() - - var allowed, rateLimited int - for i := 0; i < 5; i++ { - ctx := newTestContext(storage, uint64(i)) - router.RateLimitMiddleware(ctx) - if isRateLimited(ctx) { - rateLimited++ - } else { - allowed++ - } - } - - assert.Equal(t, 3, allowed, "only burst amount of requests should be allowed") - assert.Equal(t, 2, rateLimited, "excess requests should be rate limited") - }) - - t.Run("returns rate limit error message", func(t *testing.T) { - t.Parallel() - - router := newTestRouter(10, 1) - storage := rpc.NewSafeStorage() - - // First request - allowed - ctx1 := newTestContext(storage, 1) - router.RateLimitMiddleware(ctx1) - require.False(t, isRateLimited(ctx1), "first request should be allowed") - - // Second request - should be rate limited - ctx2 := newTestContext(storage, 2) - router.RateLimitMiddleware(ctx2) - - require.True(t, isRateLimited(ctx2), "second request should be rate limited") - assert.Equal(t, "rate limit exceeded", ctx2.Response.Error().Error()) - }) - - t.Run("tokens refill over time", func(t *testing.T) { - t.Parallel() - - router := newTestRouter(100, 2) // 100 tokens per second = 1 token per 10ms - storage := rpc.NewSafeStorage() - - // Exhaust the bucket - for i := 0; i < 2; i++ { - ctx := newTestContext(storage, uint64(i)) - router.RateLimitMiddleware(ctx) - require.False(t, isRateLimited(ctx), "initial requests should be allowed") - } - - // This should be rate limited - ctx := newTestContext(storage, 100) - router.RateLimitMiddleware(ctx) - require.True(t, isRateLimited(ctx), "should be rate limited after exhausting bucket") - - // Wait for tokens to refill (need 1 token, at 100/sec = 10ms per token) - time.Sleep(15 * time.Millisecond) - - // Now it should work - ctx = newTestContext(storage, 101) - router.RateLimitMiddleware(ctx) - assert.False(t, isRateLimited(ctx), "should be allowed after refill") - }) - - t.Run("separate storage has separate buckets", func(t *testing.T) { - t.Parallel() - - router := newTestRouter(10, 2) - storage1 := rpc.NewSafeStorage() - storage2 := rpc.NewSafeStorage() - - // Exhaust storage1's bucket - for i := 0; i < 2; i++ { - ctx := newTestContext(storage1, uint64(i)) - router.RateLimitMiddleware(ctx) - } - - // storage1 should be rate limited - ctx1 := newTestContext(storage1, 100) - router.RateLimitMiddleware(ctx1) - assert.True(t, isRateLimited(ctx1), "storage1 should be rate limited") - - // storage2 should still have its own bucket - ctx2 := newTestContext(storage2, 200) - router.RateLimitMiddleware(ctx2) - assert.False(t, isRateLimited(ctx2), "storage2 should have its own bucket") - }) - - t.Run("bucket persists in storage", func(t *testing.T) { - t.Parallel() - - router := newTestRouter(10, 5) - storage := rpc.NewSafeStorage() - - // Make one request - ctx := newTestContext(storage, 1) - router.RateLimitMiddleware(ctx) - - // Check bucket is stored - val, ok := storage.Get(rateLimitStorageKey) - require.True(t, ok, "bucket should be stored") - - bucket, ok := val.(*tokenBucket) - require.True(t, ok, "stored value should be a tokenBucket") - assert.Less(t, bucket.tokens, 5.0, "tokens should have been consumed") - }) -} diff --git a/nitronode/api/rpc_router.go b/nitronode/api/rpc_router.go index a4b160a9d..69822d9c5 100644 --- a/nitronode/api/rpc_router.go +++ b/nitronode/api/rpc_router.go @@ -3,9 +3,7 @@ package api import ( "time" - "github.com/layer-3/nitrolite/nitronode/action_gateway" "github.com/layer-3/nitrolite/nitronode/api/app_session_v1" - "github.com/layer-3/nitrolite/nitronode/api/apps_v1" "github.com/layer-3/nitrolite/nitronode/api/channel_v1" "github.com/layer-3/nitrolite/nitronode/api/node_v1" "github.com/layer-3/nitrolite/nitronode/api/user_v1" @@ -22,26 +20,16 @@ type RPCRouter struct { Node rpc.Node lg log.Logger runtimeMetrics metrics.RuntimeMetricExporter - - rateLimitPerSec float64 - rateLimitBurst float64 } type RPCRouterConfig struct { - NodeVersion string - MinChallenge uint32 - MaxChallenge uint32 - - AppRegistryEnabled bool - MaxParticipants int - MaxSessionDataLen int - MaxAppMetadataLen int - MaxRebalanceSignedUpdates int - MaxSessionKeyIDs int - MaxSessionKeysPerUser int - - RateLimitPerSec float64 - RateLimitBurst float64 + NodeVersion string + MinChallenge uint32 + MaxChallenge uint32 + MaxParticipants int + MaxSessionDataLen int + MaxSessionKeyIDs int + MaxSessionKeysPerUser int } func NewRPCRouter( @@ -50,20 +38,16 @@ func NewRPCRouter( signer sign.Signer, dbStore database.DatabaseStore, memoryStore memory.MemoryStore, - actionGateway action_gateway.ActionAllower, runtimeMetrics metrics.RuntimeMetricExporter, logger log.Logger, ) *RPCRouter { r := &RPCRouter{ - Node: node, - lg: logger.WithName("rpc-router"), - runtimeMetrics: runtimeMetrics, - rateLimitPerSec: cfg.RateLimitPerSec, - rateLimitBurst: cfg.RateLimitBurst, + Node: node, + lg: logger.WithName("rpc-router"), + runtimeMetrics: runtimeMetrics, } r.Node.Use(r.ObservabilityMiddleware) - r.Node.Use(r.RateLimitMiddleware) // Transaction wrapper helpers for each store type. // wrapWithMetrics executes fn inside a DB transaction with a metricStore wrapper, @@ -85,9 +69,6 @@ func NewRPCRouter( useAppSessionV1StoreInTx := func(h app_session_v1.StoreTxHandler) error { return wrapWithMetrics(func(ms *metricStore) error { return h(ms) }) } - useAppV1StoreInTx := func(h apps_v1.StoreTxHandler) error { - return wrapWithMetrics(func(ms *metricStore) error { return h(ms) }) - } useUserV1StoreInTx := func(h user_v1.StoreTxHandler) error { return wrapWithMetrics(func(ms *metricStore) error { return h(ms) }) } @@ -102,12 +83,11 @@ 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, 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.MaxSessionKeysPerUser) - appsV1Handler := apps_v1.NewHandler(dbStore, useAppV1StoreInTx, actionGateway, cfg.MaxAppMetadataLen) + channelV1Handler := channel_v1.NewHandler(useChannelV1StoreInTx, memoryStore, nodeChannelSigner, stateAdvancer, statePacker, nodeAddress, cfg.MinChallenge, cfg.MaxChallenge, runtimeMetrics, cfg.MaxSessionKeyIDs, cfg.MaxSessionKeysPerUser) + appSessionV1Handler := app_session_v1.NewHandler(useAppSessionV1StoreInTx, memoryStore, nodeChannelSigner, stateAdvancer, statePacker, nodeAddress, runtimeMetrics, + cfg.MaxParticipants, cfg.MaxSessionDataLen, cfg.MaxSessionKeyIDs, cfg.MaxSessionKeysPerUser) nodeV1Handler := node_v1.NewHandler(memoryStore, nodeAddress, cfg.NodeVersion) - userV1Handler := user_v1.NewHandler(dbStore, useUserV1StoreInTx, actionGateway) + userV1Handler := user_v1.NewHandler(dbStore, useUserV1StoreInTx) appSessionV1Group := r.Node.NewGroup(rpc.AppSessionsV1Group.String()) appSessionV1Group.Handle(rpc.AppSessionsV1SubmitDepositStateMethod.String(), appSessionV1Handler.SubmitDepositState) @@ -117,9 +97,6 @@ func NewRPCRouter( appSessionV1Group.Handle(rpc.AppSessionsV1GetAppSessionsMethod.String(), appSessionV1Handler.GetAppSessions) appSessionV1Group.Handle(rpc.AppSessionsV1SubmitSessionKeyStateMethod.String(), appSessionV1Handler.SubmitSessionKeyState) appSessionV1Group.Handle(rpc.AppSessionsV1GetLastKeyStatesMethod.String(), appSessionV1Handler.GetLastKeyStates) - if cfg.MaxRebalanceSignedUpdates >= 2 { - appSessionV1Group.Handle(rpc.AppSessionsV1RebalanceAppSessionsMethod.String(), appSessionV1Handler.RebalanceAppSessions) - } channelV1Group := r.Node.NewGroup(rpc.ChannelV1Group.String()) channelV1Group.Handle(rpc.ChannelsV1GetChannelsMethod.String(), channelV1Handler.GetChannels) @@ -136,19 +113,9 @@ func NewRPCRouter( nodeV1Group.Handle(rpc.NodeV1GetAssetsMethod.String(), nodeV1Handler.GetAssets) nodeV1Group.Handle(rpc.NodeV1GetConfigMethod.String(), nodeV1Handler.GetConfig) - appsV1Group := r.Node.NewGroup(rpc.AppsV1Group.String()) - if !cfg.AppRegistryEnabled { - appsV1Group.Use(func(c *rpc.Context) { - c.Fail(nil, "apps.v1 group is disabled") - }) - } - appsV1Group.Handle(rpc.AppsV1GetAppsMethod.String(), appsV1Handler.GetApps) - appsV1Group.Handle(rpc.AppsV1SubmitAppVersionMethod.String(), appsV1Handler.SubmitAppVersion) - userV1Group := r.Node.NewGroup(rpc.UserV1Group.String()) userV1Group.Handle(rpc.UserV1GetBalancesMethod.String(), userV1Handler.GetBalances) userV1Group.Handle(rpc.UserV1GetTransactionsMethod.String(), userV1Handler.GetTransactions) - userV1Group.Handle(rpc.UserV1GetActionAllowancesMethod.String(), userV1Handler.GetActionAllowances) // Pre-publish per-method metric series at 0 so dashboards and absent()-style // alerts have defined values before any traffic arrives. Must run after all diff --git a/nitronode/api/user_v1/get_action_allowances.go b/nitronode/api/user_v1/get_action_allowances.go deleted file mode 100644 index 5fa193a33..000000000 --- a/nitronode/api/user_v1/get_action_allowances.go +++ /dev/null @@ -1,59 +0,0 @@ -package user_v1 - -import ( - "strconv" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/rpc" -) - -// GetActionAllowances retrieves the action allowances for a user. -func (h *Handler) GetActionAllowances(c *rpc.Context) { - var req rpc.UserV1GetActionAllowancesRequest - if err := c.Request.Payload.Translate(&req); err != nil { - c.Fail(err, "failed to parse parameters") - return - } - - if req.Wallet == "" { - c.Fail(nil, "wallet is required") - return - } - - var allowances []core.ActionAllowance - err := h.useStoreInTx(func(tx Store) error { - var err error - allowances, err = h.actionGateway.GetUserAllowances(h.store, req.Wallet) - if err != nil { - return rpc.Errorf("failed to retrieve action allowances: %w", err) - } - - return nil - }) - if err != nil { - c.Fail(err, "failed to retrieve action allowances") - return - } - - rpcAllowances := make([]rpc.ActionAllowanceV1, len(allowances)) - for i, a := range allowances { - rpcAllowances[i] = rpc.ActionAllowanceV1{ - GatedAction: a.GatedAction, - TimeWindow: a.TimeWindow, - Allowance: strconv.FormatUint(a.Allowance, 10), - Used: strconv.FormatUint(a.Used, 10), - } - } - - response := rpc.UserV1GetActionAllowancesResponse{ - Allowances: rpcAllowances, - } - - payload, err := rpc.NewPayload(response) - if err != nil { - c.Fail(err, "failed to create response") - return - } - - c.Succeed(c.Request.Method, payload) -} diff --git a/nitronode/api/user_v1/get_action_allowances_test.go b/nitronode/api/user_v1/get_action_allowances_test.go deleted file mode 100644 index 37cbcfac3..000000000 --- a/nitronode/api/user_v1/get_action_allowances_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package user_v1 - -import ( - "context" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/rpc" -) - -func TestGetActionAllowances_Success(t *testing.T) { - mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler( - mockStore, - storeTxProvider, - &MockActionGateway{ - Allowances: []core.ActionAllowance{ - {GatedAction: core.GatedActionTransfer, TimeWindow: "24h", Allowance: 100, Used: 5}, - {GatedAction: core.GatedActionAppSessionCreation, TimeWindow: "24h", Allowance: 50, Used: 0}, - }, - }, - ) - - reqPayload := rpc.UserV1GetActionAllowancesRequest{ - Wallet: "0x1234567890123456789012345678901234567890", - } - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "user.v1.get_action_allowances", payload), - } - - handler.GetActionAllowances(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) - - var response rpc.UserV1GetActionAllowancesResponse - err = ctx.Response.Payload.Translate(&response) - require.NoError(t, err) - - assert.Len(t, response.Allowances, 2) - assert.Equal(t, core.GatedActionTransfer, response.Allowances[0].GatedAction) - assert.Equal(t, "24h", response.Allowances[0].TimeWindow) - assert.Equal(t, "100", response.Allowances[0].Allowance) - assert.Equal(t, "5", response.Allowances[0].Used) - assert.Equal(t, core.GatedActionAppSessionCreation, response.Allowances[1].GatedAction) - assert.Equal(t, "50", response.Allowances[1].Allowance) - assert.Equal(t, "0", response.Allowances[1].Used) -} - -func TestGetActionAllowances_EmptyResult(t *testing.T) { - mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler(mockStore, storeTxProvider, &MockActionGateway{}) - - reqPayload := rpc.UserV1GetActionAllowancesRequest{ - Wallet: "0x1234567890123456789012345678901234567890", - } - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "user.v1.get_action_allowances", payload), - } - - handler.GetActionAllowances(ctx) - - require.NotNil(t, ctx.Response) - require.NoError(t, ctx.Response.Error()) - - var response rpc.UserV1GetActionAllowancesResponse - err = ctx.Response.Payload.Translate(&response) - require.NoError(t, err) - - assert.Empty(t, response.Allowances) -} - -func TestGetActionAllowances_MissingWallet(t *testing.T) { - mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler(mockStore, storeTxProvider, &MockActionGateway{}) - - reqPayload := rpc.UserV1GetActionAllowancesRequest{} - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "user.v1.get_action_allowances", payload), - } - - handler.GetActionAllowances(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "wallet is required") -} - -func TestGetActionAllowances_GatewayError(t *testing.T) { - mockStore := new(MockStore) - storeTxProvider := func(fn StoreTxHandler) error { - return fn(mockStore) - } - - handler := NewHandler(mockStore, storeTxProvider, &MockActionGateway{ - Err: fmt.Errorf("gateway failure"), - }) - - reqPayload := rpc.UserV1GetActionAllowancesRequest{ - Wallet: "0x1234567890123456789012345678901234567890", - } - payload, err := rpc.NewPayload(reqPayload) - require.NoError(t, err) - - ctx := &rpc.Context{ - Context: context.Background(), - Request: rpc.NewRequest(1, "user.v1.get_action_allowances", payload), - } - - handler.GetActionAllowances(ctx) - - require.NotNil(t, ctx.Response) - err = ctx.Response.Error() - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to retrieve action allowances") -} diff --git a/nitronode/api/user_v1/get_transactions_test.go b/nitronode/api/user_v1/get_transactions_test.go index 0703dab1c..c69972fe1 100644 --- a/nitronode/api/user_v1/get_transactions_test.go +++ b/nitronode/api/user_v1/get_transactions_test.go @@ -119,6 +119,89 @@ func TestGetTransactions_Success(t *testing.T) { mockStore.AssertExpectations(t) } +// TestGetTransactions_NilLimit verifies that omitting pagination.limit (nil) uses the default +// and succeeds without error. +func TestGetTransactions_NilLimit(t *testing.T) { + mockStore := new(MockStore) + + handler := &Handler{ + store: mockStore, + } + + wallet := "0x1234567890123456789012345678901234567890" + + // Pagination present but Limit omitted — should reach the store with default behavior. + mockStore.On( + "GetUserTransactions", + wallet, + (*string)(nil), + (*core.TransactionType)(nil), + (*uint64)(nil), + (*uint64)(nil), + &core.PaginationParams{Limit: nil}, + ).Return([]core.Transaction{}, core.PaginationMetadata{Page: 1, PerPage: 50, PageCount: 0, TotalCount: 0}, nil) + + reqPayload := rpc.UserV1GetTransactionsRequest{ + Wallet: wallet, + Pagination: &rpc.PaginationParamsV1{}, // Limit is nil + } + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "user.v1.get_transactions", Payload: payload}, + } + + handler.GetTransactions(ctx) + + require.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) +} + +// TestGetTransactions_ZeroLimit verifies that pagination.limit == 0 is treated as absent +// (coerced to the default limit) and succeeds without error. +func TestGetTransactions_ZeroLimit(t *testing.T) { + mockStore := new(MockStore) + + handler := &Handler{ + store: mockStore, + } + + wallet := "0x1234567890123456789012345678901234567890" + zero := uint32(0) + + // limit=0 passes through to the store; GetOffsetAndLimit coerces it to DefaultLimit. + mockStore.On( + "GetUserTransactions", + wallet, + (*string)(nil), + (*core.TransactionType)(nil), + (*uint64)(nil), + (*uint64)(nil), + &core.PaginationParams{Limit: &zero}, + ).Return([]core.Transaction{}, core.PaginationMetadata{Page: 1, PerPage: 10, PageCount: 0, TotalCount: 0}, nil) + + reqPayload := rpc.UserV1GetTransactionsRequest{ + Wallet: wallet, + Pagination: &rpc.PaginationParamsV1{ + Limit: &zero, + }, + } + payload, err := rpc.NewPayload(reqPayload) + require.NoError(t, err) + + ctx := &rpc.Context{ + Context: context.Background(), + Request: rpc.Message{Method: "user.v1.get_transactions", Payload: payload}, + } + + handler.GetTransactions(ctx) + + require.Nil(t, ctx.Response.Error()) + mockStore.AssertExpectations(t) +} + // TestGetTransactions_NormalizesWallet verifies the wallet is normalized before the store call. func TestGetTransactions_NormalizesWallet(t *testing.T) { mockStore := new(MockStore) diff --git a/nitronode/api/user_v1/handler.go b/nitronode/api/user_v1/handler.go index d0d68d10a..4bd7f458c 100644 --- a/nitronode/api/user_v1/handler.go +++ b/nitronode/api/user_v1/handler.go @@ -2,20 +2,17 @@ package user_v1 // Handler manages user data operations and provides RPC endpoints. type Handler struct { - store Store - useStoreInTx StoreTxProvider - actionGateway ActionGateway + store Store + useStoreInTx StoreTxProvider } // NewHandler creates a new Handler instance with the provided dependencies. func NewHandler( store Store, useStoreInTx StoreTxProvider, - actionGateway ActionGateway, ) *Handler { return &Handler{ - store: store, - useStoreInTx: useStoreInTx, - actionGateway: actionGateway, + store: store, + useStoreInTx: useStoreInTx, } } diff --git a/nitronode/api/user_v1/interface.go b/nitronode/api/user_v1/interface.go index 35044a811..100d0cf3a 100644 --- a/nitronode/api/user_v1/interface.go +++ b/nitronode/api/user_v1/interface.go @@ -1,7 +1,6 @@ package user_v1 import ( - "github.com/layer-3/nitrolite/nitronode/action_gateway" "github.com/layer-3/nitrolite/pkg/core" ) @@ -27,11 +26,4 @@ type Store interface { FromTime *uint64, ToTime *uint64, Paginate *core.PaginationParams) ([]core.Transaction, core.PaginationMetadata, error) - - action_gateway.Store -} - -type ActionGateway interface { - // GetUserAllowances retrieves the action allowances for a user, which define what actions the user is permitted to perform. - GetUserAllowances(tx action_gateway.Store, userAddress string) ([]core.ActionAllowance, error) } diff --git a/nitronode/api/user_v1/testing.go b/nitronode/api/user_v1/testing.go index 8bbd5d662..9f345ea72 100644 --- a/nitronode/api/user_v1/testing.go +++ b/nitronode/api/user_v1/testing.go @@ -1,12 +1,8 @@ package user_v1 import ( - "time" - - "github.com/shopspring/decimal" "github.com/stretchr/testify/mock" - "github.com/layer-3/nitrolite/nitronode/action_gateway" "github.com/layer-3/nitrolite/pkg/core" ) @@ -34,32 +30,3 @@ func (m *MockStore) GetUserTransactions(Wallet string, Asset *string, TxType *co } return args.Get(0).([]core.Transaction), metadata, args.Error(2) } - -func (m *MockStore) GetAppCount(_ string) (uint64, error) { - return 0, nil -} - -func (m *MockStore) GetTotalUserStaked(_ string) (decimal.Decimal, error) { - return decimal.Zero, nil -} - -func (m *MockStore) RecordAction(_ string, _ core.GatedAction) error { - return nil -} - -func (m *MockStore) GetUserActionCount(_ string, _ core.GatedAction, _ time.Duration) (uint64, error) { - return 0, nil -} - -func (m *MockStore) GetUserActionCounts(_ string, _ time.Duration) (map[core.GatedAction]uint64, error) { - return nil, nil -} - -type MockActionGateway struct { - Allowances []core.ActionAllowance - Err error -} - -func (m *MockActionGateway) GetUserAllowances(_ action_gateway.Store, _ string) ([]core.ActionAllowance, error) { - return m.Allowances, m.Err -} diff --git a/nitronode/api/user_v1/utils.go b/nitronode/api/user_v1/utils.go index 23b2c3831..94f030073 100644 --- a/nitronode/api/user_v1/utils.go +++ b/nitronode/api/user_v1/utils.go @@ -21,8 +21,8 @@ func mapTransactionV1(tx core.Transaction) rpc.TransactionV1 { func mapBalanceEntryV1(entry core.BalanceEntry) rpc.BalanceEntryV1 { return rpc.BalanceEntryV1{ - Asset: entry.Asset, - Amount: entry.Balance.String(), + Asset: entry.Asset, + Amount: entry.Balance.String(), Enforced: entry.Enforced.String(), } } diff --git a/nitronode/chart/README.md b/nitronode/chart/README.md index 8bd9587c9..ff4cd2fd5 100644 --- a/nitronode/chart/README.md +++ b/nitronode/chart/README.md @@ -47,7 +47,6 @@ helm delete my-release | config.envSecret | string | `""` | Name of the secret containing environment variables | | config.blockchains | string | `""` | Blockchains configuration | | config.assets | string | `""` | Assets configuration | -| config.actionGateway | string | `""` | Action Gateway configuration | | replicaCount | int | `1` | Number of replicas | | image.repository | string | `"ghcr.io/layer-3/nitrolite/nitronode"` | Docker image repository | | image.tag | string | `"v1.0.0-rc.0"` | Docker image tag | diff --git a/nitronode/chart/config/prod-v1/assets.yaml b/nitronode/chart/config/prod-v1/assets.yaml index c2984cefe..ce45ae948 100644 --- a/nitronode/chart/config/prod-v1/assets.yaml +++ b/nitronode/chart/config/prod-v1/assets.yaml @@ -1,3 +1,12 @@ +# WARNING: all tokens grouped under one symbol are treated as fully fungible 1:1 +# representations of the same asset — off-chain credit can be redeemed from any of +# these token inventories. Group only economically equivalent (1:1 redeemable) +# tokens under one symbol. Equivalence cannot be verified programmatically and is an +# operator responsibility. See docs/protocol/security-and-limitations.md. +# +# WARNING: hook-enabled tokens (ERC777, ERC1363, non-standard ERC20 with re-entrant +# transferFrom) are NOT supported on some currently-deployed ChannelHub instances. +# Per-deployment status: contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md. assets: # Alphabetically sorted by symbol (case insensitive) - name: "Ether" diff --git a/nitronode/chart/config/prod-v1/blockchains.yaml b/nitronode/chart/config/prod-v1/blockchains.yaml index 0a78eeb14..85224983d 100644 --- a/nitronode/chart/config/prod-v1/blockchains.yaml +++ b/nitronode/chart/config/prod-v1/blockchains.yaml @@ -4,38 +4,54 @@ blockchains: channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # 3 blocks × 12s. Post-Merge Etherscan sample (1500 rows, Sep 2022 – Jun 2026) found zero depth-2+ reorgs. + confirmation_delay_secs: 36 - name: "flare" id: 14 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # ~1 block. Snowman++ probabilistic instant finality. + confirmation_delay_secs: 2 - name: "bsc" id: 56 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # ~3 blocks at 0.75s (post-Maxwell). Covers BEP-126 fast-finality vote window (~1.5s). + confirmation_delay_secs: 2 - name: "polygon" id: 137 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # Matches post-Rio finality (~5s, reorg depth capped at 2 blocks). + confirmation_delay_secs: 5 - name: "world_chain" id: 480 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # ~1 block. OP Stack single sequencer; no consensus reorgs in normal operation. + confirmation_delay_secs: 2 - name: "base" id: 8453 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # ~1 block. OP Stack single sequencer; Flashblocks reorg <0.001% after preconf. + confirmation_delay_secs: 2 - name: "linea" id: 59144 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # ~2 blocks. Single Consensys sequencer; slightly more margin than other OP-style L2s. + confirmation_delay_secs: 4 - name: "xrpl_evm" id: 1440000 channel_hub_address: "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1" channel_hub_sig_validators: 1: "0x8C00e739B9D22103774Cb32B21A203F6De31A2cC" + # CometBFT BFT-deterministic instant finality — blocks irreversible once committed. Gate disabled. + confirmation_delay_secs: 0 diff --git a/nitronode/chart/config/prod-v1/nitronode.yaml.gotmpl b/nitronode/chart/config/prod-v1/nitronode.yaml.gotmpl index dbfa0ba44..1fa656295 100644 --- a/nitronode/chart/config/prod-v1/nitronode.yaml.gotmpl +++ b/nitronode/chart/config/prod-v1/nitronode.yaml.gotmpl @@ -22,7 +22,6 @@ config: NITRONODE_GCP_KMS_KEY_NAME: "projects/ynet-stage/locations/europe-central2/keyRings/clearnode-signers-eu/cryptoKeys/prod-v1-a/cryptoKeyVersions/1" NITRONODE_MAX_PARTICIPANTS: "32" 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. diff --git a/nitronode/chart/config/sandbox-v1/assets.yaml b/nitronode/chart/config/sandbox-v1/assets.yaml index 0bac338e2..61b1c3c8a 100644 --- a/nitronode/chart/config/sandbox-v1/assets.yaml +++ b/nitronode/chart/config/sandbox-v1/assets.yaml @@ -1,3 +1,12 @@ +# WARNING: all tokens grouped under one symbol are treated as fully fungible 1:1 +# representations of the same asset — off-chain credit can be redeemed from any of +# these token inventories. Group only economically equivalent (1:1 redeemable) +# tokens under one symbol. Equivalence cannot be verified programmatically and is an +# operator responsibility. See docs/protocol/security-and-limitations.md. +# +# WARNING: hook-enabled tokens (ERC777, ERC1363, non-standard ERC20 with re-entrant +# transferFrom) are NOT supported on some currently-deployed ChannelHub instances. +# Per-deployment status: contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md. assets: - name: "Ether" symbol: "eth" diff --git a/nitronode/chart/config/sandbox-v1/blockchains.yaml b/nitronode/chart/config/sandbox-v1/blockchains.yaml index 0df6b4f1f..66c1c6512 100644 --- a/nitronode/chart/config/sandbox-v1/blockchains.yaml +++ b/nitronode/chart/config/sandbox-v1/blockchains.yaml @@ -4,23 +4,33 @@ blockchains: channel_hub_address: "0x5dba8515af063db0c243c15ece7b99f91459c7c3" channel_hub_sig_validators: 1: "0xB08901fb3c9952f9e33469169A1E3290FaD79e22" + # +2s over Linea mainnet to absorb sequencer restarts/redeploys on Consensys-operated testnet. + confirmation_delay_secs: 6 - name: "polygon_amoy" id: 80002 channel_hub_address: "0x5dba8515af063db0c243c15ece7b99f91459c7c3" channel_hub_sig_validators: 1: "0xB08901fb3c9952f9e33469169A1E3290FaD79e22" + # +1s over Polygon mainnet; Polygon recommended raising thresholds during Heimdall-v2 rollout. + confirmation_delay_secs: 6 - name: "base_sepolia" id: 84532 channel_hub_address: "0x5dba8515af063db0c243c15ece7b99f91459c7c3" channel_hub_sig_validators: 1: "0xB08901fb3c9952f9e33469169A1E3290FaD79e22" + # +2s over Base mainnet — staging ground for Flashblocks / OP Stack releases; operator rewinds more common. + confirmation_delay_secs: 4 - name: "xrpl_evm_testnet" id: 1449000 channel_hub_address: "0x5dba8515af063db0c243c15ece7b99f91459c7c3" channel_hub_sig_validators: 1: "0xB08901fb3c9952f9e33469169A1E3290FaD79e22" + # CometBFT BFT-deterministic instant finality — same as mainnet. Gate disabled. + confirmation_delay_secs: 0 - name: "ethereum_sepolia" id: 11155111 channel_hub_address: "0x5dba8515af063db0c243c15ece7b99f91459c7c3" channel_hub_sig_validators: 1: "0xB08901fb3c9952f9e33469169A1E3290FaD79e22" + # 3 blocks × 12s. Same PoS reorg model as mainnet; permissioned validator set is no worse day-to-day. + confirmation_delay_secs: 36 diff --git a/nitronode/chart/config/sandbox-v1/nitronode.yaml.gotmpl b/nitronode/chart/config/sandbox-v1/nitronode.yaml.gotmpl index 5caf6b4f9..2d15d6816 100644 --- a/nitronode/chart/config/sandbox-v1/nitronode.yaml.gotmpl +++ b/nitronode/chart/config/sandbox-v1/nitronode.yaml.gotmpl @@ -22,7 +22,6 @@ config: NITRONODE_GCP_KMS_KEY_NAME: "projects/ynet-stage/locations/europe-central2/keyRings/clearnode-signers-eu/cryptoKeys/sandbox-v1-b/cryptoKeyVersions/1" NITRONODE_MAX_PARTICIPANTS: "32" NITRONODE_MAX_SESSION_DATA_LEN: "1024" - NITRONODE_MAX_SIGNED_UPDATES: "0" NITRONODE_MAX_SESSION_KEY_IDS: "256" # Force fixed GasLimit on every blockchain tx (skip eth_estimateGas). # Required for XRPL EVM testnet — its RPC rejects estimateGas with diff --git a/nitronode/chart/config/stress-v1/assets.yaml b/nitronode/chart/config/stress-v1/assets.yaml index b35a119ff..e870c3b61 100644 --- a/nitronode/chart/config/stress-v1/assets.yaml +++ b/nitronode/chart/config/stress-v1/assets.yaml @@ -1,3 +1,12 @@ +# WARNING: all tokens grouped under one symbol are treated as fully fungible 1:1 +# representations of the same asset — off-chain credit can be redeemed from any of +# these token inventories. Group only economically equivalent (1:1 redeemable) +# tokens under one symbol. Equivalence cannot be verified programmatically and is an +# operator responsibility. See docs/protocol/security-and-limitations.md. +# +# WARNING: hook-enabled tokens (ERC777, ERC1363, non-standard ERC20 with re-entrant +# transferFrom) are NOT supported on some currently-deployed ChannelHub instances. +# Per-deployment status: contracts/deployments/HOOK-TOKEN-COMPATIBILITY.md. assets: - name: "Yellow USD" symbol: "yusd" diff --git a/nitronode/chart/config/stress-v1/blockchains.yaml b/nitronode/chart/config/stress-v1/blockchains.yaml index 9ccb2de93..2704629b4 100644 --- a/nitronode/chart/config/stress-v1/blockchains.yaml +++ b/nitronode/chart/config/stress-v1/blockchains.yaml @@ -4,8 +4,12 @@ blockchains: channel_hub_address: "0x7d61ec428cfae560f43647af567ea7c6e2cc5527" channel_hub_sig_validators: 1: "0xe2B33Aa3922d7ac386e6801Ae7D9498C4DF45F1f" + # 3 blocks × 12s. Same PoS reorg model as mainnet; permissioned validator set is no worse day-to-day. + confirmation_delay_secs: 36 - name: "base_sepolia" id: 84532 channel_hub_address: "0x61b9e0767f2eca7e33802e82f9c64b1ebe72ba31" channel_hub_sig_validators: 1: "0x4085554a56F962b6c8eeeb017Bf2e9D2F3E31131" + # +2s over Base mainnet — staging ground for Flashblocks / OP Stack releases; operator rewinds more common. + confirmation_delay_secs: 4 diff --git a/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl b/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl index bf71eb7a5..173cf49c1 100644 --- a/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl +++ b/nitronode/chart/config/stress-v1/nitronode.yaml.gotmpl @@ -22,7 +22,6 @@ config: NITRONODE_GCP_KMS_KEY_NAME: "projects/ynet-stage/locations/europe-central2/keyRings/clearnode-signers-eu/cryptoKeys/stress-v1-a/cryptoKeyVersions/1" NITRONODE_MAX_PARTICIPANTS: "32" 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. diff --git a/nitronode/chart/templates/configmap.yaml b/nitronode/chart/templates/configmap.yaml index 1835eb2d9..cf24d0064 100644 --- a/nitronode/chart/templates/configmap.yaml +++ b/nitronode/chart/templates/configmap.yaml @@ -13,5 +13,3 @@ data: {{ .Values.config.blockchains | indent 4 }} assets.yaml: |- {{ .Values.config.assets | indent 4 }} - action_gateway.yaml: |- -{{ .Values.config.actionGateway | indent 4 }} diff --git a/nitronode/chart/values.yaml b/nitronode/chart/values.yaml index c8e85e5bd..6f689b347 100644 --- a/nitronode/chart/values.yaml +++ b/nitronode/chart/values.yaml @@ -42,8 +42,6 @@ config: blockchains: "" # -- Assets configuration assets: "" - # -- Action Gateway configuration - actionGateway: "" # -- Number of replicas replicaCount: 1 diff --git a/nitronode/config/migrations/postgres/20260602000000_session_key_user_only_revocation.sql b/nitronode/config/migrations/postgres/20260602000000_session_key_user_only_revocation.sql new file mode 100644 index 000000000..ae3983d1c --- /dev/null +++ b/nitronode/config/migrations/postgres/20260602000000_session_key_user_only_revocation.sql @@ -0,0 +1,29 @@ +-- +goose Up +-- User-only session-key revocation: a submit with expires_at <= now deactivates a delegation +-- and is authorized by the wallet's user_sig alone, so the session-key holder cannot veto +-- revocation of a lost, unavailable, or compromised key. Such revocation rows carry an empty +-- session_key_sig, which the *_session_key_sig_present_chk CHECK constraints added in +-- 20260508000000_session_key_ownership_constraints reject. Drop them. +-- +-- The ownership guarantee those constraints protected is preserved in application code: submits +-- that activate, extend, or rotate a key (expires_at > now) still require a valid session_key_sig, +-- so every *active* history row remains co-signed by the session-key holder. Owner and auth +-- lookups filter expires_at > now, so revocation rows with an empty session_key_sig are never +-- trusted as a session key's owner. +ALTER TABLE app_session_key_states_v1 + DROP CONSTRAINT IF EXISTS app_session_key_states_v1_session_key_sig_present_chk; + +ALTER TABLE channel_session_key_states_v1 + DROP CONSTRAINT IF EXISTS channel_session_key_states_v1_session_key_sig_present_chk; + +-- +goose Down +-- Re-add as NOT VALID: skip the legacy backfill scan (revocation rows with an empty +-- session_key_sig may now exist) so the down migration cannot fail on pre-existing data; only +-- future inserts are checked. Matches how 20260508000000 originally added them. +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; diff --git a/nitronode/config/migrations/postgres/20260603000000_drop_app_registry_staking_action_log.sql b/nitronode/config/migrations/postgres/20260603000000_drop_app_registry_staking_action_log.sql new file mode 100644 index 000000000..64a0a7422 --- /dev/null +++ b/nitronode/config/migrations/postgres/20260603000000_drop_app_registry_staking_action_log.sql @@ -0,0 +1,48 @@ +-- +goose Up + +-- Drop the off-chain app registry, user staking, and action-log (rate limiting) +-- subsystems. Their tables and indexes are no longer referenced by the node. + +DROP TABLE IF EXISTS apps_v1; +DROP TABLE IF EXISTS action_log_v1; +DROP TABLE IF EXISTS user_staked_v1; + +-- +goose Down + +-- Application registry +DROP TABLE IF EXISTS apps_v1; +CREATE TABLE apps_v1 ( + id VARCHAR(66) PRIMARY KEY, + owner_wallet CHAR(42) NOT NULL, + metadata TEXT NOT NULL, + version NUMERIC(20,0) NOT NULL DEFAULT 1, + creation_approval_not_required BOOLEAN NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_apps_v1_owner_wallet ON apps_v1(owner_wallet); + +-- User staked table: Stores staked amounts per user per blockchain +DROP TABLE IF EXISTS user_staked_v1; +CREATE TABLE user_staked_v1 ( + user_wallet CHAR(42) NOT NULL, + blockchain_id NUMERIC(20,0) NOT NULL, + amount NUMERIC(78, 18) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_wallet, blockchain_id) +); + +CREATE INDEX idx_user_staked_v1_user_wallet ON user_staked_v1(user_wallet); + +-- Action log table: Records user actions for rate limiting and auditing +DROP TABLE IF EXISTS action_log_v1; +CREATE TABLE action_log_v1 ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_wallet CHAR(42) NOT NULL, + gated_action SMALLINT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_action_log_v1_wallet_gated_action_created ON action_log_v1(user_wallet, gated_action, created_at DESC); diff --git a/nitronode/config/migrations/postgres/20260608000000_add_block_hash_to_contract_events.sql b/nitronode/config/migrations/postgres/20260608000000_add_block_hash_to_contract_events.sql new file mode 100644 index 000000000..5dc48625f --- /dev/null +++ b/nitronode/config/migrations/postgres/20260608000000_add_block_hash_to_contract_events.sql @@ -0,0 +1,7 @@ +-- +goose Up + +ALTER TABLE contract_events ADD COLUMN block_hash CHAR(66) NOT NULL DEFAULT ''; + +-- +goose Down + +ALTER TABLE contract_events DROP COLUMN block_hash; diff --git a/nitronode/config/schemas/action_gateway_schema.yaml b/nitronode/config/schemas/action_gateway_schema.yaml deleted file mode 100644 index 4f4997a67..000000000 --- a/nitronode/config/schemas/action_gateway_schema.yaml +++ /dev/null @@ -1,31 +0,0 @@ -$schema: "http://json-schema.org" -type: object -required: - - level_step_tokens - - app_cost - - action_gates -properties: - level_step_tokens: - type: string - pattern: "^(?:[1-9][0-9]*(\\.[0-9]+)?|0\\.[0-9]*[1-9][0-9]*)$" - description: "Decimal amount for level step tokens" - app_cost: - type: string - pattern: "^(?:[1-9][0-9]*(\\.[0-9]+)?|0\\.[0-9]*[1-9][0-9]*)$" - action_gates: - type: object - additionalProperties: - $ref: "#/definitions/ActionGateConfig" -definitions: - ActionGateConfig: - type: object - required: - - free_actions_allowance - - increase_per_level - properties: - free_actions_allowance: - type: integer - minimum: 0 - increase_per_level: - type: integer - minimum: 0 diff --git a/nitronode/docs/reorg-fix.md b/nitronode/docs/reorg-fix.md new file mode 100644 index 000000000..095ca433b --- /dev/null +++ b/nitronode/docs/reorg-fix.md @@ -0,0 +1,491 @@ +# Reorg Attack Fix — Confirmation Window Specification + +## 1. Risk + +The Nitronode event listener credits a user's off-chain balance the moment it observes a deposit event on-chain. If the block containing that deposit is subsequently removed from the canonical chain (a "reorganisation"), the off-chain credit persists while the on-chain deposit no longer exists. Because the credited balance can be transferred to a receiver before the node has any way to detect the reorg, the node ends up honoring an off-chain state transition that is permanently unbacked. + +The worst-case outcome is a net loss of node liquidity equal to the sum of all deposit amounts that were credited during a reorg window and successfully drained to attacker-controlled receivers before the reorg was detected. There is no recovery path for the node once a signed receiver state exists. + +This risk is meaningful on any chain where head-level reorgs occur naturally or can be induced. On modern fast-finality chains (BNB, Polygon post-Rio, Avalanche) the residual probability is very low. On Ethereum L1, depth-1 reorgs are routine and cryptoeconomic finality takes ~12.8 minutes. + +--- + +## 2. Solution Overview + +A **per-chain confirmation window** is introduced between raw event delivery and handler invocation. When the listener observes any event on chain C: + +- It does **not** invoke the handler immediately. +- It waits for `confirmation_delay_secs` seconds (configured per chain in `blockchains.yaml`). +- If no reorg of the event's block occurs during that window, the handler is invoked normally. +- If the event's block is reorged out (`removed: true` log arrives), the pending invocation is cancelled with no side effects. +- If the reorged transaction is re-included (the same event appears again), the confirmation window restarts from zero. + +The delay applies uniformly to **all** events, not only deposit-class ones. Selective gating would require the component to understand event semantics and introduce ordering hazards when events for different channels arrive interleaved — for example, a deposit event and a challenge event on separate channels could fire their handlers out of original arrival order if only the deposit is delayed. Uniform delay preserves the relative order of all events as they arrived from the chain while adding a single, predictable latency layer. + +### 2.1 Residual risk and the finality trade-off + +The confirmation window eliminates the reorg risk only when `confirmation_delay_secs` is set to or above the chain's cryptoeconomic finality time. For the representative values in §3: + +- **Ethereum at 780s (~13 min):** matches Casper FFG hard finality. Reorging past this point requires ≥1/3 of total stake to be slashed. No residual risk. +- **Polygon at 10s, BNB at 5s:** exceeds the empirical reorg tail depth. Residual risk is negligible but not cryptoeconomically eliminated. +- **Ethereum at 36s (3 blocks, "quick" finality):** P(reorg depth ≥ 4) ≈ 10⁻⁵–10⁻⁶ per event. Residual risk is real. + +When `confirmation_delay_secs` is set *below* the chain's finality time, **this specification acknowledges a residual risk**: it is possible — with low but non-zero probability — that an event passes the gate, the reactor commits it to the database, and the block containing that event is subsequently reorged out by a reorg deeper than the gate window. + +When this occurs, the committed state (balance credit, channel open) has no corresponding on-chain event in the canonical chain. If the transaction is re-mined in the new canonical block, the reactor's idempotency guard (§6.6) handles the re-delivery cleanly. If it is not re-mined, the DB retains stale state that can only be partially corrected on the next node restart via the reconciliation walk (§4.4). There is no automated rollback; the exposure scales with the deposit value and is bounded by the probability of deep reorgs on the target chain. + +Operators who cannot accept this residual exposure should set `confirmation_delay_secs` to the chain's hard-finality time (Ethereum: 780s; Polygon: `finalized` tag resolves to ~5s; L2s: `finalized` maps to L1 Casper FFG at ~13 min). The gate's detection mechanisms (§6.5, §6.6) provide observability when the residual-risk scenario occurs. + +--- + +## 3. Configuration + +A new `confirmation_delay_secs` field is added per chain in `blockchains.yaml`. The values target ~99.9% reorg-safety on each chain — i.e. the probability that a reorg displaces an event after the gate elapses is empirically below 0.1% — rather than hard cryptoeconomic finality. This trades a small residual exposure (§2.1) for substantially better UX. Production values: + +```yaml +chains: + - id: 1 # Ethereum mainnet + confirmation_delay_secs: 36 # 3 blocks × 12s. Post-Merge Etherscan sample (1500 rows, Sep 2022 – Jun 2026) found zero depth-2+ reorgs. + - id: 56 # BNB Smart Chain + confirmation_delay_secs: 2 # ~3 blocks at 0.75s (post-Maxwell). Covers BEP-126 fast-finality vote window (~1.5s). + - id: 137 # Polygon PoS (post-Heimdall v2 / Rio) + confirmation_delay_secs: 5 # Matches post-Rio finality (~5s, reorg depth capped at 2 blocks). +``` + +`confirmation_delay_secs: 0` disables the gate — events are processed immediately. Appropriate for BFT single-slot chains (XRPL EVM, other CometBFT/Tendermint chains) where blocks are irreversible the moment they're committed, or for chains using a finality-tag subscription rather than a block-count gate. + +Operators who cannot accept the residual exposure described in §2.1 should raise these values toward each chain's hard-finality time (Ethereum: 780s; Polygon: `finalized` tag ≈5s; OP Stack L2s: `finalized` maps to L1 Casper FFG ≈13 min). + +--- + +## 4. Confirmation Window Behavior + +### 4.1 Normal path + +When a log `E` arrives (without `Removed: true`): + +1. Record the event in the live-entry map under `(txHash, logIndex)` with its `blockHash` as the tombstone discriminator, and append it to the FIFO drain queue with its block timestamp as `arrivedAt`. +2. The gate's drain goroutine (single shared timer per gate; see §6.3) treats the entry as eligible once `arrivedAt + confirmation_delay_secs` has elapsed. +3. When the entry matures, invoke the event handler. + +### 4.2 Reorg path + +If a log with `Removed: true` arrives for the same `(txHash, blockHash, logIndex)` before the timer fires: + +- Cancel the pending timer. +- Do not invoke the handler — no state change occurs. +- The listener remains active. When the same transaction is re-included, its event will be delivered again (without `Removed: true`) and the gate starts a fresh window under the new block's key. + +### 4.3 Out-of-order delivery + +The re-added event (no `Removed: true`, new block) may arrive at the listener before the corresponding `Removed: true` log for the old block. When this happens, the gate **replaces** the pending entry for `(txHash, logIndex)` with the new one and resets the confirmation timer under the new block's key: + +- On the non-removed re-add, overwrite `pending[(txHash, logIndex)]` with the new `blockHash` and append the new event to the queue tail with a fresh `arrivedAt`. The earlier queue entry remains in place as a tombstone — its `blockHash` no longer matches `pending`, so the drain goroutine silently skips it when it reaches the head. +- The subsequent `Removed: true` log for the OLD block carries the old `blockHash` and therefore matches neither `pending` (whose value is now the new block's hash) nor any `forwardedSet` record. It performs a no-op. + +The tombstone-map design replaces the prior slice-scan approach: every live operation is O(1), and exactly one event per `(txHash, logIndex)` is forwarded — the latest re-mining. + +- On a `Removed: true` log for a key that **has no live `pending` entry and no `forwardedSet` record**: no-op. The event either belongs to a block that was already replaced by a later re-add (handled above), or it is a stale removal from a fork the gate has no record of. + +> Repeated reorgs of the same transaction are theoretically possible but imply a chain-level consensus failure. The gate's replace/restart cycle handles each naturally; no special cap is needed. + +### 4.4 Startup and reconciliation + +#### Prerequisites + +Before the reconciliation logic described below can function, `block_hash` must be added as a column to `contract_events` and to the `core.BlockchainEvent` struct. The value is available in `types.Log.BlockHash` at the time the gate calls the reactor. Without this column, reorg detection in steps 2–4 is not possible. + +**Why `block_hash` is the minimal required addition — and why alternatives fail:** + +The reconciliation walk needs to answer one question per stored block: "is this specific block still in the canonical chain?" The definitive answer combines the stored hash with an `eth_getBlockByNumber(storedBlockNumber)` lookup — the canonical chain has exactly one block at each height, and comparing its hash to the stored hash tells us whether the stored block is still canonical. Without the stored hash, two alternatives were evaluated and both fail: + +- **`block_number` alone is insufficient.** After a reorg, a *different* block can occupy the same height. Calling `eth_getBlockByNumber(storedBlockNumber)` always returns a block — but it may be a new block from the reorged fork. Without the original hash there is no way to tell whether the block returned is the one the reactor processed. + +- **`transaction_hash` via `eth_getTransactionReceipt` is insufficient.** A block can be reorged out even if every one of its transactions was re-mined in a new block at the same height. In that case all receipt lookups return `blockNumber` matching the stored value, but the original block is gone and the stored DB state no longer corresponds to the canonical chain. Additionally, the backward walk (step 3) must traverse every stored *block* in descending order; rows in `contract_events` only exist for blocks that contained a `ChannelHub` event. A reorg that diverged entirely within a gap — blocks with no relevant events — is invisible to a tx-receipt-based walk. + +Note that `eth_getBlockByHash(storedHash)` alone is **not** suitable as the canonicality check: a node may still have the orphan side-chain header cached locally and return it successfully, so a non-null response does not prove the block is in the canonical chain. The check must use `eth_getBlockByNumber` so the response is by definition the current canonical block at that height. + +`block_hash` is a single `CHAR(66)` column. Its addition enables exact, O(1)-per-step canonicality checks and is the only approach that handles all reorg scenarios correctly. + +#### Definition: latest processed block + +The **latest processed block** for a chain is the highest block number at which the reactor successfully committed at least one event to the database — identical to the listener's existing startup cursor (`MAX(block_number)` in `contract_events` for this `blockchain_id` and contract address, computed by `GetLatestContractEventBlockNumber`). This is distinct from the highest block the listener ever *saw*: the listener may have seen many blocks that contained no relevant events and therefore left no `contract_events` rows. + +#### Reconciliation steps + +On startup, for each chain, after the `block_hash` migration has been applied: + +1. Query `contract_events` for the latest committed event: `latestBlockNum = MAX(block_number)`, `latestBlockHash = block_hash` at that row. If no rows exist, start the scan from the chain's configured genesis / start block and skip to step 5. +2. Call `eth_getBlockByNumber(latestBlockNum)` on the chain's RPC and compare the returned block's hash against `latestBlockHash`. + - **Hash matches** → the stored block is the current canonical block at that height; no reorg above it. Proceed to step 4. + - **Hash differs** → a different block now occupies that height; the stored block has been reorged out. Proceed to step 3. + - **`ethereum.NotFound`** (RPC has no canonical block at that number, e.g. the height was pruned) → treat as reorged-out and proceed to step 3 rather than failing startup. +3. **Common-ancestor walk using stored block hashes:** query `contract_events` for the next-older distinct `block_hash` (the highest `block_number` strictly below the current candidate). Repeat step 2 with this (number, hash) pair. Continue until a stored block is confirmed canonical, or until no older stored hash exists. This height is the **common ancestor**. + + > **Why walk stored hashes, not block numbers?** In normal operation most blocks contain no `ChannelHub` events, so `contract_events` has no row for them. A block-number walk would find nothing to compare at event-gap heights and could miss a reorg that occurred entirely within such a gap. Walking by stored block hashes ensures every comparison is against a block the reactor actually processed. + + If the walk exhausts stored rows without finding a canonical one **and** no older row exists (`prevNum == 0` with `prevHash == ""`), the listener resumes from the *original* latest stored block number. The orphaned hash is discarded; `eth_getLogs` is a canonical-chain range query, so canonical-replacement logs between that height and the current tip are re-fetched normally. The empty-store case (`latestNum == 0`) continues to skip historical replay and tracks the chain from the live subscription. The historical scan is inclusive at both bounds: when the live tip equals the resume height (the depth-1 reorg of the single most-recent stored event, with no newer block since), the listener still issues `FilterLogs(N, N)` so the canonical replacement at that height is fetched; downstream dedup (`IsContractEventProcessed` at the listener phase-boundary and the reactor pre-check) absorbs the inevitable re-fetch on quiet-chain restart-at-exact-tip. + +4. Set the scan start to `commonAncestorBlockNum`. Events between `commonAncestorBlockNum` and `latestBlockNum` that came from the reorged fork are still present in the DB. The reactor has no rollback mechanism for those rows — the re-scan below will re-apply canonical events over them where the transaction was re-mined (idempotent), and leave the orphaned DB state in place where the transaction was not re-mined (residual risk; see §2.1). State-setting operations (`UpdateChannel`, `RefreshUserEnforcedBalance`) will overwrite with canonical values for re-mined events; rows from dropped transactions remain as stale data with no automated cleanup. +5. Start the event scan from `commonAncestorBlockNum` (or genesis if step 1 found no rows). Replayed events are routed **per-event by block age**: + - Events whose block timestamp is **older than `confirmation_delay_secs`** are routed directly to the reactor, bypassing the gate. Their block is past the reorg window — `eth_getLogs` returned them as canonical, and any reorg that could displace them would exceed the configured finality bound. There is no incremental reorg risk to guard against, and routing them through the gate would only add latency. + - Events whose block timestamp is **younger than `confirmation_delay_secs`** are routed through the gate, the same path live events take. The common-ancestor walk only confirms the *starting* block is canonical; replay can fetch logs from blocks all the way up to the current chain tip, some of which are still inside the reorg window. Forwarding those directly to the reactor would re-introduce the very double-spend window the gate was built to close. + + The `Listener` accepts two handlers (`eventHandler` for live events and recent historical events, `historicalEventHandler` for mature historical events) and makes the per-event routing decision from `eventLog.BlockTimestamp`. To guarantee that field is populated regardless of the RPC provider's behavior, the listener calls `ensureBlockTimestamp` once per event, which uses `eventLog.BlockTimestamp` when present and falls back to `HeaderByHash` otherwise (at most one fetch per block regardless of event count). + When `confirmation_delay_secs` is `0` the gate is disabled and every historical event is routed to `historicalEventHandler`. On an `ensureBlockTimestamp` failure the Listener falls back to `eventHandler` (the gate) — the conservative choice that preserves the reorg-protection invariant at the cost of a small delay. +6. The reactor is idempotent for replayed events: `HandleHomeChannelCreated` has an explicit early-return guard when the channel is already open; `HandleHomeChannelCheckpointed` and `RefreshUserEnforcedBalance` use set-semantics (not accumulation) and recompute from the latest DB state. Before opening a transaction, `HandleEvent` calls `IsContractEventProcessed`; if the event is already committed, it returns `nil` immediately with no DB transaction opened. If `IsContractEventProcessed` returns an error, `HandleEvent` returns the wrapped error; the listener unsubscribes and the process restarts (per the lifecycle closure in §6.8), re-fetching the same range via the DB cursor so the pre-check retries. For events that pass the pre-check, `StoreContractEvent` is called last inside the DB transaction and enforces a unique constraint on `(transaction_hash, log_index, blockchain_id)` as a final backstop. +7. Historical log queries (`eth_getLogs`) return only canonical chain events — there are no `Removed: true` signals during replay, and replay does not flow through the gate (step 5). Removal signals from the live WebSocket subscription that arrive during the replay phase are buffered in the listener's `currentCh` and reach the gate only after the historical replay phase completes; if they cancel a re-mined event that has already been forwarded by the live path, the post-gate reorg detection in §6.5 logs them. +8. When `confirmation_delay_secs == 0`, the listener drops `Removed:true` live logs at the Phase 2 boundary because there is no downstream gate to consume them; the reactor never receives `Removed:true` logs in either mode. + +--- + +## 5. Scope + +The delay applies to **all** events emitted by the `ChannelHub` contract on a given chain. No filtering by event type is performed inside the gate. + +> **Note:** `ChannelCreated` (`handleHomeChannelCreated`) calls `RefreshUserEnforcedBalance`. Verify whether the initial channel state carries a non-zero deposit; if it does, the uniform delay already protects it — no special casing is needed. + +--- + +## 6. Implementation Notes + +### 6.1 Component placement and wiring + +The `ConfirmationGate` is a thin in-memory component that sits between the raw log stream (`listener.go`) and the `ChannelHubReactor`. + +**Existing wiring** (`nitronode/main.go:127-129`): + +```go +reactor := evm.NewChannelHubReactor(b.ID, ...) +l := evm.NewListener(..., reactor.HandleEvent, ...) +``` + +The listener accepts a handler of type `HandleEvent func(ctx context.Context, eventLog types.Log) error`. The gate exposes the same signature and is inserted between the two: + +```go +reactor := evm.NewChannelHubReactor(b.ID, ...) +var liveHandler evm.HandleEvent +if confirmationDelay > 0 { + gate, err := evm.NewConfirmationGate(confirmationDelay, b.ID, reactor.HandleEvent, logger) + if err != nil { /* fatal */ } + gate.Start(ctx) + liveHandler = gate.HandleEvent +} else { + liveHandler = reactor.HandleEvent +} +l := evm.NewListener(..., liveHandler, reactor.HandleEvent, ...) +``` + +The constructor returns an error for `delay <= 0`; the wiring layer is responsible for skipping gate construction when the operator configured `confirmation_delay_secs: 0` and routing live events straight to the reactor. + +The reactor itself does not change. All the listener's existing logic — subscription management, cursor tracking, reconnection, historical replay — is unaffected. + +**Handling `Removed: true` logs:** currently `listener.go:289-294` skips removed logs before they reach the handler. This skip must be moved: the listener should forward removed logs to `gate.HandleEvent` (they still carry the `Removed` flag on `types.Log`), and the gate alone decides whether to cancel a pending timer or ignore the signal. The reactor never sees a `Removed: true` log. + +### 6.2 Event identity for queue keying + +The Listener delivers events in strict block order, so the FIFO queue is naturally ordered by arrival time. Two distinct keys identify events at different layers of the design: + +- **`(txHash, logIndex)` — the live-entry key, used as the tombstone-map (`pending`) key.** On a non-removed arrival, the Pusher sets `pending[ek] = eventLog.BlockHash` (overwriting any prior value) and appends to the queue tail. On a `Removed: true` arrival, the Pusher checks `pending[ek]` and cancels (deletes from `pending`) if the stored `blockHash` matches the removed log's. A stale removal for an OLD block whose `pending` value has already been overwritten by a newer re-add will not match and falls through to the `forwardedSet` lookup (§6.5). Both operations are O(1) map lookups; the queue body is never scanned. +- **`(txHash, blockHash, logIndex)` — the post-gate detection key (`forwardedKey`), used to index `forwardedSet`.** When the drain goroutine forwards an event, it inserts this triple into `forwardedSet` so a later `Removed: true` for the same exact occurrence can be matched and the post-gate reorg WARN emitted. Including `blockHash` ensures a stale removal for an already-replaced fork cannot cause a spurious WARN against a different re-mining. + +`blockHash` is excluded from the live-entry key so that a re-mining of the same tx overwrites the original `pending` value regardless of which block it landed in. `blockHash` is included in the post-gate detection key so that the WARN matches the specific occurrence that was forwarded. + +A single transaction can emit multiple events for the same `txHash` (e.g., two `ChannelDeposited` logs in a batch open). `logIndex` disambiguates these; it is unique per log within a block and is present in both the live event and its corresponding `Removed: true` log. + +`blockHash` is also used by: + +- The post-gate reorg detection map (`forwardedSet`, §6.5) — keyed by `(txHash, blockHash, logIndex)` to identify which specific occurrence was forwarded, with the FIFO `forwardedQueue` driving O(1) eviction. +- `StoreContractEvent` in the reactor — stored in `contract_events` for the reconciliation walk (§4.4). + +### 6.3 Timer-and-kick design + +**Data structure:** a FIFO queue of `(types.Log, arrivedAt time.Time)` paired with a `pending` tombstone map that is the source of truth for which queue entries are live. The queue is append-tail and pop-head only; stale entries are skipped at the head by comparing `pending[ek]` to the popped entry's `BlockHash`. Removal scans of the queue body are eliminated. + +```go +type queueEntry struct { + log types.Log + arrivedAt time.Time +} + +type eventKey struct { // used as the tombstone-map key (re-add replaces prior entry) + txHash common.Hash + logIndex uint +} + +type forwardedKey struct { // post-gate detection key (full triple, written by drain goroutine, read on Removed) + txHash common.Hash + blockHash common.Hash + logIndex uint +} + +type forwardedExpiry struct { + key forwardedKey + forwardedAt time.Time +} + +type ConfirmationGate struct { + delay time.Duration + chainID uint64 + handler HandleEvent + logger log.Logger + + mu sync.Mutex + queue []queueEntry // protected by mu + pending map[eventKey]common.Hash // live (txHash, logIndex) -> blockHash; protected by mu + forwardedSet map[forwardedKey]time.Time // protected by mu; entries are kept for a small multiple of `delay` (see §6.5) + forwardedQueue []forwardedExpiry // FIFO of (key, forwardedAt) driving O(1) eviction; protected by mu + + kick chan struct{} // buffered 1, non-blocking sender + timer *time.Timer // created in Start(ctx); reset to the head entry's deadline +} +``` + +--- + +**Pusher path** (driven by the existing Listener; implements the `HandleEvent` signature) + +Receives `types.Log` from the Listener. On each event: + +- If `Removed: true` — under `mu`: if `pending[ek] == eventLog.BlockHash`, `delete(pending, ek)` (pre-gate cancel; the tombstoned queue entry is silently skipped when it reaches the head). Otherwise, if `forwardedSet[fk]` is set, emit the post-gate WARN (§6.5) and `delete(forwardedSet, fk)`; leave the corresponding `forwardedQueue` entry in place — it expires on its own and the eviction loop's value-check makes the early delete safe. Otherwise, emit a DEBUG "removal for unknown/stale event". +- Otherwise — under `mu`: set `pending[ek] = eventLog.BlockHash` (replacing any prior value for the same `(txHash, logIndex)`) and append `(log, arrivedAt)` to the queue tail. `arrivedAt` is the block timestamp (see §6.7). Release `mu` and send a non-blocking `kick` (`select { case g.kick <- struct{}{}: default: }`). + +No expiration check, no forwarding. Push only. + +--- + +**Drain goroutine** (single, started by `Start(ctx)`) + +A single timer drives forwarding; no idle wakeups. The timer is reset to the head entry's deadline; a 1-buffered `kick` channel coalesces wakeups from the Pusher when a new head deadline is sooner than the currently-armed timer (or when the queue was empty). + +```go +for { + select { + case <-ctx.Done(): + return + case <-g.kick: + case <-g.timer.C: + } + g.drainAndReschedule() +} +``` + +`drainAndReschedule`: + +1. Under `mu`: `now := time.Now()`. While the head entry is mature (`queue[0].arrivedAt + delay <= now`): + - Pop it. + - **Tombstone check:** if `pending[ek] != entry.log.BlockHash`, the live entry for that `(txHash, logIndex)` has been replaced by a re-add. Drop silently. Do **not** touch `pending[ek]` — it refers to the *new* live entry still in the queue. + - Otherwise: `delete(pending, ek)`; `forwardedSet[fk] = now`; `forwardedQueue = append(forwardedQueue, forwardedExpiry{fk, now})`. **These three writes happen before releasing `mu`** around the handler call, so a fast `Removed: true` arriving immediately after forwarding always sees the entry and emits the post-gate WARN. + - Release `mu`, call `handler`, re-acquire. +2. Evict aged-out `forwardedSet` entries (see §6.5). +3. Reset the timer to the new head's deadline, or leave it stopped if the queue is empty (the next `kick` will recompute). + +No event handling, no Listener awareness. Drain-and-forward only. + +--- + +**Properties** + +| Property | Detail | +| --- | --- | +| Chain-agnostic | `confirmationDelay` is the only chain-specific input | +| Forward latency after window | Bounded by timer scheduling jitter; no fixed polling tick | +| Idle cost | None — no ticker; the goroutine blocks on `ctx.Done()`/`kick`/`timer.C` | +| Reorg within window | Pusher's tombstone delete cancels the entry; Reactor never sees the event | +| Reorg deeper than window | Rare; Reactor-level idempotency (§6.6) handles re-delivered events | +| Concurrency | Pusher and drain goroutine share `mu`; Reactor is called outside the lock | +| Shutdown | Drain goroutine exits on `ctx.Done()`; `defer g.timer.Stop()` cleans up the timer; entries still in queue are discarded (safe — they were never forwarded). `kick` is **not** closed — the Pusher may still be invoked by an in-flight listener event during shutdown, and the non-blocking send is safe whether the receiver is alive or gone. | + +### 6.4 Exposing `confirmation_delay_secs` via API + +Clients need to know the confirmation delay for each chain so they can display the correct waiting time to users after submitting a deposit. The best existing candidate is **`node.v1.GetConfig`**, which already returns a per-chain `BlockchainInfoV1` object. + +Files to update: + +- `pkg/rpc/types.go` — add `ConfirmationDelaySecs uint64` to `BlockchainInfoV1`. +- `nitronode/api/node_v1/utils.go` — populate the new field in `mapBlockchainV1` from the chain's loaded config. +- `pkg/core/types.go` (or wherever `core.Blockchain` is defined) — add `ConfirmationDelaySecs uint64` so the value flows from `blockchains.yaml` through config loading into the API handler. + +No new endpoint is needed. The field appears alongside existing per-chain fields (contract addresses, asset list, block time) and is read-only from the client's perspective. + +### 6.5 Post-gate reorg detection in the gate + +The `forwardedSet` membership map (paired with the `forwardedQueue` FIFO; both in the `ConfirmationGate` struct, §6.3) provides detection without any DB access. The **drain goroutine** writes to both each time it forwards an event; the **Pusher** reads `forwardedSet` when a `Removed: true` log arrives and finds no live entry in `pending`. + +When `Removed: true` arrives in the Pusher: + +- **`pending[ek] == eventLog.BlockHash`** → normal pre-gate removal; delete from `pending` and return. No log. +- **No pre-gate match, but `forwardedKey{txHash, blockHash, logIndex}` is in `forwardedSet`** → the event was already forwarded to the Reactor and its block has now been reorged out. Log at **`WARN`** with `txHash`, `blockHash`, `logIndex`, `chainID`. `delete(forwardedSet, fk)`. The corresponding `forwardedQueue` entry is left in place — it ages out on its own; the eviction loop's value-check (below) tolerates the early delete. +- **Match in neither** → log at `DEBUG` ("removal for unknown/stale event" — predates the current run or arrived after FIFO eviction). + +`forwardedSet` entries are kept for a small multiple of `delay` — long enough that any `Removed: true` for a forwarded event arrives while the entry is still present, short enough that the map remains bounded. The exact multiplier is an implementation choice (current value: see `recentMultiplier` in `confirmation_gate.go`; e.g. 2 or 3 work in practice). + +Eviction is performed in `drainAndReschedule` (the timer/kick goroutine), not in a separate sweep: + +- Pop the front of `forwardedQueue` while `now − forwardedAt > recentMultiplier × delay`. +- For each popped `forwardedExpiry{key, forwardedAt}`, **delete from `forwardedSet` only if `forwardedSet[key] == forwardedAt`**. The value check guards the rare re-forward case (same key forwarded a second time after the chain un-reorgs back to the original block and a fresh delay elapses): the older FIFO entry must not evict the newer set membership. It also makes the §6.5 early delete (post-gate WARN path) a safe no-op when the eviction loop later visits its `forwardedQueue` sibling. + +`forwardedAt` is the gate's wall-clock at forward time — not `BlockTimestamp` — so FIFO ordering is monotonic regardless of how `arrivedAt` was sourced. The map stays small because post-gate reorgs are rare and `Removed: true` arrives within one or two block-times of the reorg. No separate cleanup goroutine is required. + +### 6.6 Reactor defense-in-depth: skip re-delivered events + +When a re-added event reaches the reactor (same tx re-mined in a new block after a reorg, confirmed by a fresh gate timer), the reactor attempts to process an event it has already committed. This guard converts what is currently a DB constraint-violation error and a full transaction rollback into a clean, explicit logged exit. + +**Important limitation:** this guard identifies events by `(txHash, logIndex, blockchainID)`, where `log_index` is a **block-level** index in go-ethereum — the position of this log among all logs in the entire block, across all transactions. If a transaction is re-mined in a new block where different transactions precede it, its logs receive different block-level `log_index` values. The new `(txHash, newLogIndex, blockchainID)` tuple does not match any committed row, so `IsContractEventProcessed` returns `false` and **the reorged event passes through this check**. In that case the reactor's business-logic idempotency is the actual guard (see below). This guard therefore only catches exact re-deliveries — cases where `log_index` is unchanged. + +Add a new method to `ChannelHubReactorStore`: + +```go +// IsContractEventProcessed reports whether an event identified by +// (txHash, logIndex, blockchainID) has already been committed, +// regardless of which block it appeared in. +// NOTE: uses block-level logIndex — does not detect reorged events +// where the same tx re-mines with a different block-level log position. +IsContractEventProcessed(txHash string, logIndex uint, blockchainID uint64) (bool, error) +``` + +At the top of `HandleEvent`, before entering `useStoreInTx`, call this method. If the event is already committed, log at **`INFO`** ("skipping re-delivered event, already committed") and return `nil` immediately. No transaction is opened; no state is touched. If `IsContractEventProcessed` itself returns an error, `HandleEvent` returns the wrapped error immediately; the listener unsubscribes and the process restarts (per the lifecycle closure in §6.8). On restart, the DB cursor re-fetches the same range and the pre-check retries. + +Reorged events that pass through this check are still neutralized by the reactor's **business-logic idempotency**: + +- `HandleHomeChannelCreated` has an explicit early-return when the channel is already open. +- `HandleHomeChannelCheckpointed` and `RefreshUserEnforcedBalance` use set-semantics (overwrite, not accumulate). +- The `StoreContractEvent` unique constraint on `(transaction_hash, log_index, blockchain_id)` remains as the final backstop for the case where `log_index` happens to be unchanged. + +The value of `IsContractEventProcessed` is therefore: + +1. **Noise reduction for exact re-deliveries** — converts a constraint-violation rollback (logged as an error by the gate's drain goroutine) into a clean INFO exit with no DB transaction opened. +2. **Correctness for the reconciliation walk (§4.4)** — when the node replays already-processed historical events on startup, every re-delivered event would otherwise produce a constraint-violation error and potentially stall the walk. This pre-check makes the reconciliation path viable. + +Together, §6.5 and §6.6 produce two complementary log signals: + +| Signal | Source | Level | Meaning | +| --- | --- | --- | --- | +| "post-gate reorg detected for event X" | Gate | WARN | Committed block was reorged; residual-risk scenario is active | +| "skipping re-delivered event X" | Reactor | INFO | Same tx re-mined at same block position; reactor correctly skips it | + +If the operator sees the WARN but never the INFO, either the transaction was not re-mined, or it was re-mined at a different block position (this check did not fire; business-logic idempotency handled it silently). + +#### Reorg-safe idempotency — separate task + +To make the idempotency check itself robust to reorged events regardless of block position, the idempotency key must be stable across re-mining. The block-level `log_index` is not stable; a **tx-relative log index** is. + +The tx-relative log index is the 0-based position of a log within its own transaction's emitted logs. It is invariant: the same transaction always emits the same logs in the same order, so its tx-relative indices never change across reorgs. The EVM guarantees that all logs of a transaction arrive consecutively in ascending block-level order, so the tx-relative index can be computed in-process as: + +``` +tx_log_index = l.Index - min(l.Index for all logs of l.TxHash in this block) +``` + +No RPC call is required — the minimum is established by the first log of each transaction seen in a block, which always arrives before subsequent logs of the same transaction. + +Implementing this requires: + +- **DB migration**: add `tx_log_index` column to `contract_events`; replace the unique index `(transaction_hash, log_index, blockchain_id)` with `(transaction_hash, tx_log_index, blockchain_id)`. +- **`BlockchainEvent` struct**: add `TxLogIndex uint32` field. +- **Reactor**: maintain a small in-memory map `(blockHash, txHash) → minBlockLogIndex` to compute `tx_log_index` for each incoming event; evict entries when a new block is first seen. +- **`IsContractEventProcessed` and `StoreContractEvent`**: operate on `tx_log_index` instead of `log_index`. + +**This is a separate task.** It is not part of the current confirmation-gate scope. Until it is implemented, the reactor relies on business-logic idempotency for the reorged-different-position case, which is correct but not explicitly guarded at the storage layer. + +### 6.7 Source of `arrivedAt` and the listener's timestamp fallback + +The gate uses the **block timestamp** of each event as its `arrivedAt` reference rather than wall-clock time. This ensures that events replayed from historical blocks (whose timestamps are minutes or hours in the past) are forwarded immediately on the first drain, without waiting for the full confirmation delay to elapse again. + +#### Source of `arrivedAt` + +The gate reads `eventLog.BlockTimestamp` directly from the `types.Log` it receives. It performs no RPC, holds no timestamp cache, and depends on nothing other than the in-memory value on the log struct. The listener guarantees `BlockTimestamp` is non-zero before forwarding a non-removed event to the gate. If the gate ever observes a zero value (defense-in-depth for tests and edge cases), it falls back to `time.Now()` for that single event; the listener owns any operational warning. + +#### Reliability and fallback + +`blockTimestamp` is part of the Ethereum execution JSON-RPC spec (execution-apis `receipt.yaml`, 2024) and is populated by current Geth (≥1.13.10), Erigon, Nethermind, Reth, Besu, recent `bnb-chain/bsc`, Bor, Arbitrum Nitro, and op-geth (Base, Optimism). It is **not** populated by Avalanche C-Chain (`ava-labs/libevm` does not define the field) and is unreliable on older `bsc-dataseed` nodes still in production rotation. + +Therefore the **listener** — not the gate — owns the fallback. Before forwarding a non-removed event to the gate (or to the reactor on the historical bypass), the listener calls `ensureBlockTimestamp`, which uses `eventLog.BlockTimestamp` when present and falls back to one `HeaderByHash(blockHash)` RPC otherwise. A single-entry cache keyed on `lastBlockHash` elides repeat fetches for consecutive events from the same block, which — because the listener delivers events in block order — is the only relevant case. `Removed: true` logs skip `ensureBlockTimestamp` entirely; the gate's cancel path never reads `BlockTimestamp`. +On `HeaderByHash` failure the listener logs a WARN and forwards the event through the gate anyway, where the zero-defense fallback above degrades the entry to a wall-clock delay rather than dropping it silently. + +--- + +### 6.8 Handler error semantics + +When a downstream handler invoked after the confirmation delay returns an error, the gate's `run` goroutine returns the error and the gate's lifecycle closure (passed to `Start`) is invoked with it. In `nitronode/main.go`, that closure calls `logger.Fatal` → process exit. The supervisor restarts the process; the next `Listen` invocation re-fetches the unstored event via the DB cursor in `findCommonAncestor` + Phase 1 reconciliation, restoring the pre-PR crash-restart-replay invariant. The gate does **not** retry handler errors in-process; this is intentional and matches pre-PR behavior. Events queued behind the failed event are dropped on teardown and re-fetched after restart. The gate's lifecycle (`Start(ctx, handleClosure)`) is identical to `Listener.Listen` and `BlockchainWorker.Start`; the listener does not know that its downstream handler may fail asynchronously — error propagation is handled by the supervisor (`main.go`), where it already is for the other two components. + +--- + +### 6.9 Subscription drop and gate reset + +When the live WebSocket subscription drops, the listener treats this as a recoverable event: it flushes the gate's pending state in-process and re-reads the committed cursor before retrying. + +**The bug without this fix.** If the subscription drops *after* a live event has been queued in the gate's `pending` map but *before* the matching `Removed:true` arrives, geth will not replay the removal on the fresh subscription. The orphaned entry matures and reaches the reactor as if canonical, with the wrong `blockHash`. Worse, the listener's in-memory `lastBlock` variable has been advanced (by the Phase 2 write at `listener.go`) to the handed-off live event's height — above the committed cursor. On reconnect, `reconcileBlockRange(lastBlock, tip)` covers only `(lastLiveBlock, tip]`, missing every block the gate was holding. The orphan can commit stale state and the gap is never replayed. + +**The fix — `findCommonAncestor` inside `runOneListenPass`.** `findCommonAncestor` is called inside `runOneListenPass`, which is invoked on **every** reconnect iteration. Its return value becomes a fresh local `lastBlock` variable that dies when the pass returns — the in-memory advanced cursor never persists across iterations. This is the structural fix: the committed-only DB cursor is always re-read before Phase 1 begins. + +**`ConfirmationGate.FlushPending`.** Called from `listenEvents` at the start of each iteration (before `runOneListenPass`), it zeros `g.queue` and `g.pending` under `g.mu`. The drain goroutine continues to run; if it is mid-handler when `FlushPending` is called, the flush blocks on `g.mu` until the handler returns — the in-flight event is committed (or the handler returns error → §6.8 Fatal path) before the flush observes the cleared state. `FlushPending` intentionally does **not** clear `forwardedSet` — see below. + +**`forwardedSet` retention.** `forwardedSet` is retained across `FlushPending` for two load-bearing reasons: + +1. **Post-gate WARN observability.** In adjacent scenarios (a `Removed:true` arriving on a *still-live* subscription that the listener subsequently tears down), the WARN window survives reconnects only if `forwardedSet` is retained. This is an observability difference from Approach S (process restart) which is documented in `pr-832-open-comments.md §Comment 10`. +2. **Re-forward eviction guard correctness.** The `storedAt.Equal(popped.forwardedAt)` guard at `confirmation_gate.go:281-288` is load-bearing for correctness when the same entry key is forwarded a second time after a flush. If `forwardedSet` were cleared, an older `forwardedQueue` entry (from before the flush) could later evict the newer set membership inserted by the re-forward, creating a window where a spurious post-gate `Removed:true` would not trigger the WARN. Retaining `forwardedSet` and relying on the existing guard is the safe design. + +**Cursor invariant.** `findCommonAncestor` MUST live inside `runOneListenPass` for correctness. If it were called once in the outer `listenEvents` loop, the in-memory `lastBlock` advanced by Phase 2 would persist across reconnects and Phase 1 on the retry would scan only `(lastLiveBlock, tip]` — missing every uncommitted block the gate was holding. See "Cursor-lifecycle defect that rules out flush-only variants" in `pr-832-open-comments.md` for the full trace. + +**Cost of `findCommonAncestor` per iteration.** The function's cost is bounded by the number of `contract_events` rows for the contract on the given chain. On a healthy reconnect (first stored block still canonical), it executes two queries: `GetLatestContractEventBlockHashAndNumber` + one `HeaderByNumber` canonicality check. Pathological cost (walking many reorged rows) only occurs when every stored block was reorged out — rare and bounded by the number of distinct block heights in `contract_events`. + +**Drain-mid-handler ordering during flush.** `drainAndReschedule` releases `g.mu` before calling the handler (`confirmation_gate.go:262-273`) and re-acquires it after. A `FlushPending` call that arrives during this window blocks on `g.mu` until the handler returns. If the handler commits the event (returns nil), the flush observes `forwardedSet` containing the freshly forwarded entry — correct. If the handler returns an error, the gate's `run` returns the error → §6.8 lifecycle closure → `logger.Fatal` → process exit. The flush never observes the error but also never returns (process exits first). Acceptable. + +**Backoff on subscription drop.** Each reconnect after a mid-Phase-2 drop increments `backOffCount` in the `listenEvents` outer loop. This bounds the reconnect rate on chronically flaky RPC endpoints — idle-timeout drops on proxied WebSocket endpoints (common at 60s intervals) and provider-side disconnects (common every few hours) are genuine operational hazards on every target chain. The initial-subscription failures at `SubscribeFilterLogs` and `HeaderByNumber` also increment `backOffCount` inside `runOneListenPass` (unchanged behavior). + +**`FlushPending` is safe at any time after `NewConfirmationGate`.** It is zero-value-safe even before `Start` is called: `queue` is nil (nil append is a no-op, `queue = nil` is a no-op), and `pending` is re-initialized with `make(map[eventKey]common.Hash)` which is always safe. The drain goroutine need not be running for `FlushPending` to be called. + +**Comparison with Approach S (process restart).** Handler errors continue to take the Fatal + supervisor-restart path (§6.8); only subscription drops use this in-process recovery. The single observable difference between in-process recovery and restart is `forwardedSet` retention: on restart, `forwardedSet` is wiped; on in-process recovery, it is preserved. In the subscription-drop scenario, geth's per-subscription `Removed:true` contract means the WARN cannot fire anyway (the subscription that delivered the forward already died), so the delta is unobservable in the target scenario. For adjacent scenarios (still-live subscription torn down), the WARN is preserved only by the in-process approach. + +--- + +## 7. Client-side implications + +The confirmation gate changes one observable contract for every client of the node: **an on-chain event +is not reflected in off-chain state until the gate window elapses.** A transaction receipt (`checkpoint`, +deposit, withdrawal) no longer means the off-chain balance has been credited — it means the countdown has +started. + +### 7.1 What clients should display + +- After an on-chain operation, treat the mined receipt as an intermediate state ("transaction mined, + awaiting node confirmation (~Ns)"), not as success. +- Surface the per-chain delay to the user so the wait is expected rather than perceived as a hang. On + chains where the gate is enabled this is a few seconds; at hard-finality settings it can reach ~13 min + on Ethereum L1. +- Defer the "credited" / success signal until the off-chain balance actually reflects the change, or split + it into two signals: "transaction mined" and "credit confirmed". +- Off-chain transfers are **not** gated (they never touch the chain) and remain instant — do not apply the + delay to them. + +### 7.2 Where the delay comes from + +The per-chain delay is exposed through **`node.v1.GetConfig`**: each `BlockchainInfoV1` entry carries +`confirmation_delay_secs` (`uint32`). A value of `0` means the gate is disabled and credit is immediate. + +| Surface | Field | +| --- | --- | +| Wire (JSON, `node.v1.GetConfig`) | `confirmation_delay_secs` | +| Go SDK (`core.Blockchain`) | `ConfirmationDelaySecs` | +| TS SDK (`core.Blockchain`) | `confirmationDelaySecs` | +| Config (`blockchains.yaml`) | `confirmation_delay_secs` | + +### 7.3 Polling for the credit + +A client confirms the credit by polling balance/state after submitting the tx, waiting at least +`confirmation_delay_secs` before treating the absence of a credit as final: + +- **TS/Go SDK:** the SDKs provide `getConfirmationDelay(chainId)` (returns the configured delay) and + `waitForCheckpoint(asset, txHash)` (polls `getBalances`/`getLatestState` with a lower-bound wait of the + chain's `confirmation_delay_secs`). Prefer these over hand-rolled polling. +- **Compat layer (`@yellow-org/sdk-compat`):** the `EventPoller` (5s polling) already tolerates an + arbitrary delay; no code change is needed, only awareness that the event fires later than the receipt. +- **Manual clients:** poll `getBalances` no more than once per few seconds; do not poll faster than the + delay, and do not interpret a still-uncredited balance before the window elapses as a failure. + +### 7.4 Residual-risk note + +When `confirmation_delay_secs` is set below the chain's hard-finality time (§2.1), a credited event can in +rare cases still be reorged out after the gate passes. Clients that surface "confirmed" to end users should +be aware this is a probabilistic guarantee at the configured setting, not absolute finality, unless the +operator has set the delay to the chain's hard-finality time. diff --git a/nitronode/event_handlers/service.go b/nitronode/event_handlers/service.go index 732a76621..06cd85a1f 100644 --- a/nitronode/event_handlers/service.go +++ b/nitronode/event_handlers/service.go @@ -12,18 +12,40 @@ import ( ) var _ core.ChannelHubEventHandler = &EventHandlerService{} -var _ core.LockingContractEventHandler = &EventHandlerService{} + +// refreshMaxAttempts bounds the number of FetchChannel attempts the home-channel +// guard-drop path will make before giving up. Worst-case wall-clock is the sum +// of refreshDefaultBackoffSchedule plus per-attempt RPC timeouts inside +// FetchChannel (currently 5s each). +const refreshMaxAttempts = 3 + +// refreshDefaultBackoffSchedule is the package-default backoff between +// consecutive FetchChannel attempts. The number of entries must be +// refreshMaxAttempts - 1. Tests can override per-service via +// EventHandlerService.refreshBackoff. +var refreshDefaultBackoffSchedule = []time.Duration{ + 100 * time.Millisecond, + 200 * time.Millisecond, +} // EventHandlerService processes blockchain events and updates the local database state accordingly. // It handles events from both home channels (user state channels) and escrow channels (temporary lock channels). type EventHandlerService struct { nodeSigner *core.ChannelDefaultSigner statePacker core.StatePacker + // refreshBackoff overrides the default backoff schedule used between + // FetchChannel retries in refreshAfterDroppedEvent. When nil the package + // default (refreshDefaultBackoffSchedule) is used. Tests set this to a + // zero-valued slice to avoid actually sleeping. + refreshBackoff []time.Duration } // NewEventHandlerService creates a new EventHandlerService instance. // nodeSigner and statePacker are used to backfill the node signature on the -// checkpointed head state when it is missing from the local record. +// checkpointed head state when it is missing from the local record. The +// on-chain refresh used by home-channel guard-drop paths is supplied per-call +// as a core.ReadOnlyChannelHub parameter by the reactor that owns the chain, +// not stored on the service. func NewEventHandlerService(nodeSigner *core.ChannelDefaultSigner, statePacker core.StatePacker) *EventHandlerService { return &EventHandlerService{ nodeSigner: nodeSigner, @@ -31,6 +53,166 @@ func NewEventHandlerService(nodeSigner *core.ChannelDefaultSigner, statePacker c } } +// guardEventVersionMonotonic returns true (drop=true) when the incoming event's +// StateVersion is strictly less than the row's current StateVersion. The caller +// must `return nil` (or, for home-channel handlers, `return s.refreshAfterDroppedEvent(...)`) +// immediately when drop is true; the helper logs a structured warning identifying +// which event intent arrived stale. +// +// Intent is a short stable string describing the on-chain transition the dropped +// event would have applied (e.g. "checkpointed", "closed", "escrow_deposit_initiated"). +// It surfaces in the warn log so operators can correlate drops with reentrancy or +// reorg-replay events. +func guardEventVersionMonotonic( + ctx context.Context, + logger log.Logger, + chanID string, + intent string, + eventVersion uint64, + currentVersion uint64, +) (drop bool) { + if eventVersion >= currentVersion { + return false + } + logger.Warn("event state version is less than current channel state version, ignoring", + "channelId", chanID, + "intent", intent, + "currentStateVersion", currentVersion, + "eventStateVersion", eventVersion) + return true +} + +// refreshAfterDroppedEvent fetches the authoritative on-chain channel snapshot +// via the supplied ReadOnlyChannelHub and overwrites the local row's status, +// state version, and challenge expiry, then backfills the user signature on +// the chain-asserted version. Called from the home-channel guard-drop paths to +// close the observability gap described in §B. +// +// Retry-and-continue error contract: +// - hub returns nil snapshot on any attempt: row converged, returns nil so +// the dedup ledger advances. +// - hub returns an error: retries with bounded exponential backoff +// (refreshMaxAttempts total attempts, sleeps from refreshBackoff or +// refreshDefaultBackoffSchedule between attempts — by default 100ms+200ms +// gaps, ~700ms worst-case before giving up; per-attempt RPC timeout is +// enforced inside FetchChannel). If every attempt fails, logs at Error +// level and returns nil. The outer tx still commits — the dedup row is +// recorded and the listener moves on, but the local channel row stays at +// whatever the inner higher-version event already set it to. The row may +// stay divergent from chain — for terminal Closed states this may be +// indefinite because no future event for the channel will arrive — but +// this is strictly better than `logger.Fatal`-ing the node on a sustained +// RPC outage. ctx cancellation during a backoff returns nil immediately +// and does NOT propagate up (which would trigger logger.Fatal in the +// listener). +// +// hub is supplied by the reactor that owns this channel's chain; it must be +// non-nil on the guard-drop paths. +// fetchSnapshotWithRetry calls hub.FetchChannel up to refreshMaxAttempts times, +// sleeping s.refreshBackoff (or refreshDefaultBackoffSchedule when nil) between +// consecutive attempts. droppedIntent is included in per-attempt warning logs +// so operators can correlate retries with the originating guard-drop. +// +// Returns: +// - (snapshot, nil) on the first successful FetchChannel. +// - (nil, ctx.Err()) if ctx is cancelled during a backoff sleep. +// - (nil, lastErr) if every attempt failed. +// +// The caller decides what to do with the error; this helper never logs at +// Error level and never returns a wrapped error. +func (s *EventHandlerService) fetchSnapshotWithRetry( + ctx context.Context, + hub core.ReadOnlyChannelHub, + channelID string, + droppedIntent string, +) (*core.OnChainChannelSnapshot, error) { + logger := log.FromContext(ctx) + + backoff := s.refreshBackoff + if backoff == nil { + backoff = refreshDefaultBackoffSchedule + } + + var lastErr error + for attempt := range refreshMaxAttempts { + snapshot, err := hub.FetchChannel(ctx, channelID) + if err == nil { + return snapshot, nil + } + lastErr = err + logger.Warn("refresh after dropped event attempt failed, will retry", + "channelId", channelID, + "droppedIntent", droppedIntent, + "attempt", attempt+1, + "maxAttempts", refreshMaxAttempts, + "error", err) + if attempt == refreshMaxAttempts-1 { + break + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(backoff[attempt]): + } + } + return nil, lastErr +} + +func (s *EventHandlerService) refreshAfterDroppedEvent( + ctx context.Context, + tx core.ChannelHubEventHandlerStore, + hub core.ReadOnlyChannelHub, + channel *core.Channel, + droppedIntent string, +) error { + logger := log.FromContext(ctx) + + refreshed, err := s.fetchSnapshotWithRetry(ctx, hub, channel.ChannelID, droppedIntent) + if err != nil { + if ctx.Err() != nil { + // Ctx cancelled mid-retry. Do NOT propagate ctx.Err() upward — the + // listener escalates non-nil returns from event handlers to + // logger.Fatal. + return nil + } + logger.Error("refresh after dropped event failed after retries, leaving row possibly divergent from chain", + "channelId", channel.ChannelID, + "droppedIntent", droppedIntent, + "attempts", refreshMaxAttempts, + "error", err) + return nil + } + + channel.Status = refreshed.Status + channel.StateVersion = refreshed.StateVersion + channel.ChallengeExpiresAt = refreshed.ChallengeExpiresAt + + if err := tx.UpdateChannel(*channel); err != nil { + return err + } + if err := tx.RefreshUserEnforcedBalance(channel.UserWallet, channel.Asset); err != nil { + return err + } + // Skip the sig backfill when the chain payload carried no user signature + // (e.g. some terminal states do not echo the candidate sig back into + // ChannelMeta.lastState). UpdateStateSigsIfMissing would otherwise be a + // no-op with an empty userSig but the explicit guard documents intent. + if refreshed.LastStateUserSig != "" { + if err := tx.UpdateStateSigsIfMissing(channel.ChannelID, refreshed.StateVersion, refreshed.LastStateUserSig, ""); err != nil { + return err + } + } + + logger.Info("refreshed channel from chain after dropped event", + "channelId", channel.ChannelID, + "droppedIntent", droppedIntent, + "refreshedStatus", refreshed.Status, + "refreshedStateVersion", refreshed.StateVersion, + "refreshedChallengeExpiresAt", refreshed.ChallengeExpiresAt, + ) + return nil +} + // HandleNodeBalanceUpdated processes the NodeBalanceUpdated event emitted when the node's // on-chain liquidity changes. It records the new node liquidity for the (blockchain, asset) // pair via SetNodeBalance; this is observability data only and does not affect user staking @@ -57,7 +239,11 @@ func (s *EventHandlerService) HandleNodeBalanceUpdated(ctx context.Context, tx c func (s *EventHandlerService) HandleHomeChannelCreated(ctx context.Context, tx core.ChannelHubEventHandlerStore, event *core.HomeChannelCreatedEvent) error { logger := log.FromContext(ctx) chanID := event.ChannelID - channel, err := tx.GetChannelByID(chanID) + // Acquire the user's balance-row lock and read the channel under it before mutating status: + // the lock guards against a concurrent submit_state flipping channel status (e.g. Void→Open + // receiver issuance) between the read and our write. See HandleHomeChannelCheckpointed and + // HandleHomeChannelClosed for the same pattern. + channel, err := tx.LockUserStateForHomeChannel(chanID) if err != nil { return err } @@ -69,6 +255,7 @@ func (s *EventHandlerService) HandleHomeChannelCreated(ctx context.Context, tx c logger.Warn("channel type mismatch during HomeChannelCreated event", "channelId", chanID, "expectedType", core.ChannelTypeHome, "actualType", channel.Type) return nil } + if channel.Status >= core.ChannelStatusOpen { logger.Warn("ignoring replayed HomeChannelCreated event on already-initialized channel", "channelId", chanID, "currentStatus", channel.Status, "currentStateVersion", channel.StateVersion, "eventStateVersion", event.StateVersion) @@ -90,6 +277,17 @@ func (s *EventHandlerService) HandleHomeChannelCreated(ctx context.Context, tx c return err } + // Backfill the node signature on any unsigned receiver-credit state that + // landed while the channel was still Void. An unsigned head is possible when + // an incoming transfer or release was stored by a concurrent RPC before this + // Created event arrived and flipped the channel to Open. The guard inside + // backfillOffChainHeadNodeSig ensures only transfer_receive / release heads + // are signed, so this is a no-op for the normal case where the head is the + // CREATE state itself (already node-signed via the RPC path). + if err := s.backfillOffChainHeadNodeSig(ctx, tx, event.ChannelID); err != nil { + return err + } + logger.Info("handled HomeChannelCreated event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } @@ -110,10 +308,20 @@ func (s *EventHandlerService) HandleHomeChannelMigrated(ctx context.Context, tx // is restored instead. Without that restore, a Closing → Challenged → Open sequence driven by // on-chain events would erase the fact that the node has already signed a finalized state, and // CheckActiveChannel would let the user submit further transitions past the finalized state. -func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, tx core.ChannelHubEventHandlerStore, event *core.HomeChannelCheckpointedEvent) error { +func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, tx core.ChannelHubEventHandlerStore, hub core.ReadOnlyChannelHub, event *core.HomeChannelCheckpointedEvent) error { logger := log.FromContext(ctx) chanID := event.ChannelID - channel, err := tx.GetChannelByID(chanID) + // Acquire the user's balance-row lock and read the channel under it before mutating + // channel status or backfilling the off-chain head. Reading the channel before the + // lock would race: a concurrent submit_state can co-sign a Finalize and flip the + // channel Open→Closing between the read and the lock, and the non-challenged path + // below persists the channel snapshot verbatim — a pre-lock Open snapshot would + // silently reopen the finalized channel. Receiver issuance paths lock the same row + // and then re-check Status; without this lock an RPC can read Status=Challenged, + // decide to store an unsigned receiver row, but commit after we flip to Open and + // backfill the prior head — leaving the latest head unsigned on an Open channel. See + // HandleHomeChannelClosed for the same pattern. + channel, err := tx.LockUserStateForHomeChannel(chanID) if err != nil { return err } @@ -126,19 +334,35 @@ func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, return nil } - // Acquire the user's balance-row lock before mutating channel status or - // backfilling the off-chain head. Receiver issuance paths lock the same row - // and then re-check Status; without this lock an RPC can read Status=Challenged, - // decide to store an unsigned receiver row, but commit after we flip to Open - // and backfill the prior head — leaving the latest head unsigned on an Open - // channel. See HandleHomeChannelClosed for the same pattern. - if _, err := tx.LockUserState(channel.UserWallet, channel.Asset); err != nil { - return err + // Per protocol the checkpointed version cannot be lower than the last known on-chain + // version. This branch is reachable when contract reentrancy emits an inner + // higher-version event before the outer ChannelCheckpointed. Drop the event so we do + // not regress channel.StateVersion and, critically, so the wasChallenged branch below + // does not flip a live challenge back to Open based on a stale version. + if guardEventVersionMonotonic(ctx, logger, chanID, "checkpointed", event.StateVersion, channel.StateVersion) { + return s.refreshAfterDroppedEvent(ctx, tx, hub, channel, "checkpointed") } channel.StateVersion = event.StateVersion - wasChallenged := channel.Status == core.ChannelStatusChallenged + // Snapshot the pre-checkpoint status once and derive both transition flags from it, so the + // Void and Challenged branches below are independent of each other's mutation order: either + // branch may reassign channel.Status without silently making the other unreachable. + prevStatus := channel.Status + wasVoid := prevStatus == core.ChannelStatusVoid + wasChallenged := prevStatus == core.ChannelStatusChallenged + + // ChannelHub.createChannel can emit ChannelCheckpointed before ChannelCreated for an + // initial non-deposit/non-withdraw state. If this checkpoint is processed while the + // local row is still the Void seed from CreateChannel, the on-chain checkpoint is + // sufficient evidence that the channel has been materialized: promote Void to Open + // here instead of leaving a bumped state_version on a Void channel until the later + // ChannelCreated event replays. That replay then no-ops via the Status >= Open guard + // in HandleHomeChannelCreated. + if wasVoid { + channel.Status = core.ChannelStatusOpen + } + if wasChallenged { // Reconstruct the post-Finalize Closing marker from channel_states: if the node // has already signed a Finalize state for this channel, the off-chain close is @@ -176,9 +400,11 @@ func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, // When a challenge is cleared, the off-chain head may sit above event.StateVersion: // any receiver state issued during the challenge was stored unsigned and is now the // channel's actual latest state. Backfill the node signature on that head so future - // flows treat it as fully co-signed. On normal Open→Open checkpoints the head row - // is already node-signed via the RPC path and this is a no-op. - if wasChallenged { + // flows treat it as fully co-signed. The same applies to a Void→Open promotion: a + // concurrent RPC may have stored an unsigned receiver head while the channel was + // still Void (mirrors HandleHomeChannelCreated). On normal Open→Open checkpoints the + // head row is already node-signed via the RPC path and this is a no-op. + if wasChallenged || wasVoid { if err := s.backfillOffChainHeadNodeSig(ctx, tx, event.ChannelID); err != nil { return err } @@ -191,8 +417,13 @@ func (s *EventHandlerService) HandleHomeChannelCheckpointed(ctx context.Context, // backfillOffChainHeadNodeSig loads the off-chain head state for channelID (the highest // stored version, regardless of signature status) and node-signs it when the row is // present and the node signature is missing. The user signature is intentionally left -// untouched: when the head was created during a challenge it carries no user signature, -// and the user must countersign and acknowledge it via the regular RPC flow. +// untouched: the user must countersign and acknowledge it via the regular RPC flow. +// +// Called from two contexts: +// - challenge clearance (HandleHomeChannelCheckpointed): the head is an unsigned +// receiver credit accumulated during the dispute window. +// - channel open (HandleHomeChannelCreated): the head is an unsigned receiver credit +// stored by a concurrent RPC while the channel was still Void. func (s *EventHandlerService) backfillOffChainHeadNodeSig(ctx context.Context, tx core.ChannelHubEventHandlerStore, channelID string) error { head, err := tx.GetLastStateByChannelID(channelID, false) if err != nil { @@ -201,14 +432,12 @@ func (s *EventHandlerService) backfillOffChainHeadNodeSig(ctx context.Context, t if head == nil || head.NodeSig != nil { return nil } - // Per the challenge-clearance spec the only states accumulated during the dispute - // window are receiver credits (transfer_receive, release) — user-initiated ops are - // rejected upstream while the channel is Challenged. If the head is some other - // transition kind, the invariant has broken upstream and we must not silently - // node-sign it. Log it and bail so the caller surfaces the inconsistency. + // Only receiver credits (transfer_receive, release) should appear as unsigned heads + // in either call context. Any other transition kind means an invariant broke upstream; + // do not silently node-sign it — log and bail so the caller surfaces the inconsistency. if head.Transition.Type != core.TransitionTypeTransferReceive && head.Transition.Type != core.TransitionTypeRelease { - log.FromContext(ctx).Warn("off-chain head after challenge clearance is not a receiver state, skipping node-sig backfill", + log.FromContext(ctx).Warn("off-chain head is not a receiver state, skipping node-sig backfill", "channelId", channelID, "transitionType", head.Transition.Type, "version", head.Version, @@ -239,10 +468,18 @@ func (s *EventHandlerService) backfillOffChainHeadNodeSig(ctx context.Context, t // be resolved via ScheduleCheckpoint, and silently queueing an impossible transaction risks // letting the challenge expire on a stale state. A warning is emitted so operators submit the // appropriate on-chain action manually before expiry. -func (s *EventHandlerService) HandleHomeChannelChallenged(ctx context.Context, tx core.ChannelHubEventHandlerStore, event *core.HomeChannelChallengedEvent) error { +func (s *EventHandlerService) HandleHomeChannelChallenged(ctx context.Context, tx core.ChannelHubEventHandlerStore, hub core.ReadOnlyChannelHub, event *core.HomeChannelChallengedEvent) error { logger := log.FromContext(ctx) chanID := event.ChannelID - channel, err := tx.GetChannelByID(chanID) + // Acquire the user's balance-row lock and read the channel under it before mutating + // channel status. Receiver issuance paths (issueTransferReceiverState / + // issueReleaseReceiverState) lock the same row up front and then re-check Status via + // CheckActiveChannel; without this lock an in-flight RPC can read Status=Open, + // node-sign a receiver state, and commit after we flip to Challenged — leaving a + // node-signed higher-version receiver state on a disputed channel. The lock+read is a + // single store call so the channel snapshot is consistent with the lock. See + // HandleHomeChannelClosed for the same pattern. + channel, err := tx.LockUserStateForHomeChannel(chanID) if err != nil { return err } @@ -255,22 +492,11 @@ func (s *EventHandlerService) HandleHomeChannelChallenged(ctx context.Context, t return nil } - // Acquire the user's balance-row lock before mutating channel status. Receiver - // issuance paths (issueTransferReceiverState / issueReleaseReceiverState) lock - // the same row up front and then re-check Status via CheckActiveChannel; without - // this lock an in-flight RPC can read Status=Open, node-sign a receiver state, - // and commit after we flip to Challenged — leaving a node-signed higher-version - // receiver state on a disputed channel. See HandleHomeChannelClosed for the same - // pattern. - if _, err := tx.LockUserState(channel.UserWallet, channel.Asset); err != nil { - return err - } - - if event.StateVersion < channel.StateVersion { - // Per protocol the challenged version cannot be lower than the last known on-chain version. - // Treat as an anomaly (replay, indexer mis-order, contract bug): warn and skip persistence. - logger.Warn("challenged state version is less than current channel state version, ignoring", "channelId", chanID, "currentStateVersion", channel.StateVersion, "challengedStateVersion", event.StateVersion) - return nil + // Per protocol the challenged version cannot be lower than the last known on-chain version. + // Treat as an anomaly (replay, indexer mis-order, contract reentrancy): drop the event and + // refresh from chain to converge the local row with the authoritative on-chain status. + if guardEventVersionMonotonic(ctx, logger, chanID, "challenged", event.StateVersion, channel.StateVersion) { + return s.refreshAfterDroppedEvent(ctx, tx, hub, channel, "challenged") } channel.StateVersion = event.StateVersion @@ -330,10 +556,17 @@ func (s *EventHandlerService) HandleHomeChannelChallenged(ctx context.Context, t // Subsequent receiver-credit issuance reads the rescue row as currentState and no // longer carries the closed channel reference, so request_creation can reopen on the // same (wallet, asset) through the normal flow. -func (s *EventHandlerService) HandleHomeChannelClosed(ctx context.Context, tx core.ChannelHubEventHandlerStore, event *core.HomeChannelClosedEvent) error { +func (s *EventHandlerService) HandleHomeChannelClosed(ctx context.Context, tx core.ChannelHubEventHandlerStore, hub core.ReadOnlyChannelHub, event *core.HomeChannelClosedEvent) error { logger := log.FromContext(ctx) chanID := event.ChannelID - channel, err := tx.GetChannelByID(chanID) + // Acquire the user's balance-row lock and read the channel under it before mutating + // channel status or summing receiver credits. issueTransferReceiverState / + // issueReleaseReceiverState lock the same row up front, so this serializes the close + // against any in-flight RPC receiver-issuance for the same user: either the RPC commits + // its unsigned row before we sum (it lands in the rescue), or it blocks until we set the + // channel to Closed and then sees that via its own re-check. The lock+read is a single + // store call so the channel snapshot is consistent with the lock. + channel, err := tx.LockUserStateForHomeChannel(chanID) if err != nil { return err } @@ -346,14 +579,9 @@ func (s *EventHandlerService) HandleHomeChannelClosed(ctx context.Context, tx co return nil } - // Acquire the user's balance-row lock before mutating channel status or summing - // receiver credits. issueTransferReceiverState / issueReleaseReceiverState lock - // the same row up front, so this serializes the close against any in-flight RPC - // receiver-issuance for the same user: either the RPC commits its unsigned row - // before we sum (it lands in the rescue), or it blocks until we set the channel - // to Closed and then sees that via its own re-check. - if _, err := tx.LockUserState(channel.UserWallet, channel.Asset); err != nil { - return err + // Drop stale Closed events that would regress state_version. + if guardEventVersionMonotonic(ctx, logger, chanID, "closed", event.StateVersion, channel.StateVersion) { + return s.refreshAfterDroppedEvent(ctx, tx, hub, channel, "closed") } wasChallenged := channel.Status == core.ChannelStatusChallenged @@ -502,6 +730,10 @@ func (s *EventHandlerService) HandleEscrowDepositInitiated(ctx context.Context, return nil } + if guardEventVersionMonotonic(ctx, logger, chanID, "escrow_deposit_initiated", event.StateVersion, channel.StateVersion) { + return nil + } + channel.StateVersion = event.StateVersion channel.Status = core.ChannelStatusOpen @@ -670,6 +902,10 @@ func (s *EventHandlerService) HandleEscrowDepositFinalized(ctx context.Context, return nil } + if guardEventVersionMonotonic(ctx, logger, chanID, "escrow_deposit_finalized", event.StateVersion, channel.StateVersion) { + return nil + } + channel.StateVersion = event.StateVersion channel.Status = core.ChannelStatusClosed // Channel is terminal; any pending challenge deadline is no longer meaningful. @@ -744,6 +980,10 @@ func (s *EventHandlerService) HandleEscrowWithdrawalInitiated(ctx context.Contex return nil } + if guardEventVersionMonotonic(ctx, logger, chanID, "escrow_withdrawal_initiated", event.StateVersion, channel.StateVersion) { + return nil + } + channel.StateVersion = event.StateVersion channel.Status = core.ChannelStatusOpen @@ -838,6 +1078,10 @@ func (s *EventHandlerService) HandleEscrowWithdrawalFinalized(ctx context.Contex return nil } + if guardEventVersionMonotonic(ctx, logger, chanID, "escrow_withdrawal_finalized", event.StateVersion, channel.StateVersion) { + return nil + } + channel.StateVersion = event.StateVersion channel.Status = core.ChannelStatusClosed // Channel is terminal; any pending challenge deadline is no longer meaningful. @@ -854,14 +1098,3 @@ func (s *EventHandlerService) HandleEscrowWithdrawalFinalized(ctx context.Contex logger.Info("handled EscrowWithdrawalFinalized event", "channelId", event.ChannelID, "stateVersion", event.StateVersion, "userWallet", channel.UserWallet) return nil } - -func (s *EventHandlerService) HandleUserLockedBalanceUpdated(ctx context.Context, tx core.LockingContractEventHandlerStore, event *core.UserLockedBalanceUpdatedEvent) error { - logger := log.FromContext(ctx) - err := tx.UpdateUserStaked(event.UserAddress, event.BlockchainID, event.Balance) - if err != nil { - return err - } - - logger.Info("handled UserLockedBalanceUpdatedEvent event", "userWallet", event.UserAddress, "blockchainID", event.BlockchainID, "balance", event.Balance) - return nil -} diff --git a/nitronode/event_handlers/service_test.go b/nitronode/event_handlers/service_test.go index def94ca2a..a9bf21528 100644 --- a/nitronode/event_handlers/service_test.go +++ b/nitronode/event_handlers/service_test.go @@ -43,7 +43,7 @@ func TestHandleHomeChannelCreated_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetChannelByID", channelID).Return(channel, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.ChannelID == channelID && ch.Status == core.ChannelStatusOpen && @@ -51,6 +51,8 @@ func TestHandleHomeChannelCreated_Success(t *testing.T) { })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(1), "", "").Return(nil) + // backfillOffChainHeadNodeSig: no unsigned head present → no-op. + mockStore.On("GetLastStateByChannelID", channelID, false).Return(nil, nil) // Execute err := service.HandleHomeChannelCreated(ctx, mockStore, event) @@ -98,7 +100,8 @@ func TestHandleHomeChannelCreated_IgnoresReplayOnInitializedChannel(t *testing.T StateVersion: 1, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) + // LockUserStateForHomeChannel is called before the replay guard, so it fires even on replays. + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) err := service.HandleHomeChannelCreated(ctx, mockStore, event) @@ -111,6 +114,217 @@ func TestHandleHomeChannelCreated_IgnoresReplayOnInitializedChannel(t *testing.T } } +// TestHandleHomeChannelCreated_AcquiresUserLockBeforeMutation pins the race fix: +// the handler must call LockUserStateForHomeChannel before UpdateChannel so an +// in-flight receiver-issuance RPC cannot read Status=Void, store an unsigned receiver +// row, and commit before we flip to Open. See HandleHomeChannelCheckpointed and +// HandleHomeChannelClosed for the same pattern. +func TestHandleHomeChannelCreated_AcquiresUserLockBeforeMutation(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "usdc" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusVoid, + StateVersion: 0, + } + + event := &core.HomeChannelCreatedEvent{ + ChannelID: channelID, + StateVersion: 1, + } + + var locked bool + mockStore.On("LockUserStateForHomeChannel", channelID). + Run(func(mock.Arguments) { locked = true }). + Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(core.Channel) bool { + require.True(t, locked, "LockUserStateForHomeChannel must be called before UpdateChannel") + return true + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(1), "", "").Return(nil) + // backfillOffChainHeadNodeSig: no unsigned head → no-op. + mockStore.On("GetLastStateByChannelID", channelID, false).Return(nil, nil) + + err := service.HandleHomeChannelCreated(ctx, mockStore, event) + require.NoError(t, err) + mockStore.AssertExpectations(t) +} + +// TestHandleHomeChannelCreated_BackfillsUnsignedReceiverStateOnOpen covers the race path +// where a transfer_receive or release state was stored unsigned while the channel was still +// Void — because CheckActiveChannel returned Void at the time of issuance. Once the +// HomeChannelCreated event opens the channel, the handler must backfill the node signature +// on the unsigned head so future flows treat the credit as fully co-signed. +// Both allowed backfill transition types are covered. +func TestHandleHomeChannelCreated_BackfillsUnsignedReceiverStateOnOpen(t *testing.T) { + cases := []struct { + name string + transitionType core.TransitionType + }{ + {"transfer_receive", core.TransitionTypeTransferReceive}, + {"release", core.TransitionTypeRelease}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, nodeAddress := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "USDC" + homeChannelIDPtr := channelID + createVersion := uint64(0) + headVersion := uint64(1) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusVoid, + StateVersion: createVersion, + } + + // Unsigned receiver credit stored before the channel opened. + headState := &core.State{ + ID: core.GetStateID(userWallet, asset, 0, headVersion), + Asset: asset, + UserWallet: userWallet, + Epoch: 0, + Version: headVersion, + HomeChannelID: &homeChannelIDPtr, + Transition: core.Transition{Type: tc.transitionType}, + HomeLedger: core.Ledger{ + TokenAddress: "0xtoken", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(100), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + } + + event := &core.HomeChannelCreatedEvent{ + ChannelID: channelID, + StateVersion: createVersion, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == createVersion + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + // Sig backfill for the create state (event.UserSig is empty). + mockStore.On("UpdateStateSigsIfMissing", channelID, createVersion, "", "").Return(nil) + // backfillOffChainHeadNodeSig finds the unsigned receiver credit above the create state. + mockStore.On("GetLastStateByChannelID", channelID, false).Return(headState, nil) + + var capturedNodeSig string + mockStore.On("UpdateStateSigsIfMissing", channelID, headVersion, "", mock.AnythingOfType("string")). + Run(func(args mock.Arguments) { + capturedNodeSig = args.String(3) + }).Return(nil) + + err := service.HandleHomeChannelCreated(ctx, mockStore, event) + require.NoError(t, err) + require.NotEmpty(t, capturedNodeSig, "node signature must be populated on backfill") + + // Verify the produced signature is from the configured node key. + packer := core.NewStatePackerV1(mockEventHandlerAssetStore{}) + packed, err := packer.PackState(*headState) + require.NoError(t, err) + sigBytes, err := hexutil.Decode(capturedNodeSig) + require.NoError(t, err) + validator := core.NewChannelSigValidator(nil) + require.NoError(t, validator.Verify(nodeAddress, packed, sigBytes)) + + mockStore.AssertExpectations(t) + }) + } +} + +// TestHandleHomeChannelCreated_HeadAlreadySigned_NoBackfill verifies that when the off-chain +// head already carries a node signature (the normal case where the create-state is node-signed +// via the RPC path), the backfill is a no-op and no additional UpdateStateSigsIfMissing call +// is issued for the head version. +func TestHandleHomeChannelCreated_HeadAlreadySigned_NoBackfill(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "usdc" + homeChannelIDPtr := channelID + createVersion := uint64(0) + headVersion := uint64(1) + existingNodeSig := "0xnodesigalreadyhere" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusVoid, + StateVersion: createVersion, + } + + // Head state is already node-signed — backfill must be a no-op. + headState := &core.State{ + ID: core.GetStateID(userWallet, asset, 0, headVersion), + Asset: asset, + UserWallet: userWallet, + Epoch: 0, + Version: headVersion, + HomeChannelID: &homeChannelIDPtr, + Transition: core.Transition{Type: core.TransitionTypeTransferReceive}, + HomeLedger: core.Ledger{ + TokenAddress: "0xtoken", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(100), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + NodeSig: &existingNodeSig, + } + + event := &core.HomeChannelCreatedEvent{ + ChannelID: channelID, + StateVersion: createVersion, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.Anything).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, createVersion, "", "").Return(nil) + mockStore.On("GetLastStateByChannelID", channelID, false).Return(headState, nil) + + err := service.HandleHomeChannelCreated(ctx, mockStore, event) + require.NoError(t, err) + + mockStore.AssertExpectations(t) + // Head already node-signed → no second backfill call for the head version. + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", channelID, headVersion, "", mock.AnythingOfType("string")) +} + func TestHandleHomeChannelCheckpointed_Success(t *testing.T) { // Setup mockStore := new(MockStore) @@ -139,8 +353,7 @@ func TestHandleHomeChannelCheckpointed_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.ChannelID == channelID && ch.Status == core.ChannelStatusOpen && @@ -155,13 +368,210 @@ func TestHandleHomeChannelCheckpointed_Success(t *testing.T) { mockStore.On("GetLastStateByChannelID", channelID, false).Return(nil, nil) // Execute - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) // Assert require.NoError(t, err) mockStore.AssertExpectations(t) } +func TestHandleHomeChannelCheckpointed_FromVoidPromotesToOpen(t *testing.T) { + // ChannelCheckpointed can arrive before ChannelCreated for an initial state. A checkpoint + // on a still-Void channel must promote it to Open rather than leave a bumped state_version + // on a Void row until the later ChannelCreated event replays. + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusVoid, + StateVersion: 0, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 1, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == 1 + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(1), "", "").Return(nil) + // Void→Open promotion runs the head-sig backfill (mirrors HandleHomeChannelCreated); + // no off-chain head present → no-op. HasSignedFinalize is only consulted on the + // Challenged path and must not be reached here. + mockStore.On("GetLastStateByChannelID", channelID, false).Return(nil, nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "HasSignedFinalize", channelID) +} + +// TestHandleHomeChannelCheckpointed_FromVoidBackfillsUnsignedReceiverHead pins the Void→Open +// arm of the head-sig backfill. The checkpoint can arrive before ChannelCreated while a +// concurrent RPC has already stored an unsigned transfer_receive/release head above the +// checkpoint version. Promoting Void→Open must node-sign that head so it is treated as fully +// co-signed — mirroring TestHandleHomeChannelCreated_BackfillsUnsignedReceiverStateOnOpen for +// the checkpoint path. Without the `|| wasVoid` clause this head would stay unsigned on an Open +// channel and the test would fail. +func TestHandleHomeChannelCheckpointed_FromVoidBackfillsUnsignedReceiverHead(t *testing.T) { + cases := []struct { + name string + transitionType core.TransitionType + }{ + {"transfer_receive", core.TransitionTypeTransferReceive}, + {"release", core.TransitionTypeRelease}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, nodeAddress := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "USDC" + homeChannelIDPtr := channelID + checkpointVersion := uint64(1) + headVersion := uint64(2) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusVoid, + StateVersion: 0, + } + + // Unsigned receiver credit stored by a concurrent RPC while the channel was still Void, + // sitting above the checkpointed version. + headState := &core.State{ + ID: core.GetStateID(userWallet, asset, 0, headVersion), + Asset: asset, + UserWallet: userWallet, + Epoch: 0, + Version: headVersion, + HomeChannelID: &homeChannelIDPtr, + Transition: core.Transition{Type: tc.transitionType}, + HomeLedger: core.Ledger{ + TokenAddress: "0xtoken", + BlockchainID: 1, + UserBalance: decimal.NewFromInt(100), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: checkpointVersion, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == checkpointVersion + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + // User-sig backfill at the checkpointed version (event.UserSig is empty). + mockStore.On("UpdateStateSigsIfMissing", channelID, checkpointVersion, "", "").Return(nil) + // backfillOffChainHeadNodeSig finds the unsigned receiver credit above the checkpoint. + mockStore.On("GetLastStateByChannelID", channelID, false).Return(headState, nil) + + var capturedNodeSig string + mockStore.On("UpdateStateSigsIfMissing", channelID, headVersion, "", mock.AnythingOfType("string")). + Run(func(args mock.Arguments) { + capturedNodeSig = args.String(3) + }).Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + require.NoError(t, err) + require.NotEmpty(t, capturedNodeSig, "node signature must be populated on backfill") + + // Verify the produced signature is from the configured node key. + packer := core.NewStatePackerV1(mockEventHandlerAssetStore{}) + packed, err := packer.PackState(*headState) + require.NoError(t, err) + sigBytes, err := hexutil.Decode(capturedNodeSig) + require.NoError(t, err) + validator := core.NewChannelSigValidator(nil) + require.NoError(t, validator.Verify(nodeAddress, packed, sigBytes)) + + mockStore.AssertExpectations(t) + // Void→Open promotion does not consult the Finalize marker. + mockStore.AssertNotCalled(t, "HasSignedFinalize", channelID) + }) + } +} + +// TestHandleHomeChannelCheckpointed_DoesNotReopenFinalizedChannel pins the race fix: a +// concurrent submit_state can co-sign a Finalize and flip the channel Open→Closing in the +// window between reading the channel and acquiring the lock. Because the handler now reads the +// channel UNDER the lock (LockUserStateForHomeChannel), it observes Closing and the +// non-challenged path leaves the status untouched — it must NOT persist Open and silently +// reopen the finalized channel. Returning the post-lock Closing snapshot here simulates the +// concurrent finalize having committed first. +func TestHandleHomeChannelCheckpointed_DoesNotReopenFinalizedChannel(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + // Post-lock snapshot: a concurrent submit_state Finalize already flipped Open→Closing. + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusClosing, + StateVersion: 3, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + // Status must be preserved as Closing — never reopened to Open. + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusClosing && + ch.StateVersion == 5 + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "", "").Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + require.NoError(t, err) + mockStore.AssertExpectations(t) + // Not challenged → no Finalize lookup and no head-sig backfill on this path. + mockStore.AssertNotCalled(t, "HasSignedFinalize", channelID) + mockStore.AssertNotCalled(t, "GetLastStateByChannelID", channelID, false) +} + func TestHandleHomeChannelChallenged_PersistsChallenge(t *testing.T) { // Channel must be marked Challenged with the challenge expiry so CheckActiveChannel and // RefreshUserEnforcedBalance stop treating it as open. Auto-checkpoint stays disabled: @@ -191,8 +601,7 @@ func TestHandleHomeChannelChallenged_PersistsChallenge(t *testing.T) { ChallengeExpiry: challengeExpiry, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.ChannelID == channelID && ch.Status == core.ChannelStatusChallenged && @@ -203,7 +612,7 @@ func TestHandleHomeChannelChallenged_PersistsChallenge(t *testing.T) { mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(4), "", "").Return(nil) - err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + err := service.HandleHomeChannelChallenged(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -213,11 +622,15 @@ func TestHandleHomeChannelChallenged_PersistsChallenge(t *testing.T) { func TestHandleHomeChannelChallenged_StaleVersionIgnored(t *testing.T) { // Per protocol the challenged version cannot be lower than the last known on-chain version. - // Anomalies (replay, indexer mis-order) must not regress channel state. + // Anomalies (replay, indexer mis-order, reentrancy) must not regress + // channel state. With §B landed, the guard-drop triggers an on-chain refresh: the refresher + // returns the authoritative snapshot and the row converges to the chain view, NEVER to the + // stale event payload. mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) - service := &EventHandlerService{} + service, _ := newTestEventHandlerService(t) channelID := "0xHomeChannel123" userWallet := "0x1234567890123456789012345678901234567890" @@ -237,15 +650,35 @@ func TestHandleHomeChannelChallenged_StaleVersionIgnored(t *testing.T) { ChallengeExpiry: uint64(time.Now().Add(time.Hour).Unix()), } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + // Refresher returns a snapshot consistent with the current row (chain hasn't moved). + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusOpen, + StateVersion: 5, + ChallengeExpiresAt: nil, + LastStateUserSig: "", + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + // Row must converge to the refreshed (== current) chain view, NOT to the stale event payload. + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == 5 && + ch.ChallengeExpiresAt == nil + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) - err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + err := service.HandleHomeChannelChallenged(ctx, mockStore, mockHub, event) require.NoError(t, err) + // Row state must reflect the refreshed chain snapshot, NOT the stale event payload. + require.Equal(t, uint64(5), channel.StateVersion, "StateVersion must not regress to the stale event version") + require.Equal(t, core.ChannelStatusOpen, channel.Status, "Status must come from refresh, not the stale event") mockStore.AssertExpectations(t) - mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) - mockStore.AssertNotCalled(t, "RefreshUserEnforcedBalance", mock.Anything, mock.Anything) + mockHub.AssertExpectations(t) + // LastStateUserSig is empty, so UpdateStateSigsIfMissing must be skipped (documented intent). + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) } func TestHandleHomeChannelChallenged_ChannelNotFound(t *testing.T) { @@ -262,9 +695,9 @@ func TestHandleHomeChannelChallenged_ChannelNotFound(t *testing.T) { ChallengeExpiry: uint64(time.Now().Add(time.Hour).Unix()), } - mockStore.On("GetChannelByID", channelID).Return(nil, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(nil, nil) - err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + err := service.HandleHomeChannelChallenged(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -290,9 +723,9 @@ func TestHandleHomeChannelChallenged_TypeMismatch(t *testing.T) { ChallengeExpiry: uint64(time.Now().Add(time.Hour).Unix()), } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) - err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + err := service.HandleHomeChannelChallenged(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -329,8 +762,7 @@ func TestHandleHomeChannelChallenged_FromClosingState(t *testing.T) { ChallengeExpiry: challengeExpiry, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.ChannelID == channelID && ch.Status == core.ChannelStatusChallenged && @@ -341,14 +773,14 @@ func TestHandleHomeChannelChallenged_FromClosingState(t *testing.T) { mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(4), "", "").Return(nil) - err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + err := service.HandleHomeChannelChallenged(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) } // TestHandleHomeChannelChallenged_AcquiresUserLockBeforeMutation pins the race fix: -// the handler must call LockUserState(userWallet, asset) before UpdateChannel so an +// the handler must call LockUserStateForHomeChannel before UpdateChannel so an // in-flight receiver-issuance RPC cannot read Status=Open, node-sign a receiver state // and commit after the status flip to Challenged. See HandleHomeChannelClosed for the // same pattern. @@ -378,18 +810,17 @@ func TestHandleHomeChannelChallenged_AcquiresUserLockBeforeMutation(t *testing.T } var locked bool - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset). + mockStore.On("LockUserStateForHomeChannel", channelID). Run(func(mock.Arguments) { locked = true }). - Return(decimal.Zero, nil) + Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(core.Channel) bool { - require.True(t, locked, "LockUserState must be called before UpdateChannel") + require.True(t, locked, "LockUserStateForHomeChannel must be called before UpdateChannel") return true })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(4), "", "").Return(nil) - err := service.HandleHomeChannelChallenged(ctx, mockStore, event) + err := service.HandleHomeChannelChallenged(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) } @@ -420,8 +851,7 @@ func TestHandleHomeChannelClosed_Success(t *testing.T) { } // Mock expectations - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.ChannelID == channelID && ch.Status == core.ChannelStatusClosed && @@ -431,7 +861,7 @@ func TestHandleHomeChannelClosed_Success(t *testing.T) { mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(10), "", "").Return(nil) // Execute - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) // Assert require.NoError(t, err) @@ -1124,42 +1554,13 @@ func TestHandleEscrowWithdrawalFinalized_Success(t *testing.T) { mockStore.AssertExpectations(t) } -func TestHandleUserLockedBalanceUpdated_Success(t *testing.T) { - // Setup - mockStore := new(MockStore) - ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) - - service := &EventHandlerService{} - - // Test data - userWallet := "0x1234567890123456789012345678901234567890" - blockchainID := uint64(1) - balance := decimal.NewFromInt(1000) - - event := &core.UserLockedBalanceUpdatedEvent{ - UserAddress: userWallet, - BlockchainID: blockchainID, - Balance: balance, - } - - // Mock expectations - mockStore.On("UpdateUserStaked", userWallet, blockchainID, balance).Return(nil) - - // Execute - err := service.HandleUserLockedBalanceUpdated(ctx, mockStore, event) - - // Assert - require.NoError(t, err) - 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) { +// 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()) @@ -1184,15 +1585,14 @@ func TestHandleHomeChannelCheckpointed_BackfillsUserSig(t *testing.T) { UserSig: userSig, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", 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("UpdateStateSigsIfMissing", channelID, uint64(5), userSig, "").Return(nil) - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1225,13 +1625,12 @@ func TestHandleHomeChannelCheckpointed_BackfillError(t *testing.T) { UserSig: "0xdeadbeef", } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "usdc").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.Anything).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "0xdeadbeef", "").Return(errors.New("db error")) - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.Error(t, err) require.Contains(t, err.Error(), "db error") @@ -1258,6 +1657,24 @@ func newTestEventHandlerService(t *testing.T) (*EventHandlerService, string) { return NewEventHandlerService(nodeSigner, packer), signer.PublicKey().Address().String() } +// MockReadOnlyChannelHub is a testify/mock implementation of core.ReadOnlyChannelHub +// used by §B tests (E.11, E.12, and the §A1/§A2 guard-drop tests that invoke the +// on-chain refresh). Tests that do not exercise the refresh path can pass a fresh +// MockReadOnlyChannelHub with no expectations set: testify/mock panics loudly with +// an unexpected-call assertion if FetchChannel is invoked inadvertently. +type MockReadOnlyChannelHub struct { + mock.Mock +} + +// FetchChannel mocks the on-drop chain-state refresh hook. +func (m *MockReadOnlyChannelHub) FetchChannel(ctx context.Context, channelID string) (*core.OnChainChannelSnapshot, error) { + args := m.Called(ctx, channelID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*core.OnChainChannelSnapshot), args.Error(1) +} + // TestHandleHomeChannelCheckpointed_BackfillsHeadNodeSig covers the case where a // challenge is cleared while the off-chain head sits above the checkpointed onchain // version: a receiver state stored unsigned during the challenge window is now the @@ -1314,8 +1731,7 @@ func TestHandleHomeChannelCheckpointed_BackfillsHeadNodeSig(t *testing.T) { UserSig: checkpointUserSig, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.Status == core.ChannelStatusOpen && ch.StateVersion == checkpointVersion && @@ -1335,7 +1751,7 @@ func TestHandleHomeChannelCheckpointed_BackfillsHeadNodeSig(t *testing.T) { capturedNodeSig = args.String(3) }).Return(nil) - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) require.NotEmpty(t, capturedNodeSig, "node signature must be populated on backfill") @@ -1404,8 +1820,7 @@ func TestHandleHomeChannelCheckpointed_HeadAlreadySigned_NoBackfill(t *testing.T UserSig: "0xusersig", } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.Status == core.ChannelStatusOpen && ch.StateVersion == checkpointVersion && @@ -1417,7 +1832,7 @@ func TestHandleHomeChannelCheckpointed_HeadAlreadySigned_NoBackfill(t *testing.T mockStore.On("HasSignedFinalize", channelID).Return(false, nil) mockStore.On("GetLastStateByChannelID", channelID, false).Return(headState, nil) - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1480,8 +1895,7 @@ func TestHandleHomeChannelCheckpointed_FromChallengedWithSignedFinalize(t *testi UserSig: userSig, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.ChannelID == channelID && ch.Status == core.ChannelStatusClosing && @@ -1495,7 +1909,7 @@ func TestHandleHomeChannelCheckpointed_FromChallengedWithSignedFinalize(t *testi // Backfill path: off-chain head is the same already-signed Finalize state — no-op. mockStore.On("GetLastStateByChannelID", channelID, false).Return(finalizeState, nil) - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -1504,7 +1918,7 @@ func TestHandleHomeChannelCheckpointed_FromChallengedWithSignedFinalize(t *testi } // TestHandleHomeChannelCheckpointed_AcquiresUserLockBeforeMutation pins the race fix: -// the handler must call LockUserState before flipping Status from Challenged to Open +// the handler must call LockUserStateForHomeChannel before flipping Status from Challenged to Open // and backfilling the off-chain head. Otherwise an in-flight receiver-issuance RPC // can read Status=Challenged, choose to store an unsigned receiver row, and commit // after the backfill — leaving the latest head unsigned on a now-Open channel. @@ -1533,12 +1947,11 @@ func TestHandleHomeChannelCheckpointed_AcquiresUserLockBeforeMutation(t *testing } var locked bool - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset). + mockStore.On("LockUserStateForHomeChannel", channelID). Run(func(mock.Arguments) { locked = true }). - Return(decimal.Zero, nil) + Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(core.Channel) bool { - require.True(t, locked, "LockUserState must be called before UpdateChannel") + require.True(t, locked, "LockUserStateForHomeChannel must be called before UpdateChannel") return true })).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) @@ -1547,7 +1960,7 @@ func TestHandleHomeChannelCheckpointed_AcquiresUserLockBeforeMutation(t *testing mockStore.On("HasSignedFinalize", channelID).Return(false, nil) mockStore.On("GetLastStateByChannelID", channelID, false).Return(nil, nil) - err := service.HandleHomeChannelCheckpointed(ctx, mockStore, event) + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) } @@ -1604,8 +2017,7 @@ func TestHandleHomeChannelClosed_ChallengeRescue_Squash(t *testing.T) { StateVersion: closureVersion, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.Status == core.ChannelStatusClosed && ch.StateVersion == closureVersion })).Return(nil) @@ -1625,7 +2037,7 @@ func TestHandleHomeChannelClosed_ChallengeRescue_Squash(t *testing.T) { return true }), "").Return(nil) - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) require.Equal(t, core.TransitionTypeChallengeRescue, capturedState.Transition.Type) @@ -1633,7 +2045,9 @@ func TestHandleHomeChannelClosed_ChallengeRescue_Squash(t *testing.T) { require.True(t, rescueAmount.Equal(capturedState.Transition.Amount)) require.Nil(t, capturedState.HomeChannelID, "rescue state must be off-channel") require.Equal(t, prevState.Epoch+1, capturedState.Epoch) - require.Equal(t, uint64(0), capturedState.Version) + // Fresh-epoch in-channel rescue lands at version=1: version=0 is + // reserved as the Void/no-on-chain-state sentinel. + require.Equal(t, uint64(1), capturedState.Version) require.Nil(t, capturedState.NodeSig, "rescue state is stored unsigned, like a credit to a user with no open home channel") require.True(t, rescueAmount.Equal(capturedState.HomeLedger.UserBalance)) require.Empty(t, capturedState.HomeLedger.TokenAddress) @@ -1702,8 +2116,7 @@ func TestHandleHomeChannelClosed_ChallengeRescue_NoCredits(t *testing.T) { StateVersion: closureVersion, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) // Terminal Close clears any lingering challenge expiry alongside the status flip. mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.Status == core.ChannelStatusClosed && @@ -1722,7 +2135,7 @@ func TestHandleHomeChannelClosed_ChallengeRescue_NoCredits(t *testing.T) { }), "").Return(nil) mockStore.On("RecordTransaction", mock.Anything, "").Return(nil) - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) require.Equal(t, core.TransitionTypeChallengeRescue, capturedState.Transition.Type) @@ -1730,7 +2143,8 @@ func TestHandleHomeChannelClosed_ChallengeRescue_NoCredits(t *testing.T) { require.True(t, capturedState.HomeLedger.UserBalance.IsZero()) require.Nil(t, capturedState.HomeChannelID) require.Equal(t, prevState.Epoch+1, capturedState.Epoch) - require.Equal(t, uint64(0), capturedState.Version) + // In-channel rescue opens the fresh epoch at version=1. + require.Equal(t, uint64(1), capturedState.Version) mockStore.AssertExpectations(t) } @@ -1785,8 +2199,7 @@ func TestHandleHomeChannelClosed_ChallengeRescue_NegativeNet_ClampsToZero(t *tes StateVersion: closureVersion, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.Anything).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, closureVersion, "", "").Return(nil) @@ -1800,7 +2213,7 @@ func TestHandleHomeChannelClosed_ChallengeRescue_NegativeNet_ClampsToZero(t *tes }), "").Return(nil) mockStore.On("RecordTransaction", mock.Anything, "").Return(nil) - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) require.Equal(t, core.TransitionTypeChallengeRescue, capturedState.Transition.Type) @@ -1808,7 +2221,8 @@ func TestHandleHomeChannelClosed_ChallengeRescue_NegativeNet_ClampsToZero(t *tes require.True(t, capturedState.HomeLedger.UserBalance.IsZero()) require.Nil(t, capturedState.HomeChannelID) require.Equal(t, prevState.Epoch+1, capturedState.Epoch) - require.Equal(t, uint64(0), capturedState.Version) + // In-channel rescue opens the fresh epoch at version=1. + require.Equal(t, uint64(1), capturedState.Version) mockStore.AssertExpectations(t) } @@ -1869,8 +2283,7 @@ func TestHandleHomeChannelClosed_TimeoutAfterFinalize_AppendsRescue(t *testing.T StateVersion: closureVersion, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.Status == core.ChannelStatusClosed && ch.StateVersion == closureVersion && @@ -1892,7 +2305,7 @@ func TestHandleHomeChannelClosed_TimeoutAfterFinalize_AppendsRescue(t *testing.T return true }), "").Return(nil) - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) require.Equal(t, core.TransitionTypeChallengeRescue, capturedState.Transition.Type) @@ -1978,8 +2391,7 @@ func TestHandleHomeChannelClosed_CooperativeCloseAfterChallenge_ZeroRescue(t *te StateVersion: closureVersion, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, asset).Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { return ch.Status == core.ChannelStatusClosed && ch.StateVersion == closureVersion && @@ -2003,7 +2415,7 @@ func TestHandleHomeChannelClosed_CooperativeCloseAfterChallenge_ZeroRescue(t *te return true }), "").Return(nil) - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) require.Equal(t, core.TransitionTypeChallengeRescue, capturedState.Transition.Type) @@ -2056,13 +2468,12 @@ func TestHandleHomeChannelClosed_OpenChannel_NoRescue(t *testing.T) { StateVersion: closureVersion, } - mockStore.On("GetChannelByID", channelID).Return(channel, nil) - mockStore.On("LockUserState", userWallet, "USDC").Return(decimal.Zero, nil) + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) mockStore.On("UpdateChannel", mock.Anything).Return(nil) mockStore.On("RefreshUserEnforcedBalance", userWallet, "USDC").Return(nil) mockStore.On("UpdateStateSigsIfMissing", channelID, closureVersion, "", "").Return(nil) - err := service.HandleHomeChannelClosed(ctx, mockStore, event) + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) require.NoError(t, err) mockStore.AssertExpectations(t) @@ -2071,36 +2482,6 @@ func TestHandleHomeChannelClosed_OpenChannel_NoRescue(t *testing.T) { mockStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) } -func TestHandleUserLockedBalanceUpdated_StoreError(t *testing.T) { - // Setup - mockStore := new(MockStore) - ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) - - service := &EventHandlerService{} - - // Test data - userWallet := "0x1234567890123456789012345678901234567890" - blockchainID := uint64(1) - balance := decimal.NewFromInt(500) - - event := &core.UserLockedBalanceUpdatedEvent{ - UserAddress: userWallet, - BlockchainID: blockchainID, - Balance: balance, - } - - // Mock expectations - mockStore.On("UpdateUserStaked", userWallet, blockchainID, balance).Return(errors.New("db error")) - - // Execute - err := service.HandleUserLockedBalanceUpdated(ctx, mockStore, event) - - // Assert - require.Error(t, err) - 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()) @@ -2152,3 +2533,1256 @@ func TestHandleEscrowDepositsPurged_StoreError_Propagates(t *testing.T) { require.Contains(t, err.Error(), "db error") mockStore.AssertExpectations(t) } + +// §E.1 — RegressionDropped for HandleHomeChannelCheckpointed. +// A lower-version Checkpointed event arriving after a higher-version event must not +// regress channel.StateVersion via the event payload. With §B landed the guard-drop +// now triggers an on-chain refresh: the mock refresher returns a snapshot that agrees +// with the local row (chain has not progressed further), so the row converges to its +// existing state via the refresh path. The key invariant is that the older event's +// payload is NOT what drives the write — the chain view is. +func TestHandleHomeChannelCheckpointed_RegressionDropped(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 10, // N+M + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // N < N+M + UserSig: "0xstaleusersig", + } + + // Refresher returns a snapshot consistent with the current row (chain hasn't moved). + refreshedSig := "0xchainusersig" + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusOpen, + StateVersion: 10, + ChallengeExpiresAt: nil, + LastStateUserSig: refreshedSig, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + // Row must converge to the refreshed (== current) chain view, NOT to the stale event payload. + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == 10 && + ch.ChallengeExpiresAt == nil + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(10), refreshedSig, "").Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, mockHub, event) + + require.NoError(t, err) + // Row state must reflect the refreshed chain snapshot, NOT the stale event payload. + require.Equal(t, uint64(10), channel.StateVersion, "StateVersion must not regress to the stale event version") + require.Equal(t, core.ChannelStatusOpen, channel.Status, "Status must come from refresh, not the stale event") + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // The stale event's UserSig at the regressed version must never be written. + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", channelID, uint64(5), mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "HasSignedFinalize", mock.Anything) + mockStore.AssertNotCalled(t, "GetLastStateByChannelID", mock.Anything, mock.Anything) +} + +// §E.2 — scenario-3 regression test (the critical one). +// A lower-version Checkpointed must not silently clear an active challenge by entering +// the wasChallenged branch and flipping Status back to Open / clearing ChallengeExpiresAt. +// With §B landed, the guard-drop triggers an on-chain refresh: the refresher returns the +// authoritative Challenged snapshot (chain still shows Challenged), so the row converges +// to the chain view — NEVER to the stale Checkpointed event's payload which would have +// cleared the challenge. +func TestHandleHomeChannelCheckpointed_RegressionDoesNotClearChallenge(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 10, // N+M after a higher-version Challenged + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // N < N+M (stale Deposited/Checkpointed) + UserSig: "0xstaleusersig", + } + + // Chain still asserts Challenged at version 10 with the same expiry — refresh agrees with row. + refreshedSig := "0xchainusersig" + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + LastStateUserSig: refreshedSig, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + // Critical: UpdateChannel must persist the Challenged snapshot from chain, NOT the cleared + // snapshot the stale event's wasChallenged branch would have produced. + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusChallenged && + ch.StateVersion == 10 && + ch.ChallengeExpiresAt != nil && + ch.ChallengeExpiresAt.Unix() == expiryTime.Unix() + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(10), refreshedSig, "").Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, mockHub, event) + + require.NoError(t, err) + // Challenge state preserved via chain refresh, not via the stale event's payload. + require.Equal(t, core.ChannelStatusChallenged, channel.Status, "Challenged status must be preserved via refresh") + require.NotNil(t, channel.ChallengeExpiresAt, "ChallengeExpiresAt must not be cleared by stale Checkpointed") + require.Equal(t, expiryTime.Unix(), channel.ChallengeExpiresAt.Unix(), "ChallengeExpiresAt must be unchanged") + require.Equal(t, uint64(10), channel.StateVersion, "StateVersion must not regress") + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // The stale wasChallenged branch must NOT run: no HasSignedFinalize lookup, no sig backfill + // at the stale version, no head-sig backfill via GetLastStateByChannelID. + mockStore.AssertNotCalled(t, "HasSignedFinalize", mock.Anything) + mockStore.AssertNotCalled(t, "GetLastStateByChannelID", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", channelID, uint64(5), mock.Anything, mock.Anything) +} + +// §E.3 — EqualVersionAccepted. The guard is `<`, not `<=`, so the legitimate +// indexer-replay/reorg case where the same (channelID, stateVersion) is re-delivered +// must still run the sig-backfill and balance-refresh idempotently. +func TestHandleHomeChannelCheckpointed_EqualVersionAccepted(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // equal to current + UserSig: "0xusersig", + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == 5 + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "0xusersig", "").Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) + // Not Challenged nor Void → backfill of head node sig is skipped. + mockStore.AssertNotCalled(t, "GetLastStateByChannelID", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "HasSignedFinalize", mock.Anything) +} + +// §E.4 — HigherVersionAccepted. Sanity that the monotonic forward flow still +// works after the guard is added: a higher-version Checkpointed against a Challenged +// channel still clears the challenge and bumps the version. +func TestHandleHomeChannelCheckpointed_HigherVersionAccepted(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 3, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // strictly greater + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == 5 && + ch.ChallengeExpiresAt == nil + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "", "").Return(nil) + mockStore.On("HasSignedFinalize", channelID).Return(false, nil) + mockStore.On("GetLastStateByChannelID", channelID, false).Return(nil, nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + + require.NoError(t, err) + mockStore.AssertExpectations(t) +} + +// §E.5 — RegressionDropped for HandleHomeChannelClosed. +// A lower-version Closed event must not regress StateVersion, must not flip Status to +// Closed, and must not issue a challenge rescue from the stale event payload. With §B +// landed, the guard-drop triggers an on-chain refresh: the chain still asserts +// Challenged (the chain has NOT actually closed at version 5 — see §A.2 terminal-status +// note), so the row converges to the chain Challenged view. The wasChallenged-driven +// rescue branch is owned by the post-guard happy path, NOT by the refresh path, so no +// rescue is issued. +func TestHandleHomeChannelClosed_RegressionDropped(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 10, // N+M + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelClosedEvent{ + ChannelID: channelID, + StateVersion: 5, // N < N+M + } + + // Chain confirms: still Challenged at version 10. The older Closed event must not drive a close. + refreshedSig := "0xchainusersig" + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + LastStateUserSig: refreshedSig, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusChallenged && + ch.StateVersion == 10 && + ch.ChallengeExpiresAt != nil + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(10), refreshedSig, "").Return(nil) + + err := service.HandleHomeChannelClosed(ctx, mockStore, mockHub, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion, "StateVersion must not regress") + require.Equal(t, core.ChannelStatusChallenged, channel.Status, "Status must not be flipped to Closed by stale event") + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // Critically, no rescue issuance — the rescue branch belongs to the happy-path close, + // not to the refresh path. SumNetTransitionAmountAfterVersion / StoreUserState / + // RecordTransaction must all be skipped. + mockStore.AssertNotCalled(t, "SumNetTransitionAmountAfterVersion", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "GetLastUserState", mock.Anything, mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) +} + +// §E.6 — RegressionDropped for HandleEscrowDepositInitiated. +// A lower-version EscrowDepositInitiated must not regress StateVersion, must not flip +// Status, and must not call ScheduleInitiateEscrowDeposit. +func TestHandleEscrowDepositInitiated_RegressionDropped(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 10, + } + + event := &core.EscrowDepositInitiatedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + + err := service.HandleEscrowDepositInitiated(ctx, mockStore, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusOpen, channel.Status) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "GetStateByChannelIDAndVersion", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "ScheduleInitiateEscrowDeposit", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// §E.7 — RegressionDropped for HandleEscrowDepositFinalized. +func TestHandleEscrowDepositFinalized_RegressionDropped(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.EscrowDepositFinalizedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + + err := service.HandleEscrowDepositFinalized(ctx, mockStore, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusChallenged, channel.Status, "Status must not flip to Closed via stale Finalized") + require.NotNil(t, channel.ChallengeExpiresAt, "Stale Finalized must not clear ChallengeExpiresAt") + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// §E.8 — RegressionDropped for HandleEscrowWithdrawalInitiated. +func TestHandleEscrowWithdrawalInitiated_RegressionDropped(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 10, + } + + event := &core.EscrowWithdrawalInitiatedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + + err := service.HandleEscrowWithdrawalInitiated(ctx, mockStore, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusOpen, channel.Status) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// §E.9 — RegressionDropped for HandleEscrowWithdrawalFinalized. +func TestHandleEscrowWithdrawalFinalized_RegressionDropped(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.EscrowWithdrawalFinalizedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil) + + err := service.HandleEscrowWithdrawalFinalized(ctx, mockStore, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusChallenged, channel.Status) + require.NotNil(t, channel.ChallengeExpiresAt) + mockStore.AssertExpectations(t) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// §E.10 — OnHomeEscrowPath. +// The reactor's handleEscrowDepositInitiatedOnHome / *FinalizedOnHome / *WithdrawalInitiatedOnHome / +// *WithdrawalFinalizedOnHome funnel into HandleHomeChannelCheckpointed (see +// channel_hub_reactor.go:506-559). The §A.1 guard therefore covers all four *OnHome paths +// automatically — there is no separate handler call path to exercise at the +// EventHandlerService layer. Writing a direct unit test against the reactor would require +// reactor fixtures (channelHubFilterer, types.Log) that don't exist for this test target, +// so we skip with documentation per the spec's E.10 special note. +func TestHandleHomeChannelCheckpointed_OnHomeEscrowPath(t *testing.T) { + t.Skip("reactor-level integration test; the *OnHome paths funnel into " + + "HandleHomeChannelCheckpointed (channel_hub_reactor.go:506-559) which is " + + "already covered by TestHandleHomeChannelCheckpointed_RegressionDropped and " + + "TestHandleHomeChannelCheckpointed_RegressionDoesNotClearChallenge. A " + + "reactor-level test would require channelHubFilterer + types.Log fixtures " + + "that aren't in scope here. See plan §A.7 and the §E.10 special note.") +} + +// §E.13 — RescueIdempotentOnEqualVersionReplay. +// Fire HandleHomeChannelClosed twice at the same version against a Challenged channel. +// First call: wasChallenged=true → status flips to Closed and issueChallengeRescue +// records exactly one rescue state + transaction. Second call (equal-version replay, +// admitted by the `<` guard): wasChallenged=false because Status is now Closed → +// rescue branch must NOT be re-entered. Total rescue count remains 1. +// +// This pins the invariant that the rescue idempotency is enforced by the channel's +// status transition (Challenged → Closed), not by the version guard. A future refactor +// moving the wasChallenged snapshot or persisting Challenged across handler invocations +// would break this and should fail this test. +func TestHandleHomeChannelClosed_RescueIdempotentOnEqualVersionReplay(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "USDC" + tokenAddress := "0xtoken" + blockchainID := uint64(1) + closureVersion := uint64(7) + rescueAmount := decimal.NewFromInt(50) + homeChannelIDPtr := channelID + expiryTime := time.Now().Add(time.Hour) + + // Start Challenged at version=closureVersion-2 < closureVersion (legitimate close + // at higher version). The lock returns the same pointer twice; the handler mutates + // channel.Status to Closed after the first call, so the second call observes Closed. + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: closureVersion - 2, + ChallengeExpiresAt: &expiryTime, + } + + prevState := &core.State{ + ID: core.GetStateID(userWallet, asset, 1, 9), + Asset: asset, + UserWallet: userWallet, + Epoch: 1, + Version: 9, + HomeChannelID: &homeChannelIDPtr, + HomeLedger: core.Ledger{ + TokenAddress: tokenAddress, + BlockchainID: blockchainID, + UserBalance: decimal.NewFromInt(50), + UserNetFlow: decimal.NewFromInt(50), + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + } + + event := &core.HomeChannelClosedEvent{ + ChannelID: channelID, + StateVersion: closureVersion, + } + + // LockUserStateForHomeChannel is called twice (once per handler invocation) and returns + // the same mutated channel pointer both times. + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil).Times(2) + // UpdateChannel is called twice (the guard admits equal versions, so the second call + // also writes the row idempotently). + mockStore.On("UpdateChannel", mock.Anything).Return(nil).Times(2) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil).Times(2) + mockStore.On("UpdateStateSigsIfMissing", channelID, closureVersion, "", "").Return(nil).Times(2) + + // Rescue side effects: must be called exactly ONCE across both handler invocations. + mockStore.On("SumNetTransitionAmountAfterVersion", channelID, closureVersion).Return(rescueAmount, nil).Once() + mockStore.On("GetLastUserState", userWallet, asset, false).Return(prevState, nil).Once() + mockStore.On("StoreUserState", mock.Anything, "").Return(nil).Once() + mockStore.On("RecordTransaction", mock.Anything, "").Return(nil).Once() + + // First call: wasChallenged=true → rescue fires. + err := service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + require.NoError(t, err) + require.Equal(t, core.ChannelStatusClosed, channel.Status, "Status must be Closed after first call") + + // Second call: equal-version replay. Status is now Closed → wasChallenged=false → + // rescue branch must not re-enter. The `.Once()` constraints on the rescue mocks + // will fail if any are called a second time. + err = service.HandleHomeChannelClosed(ctx, mockStore, new(MockReadOnlyChannelHub), event) + require.NoError(t, err) + require.Equal(t, core.ChannelStatusClosed, channel.Status, "Status remains Closed after replay") + + mockStore.AssertExpectations(t) + // Explicit double-check: AssertNumberOfCalls catches any drift even if mock matching + // somehow accepted an extra call against a more permissive expectation. + mockStore.AssertNumberOfCalls(t, "StoreUserState", 1) + mockStore.AssertNumberOfCalls(t, "RecordTransaction", 1) + mockStore.AssertNumberOfCalls(t, "SumNetTransitionAmountAfterVersion", 1) +} + +// §E.14 — EqualVersionReplay_NoSideEffects. +// For every guarded handler other than HandleHomeChannelClosed (covered by §E.13), an +// equal-version replay must be safe: no double-credit, no second balance-refresh side +// effect, no duplicate RecordTransaction. The handler-level side effects are idempotent +// because UpdateStateSigsIfMissing / RefreshUserEnforcedBalance / UpdateChannel are all +// idempotent on the same input. +// +// CAVEAT per §C.1: HandleEscrowDepositInitiated calls ScheduleInitiateEscrowDeposit, +// which is NOT idempotent — it unconditionally inserts a new blockchain_actions row. The +// sub-test EscrowDepositInitiated_DuplicateScheduleOnReplay explicitly asserts the +// double-call as a regression target for the §F.6 follow-up scheduler-dedup work. +func TestHandleXxx_EqualVersionReplay_NoSideEffects(t *testing.T) { + t.Run("HomeChannelCheckpointed", func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, + UserSig: "0xusersig", + } + + // Both calls hit the same mock; idempotent UpdateStateSigsIfMissing is the + // guarantor — but we still need to make sure the wasChallenged/wasVoid branch + // isn't re-armed on replay. Status stays Open across both calls, so the + // backfillOffChainHeadNodeSig path is never entered. + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil).Times(2) + mockStore.On("UpdateChannel", mock.Anything).Return(nil).Times(2) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil).Times(2) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "0xusersig", "").Return(nil).Times(2) + + require.NoError(t, service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event)) + require.NoError(t, service.HandleHomeChannelCheckpointed(ctx, mockStore, new(MockReadOnlyChannelHub), event)) + + mockStore.AssertExpectations(t) + // No head-sig backfill on the Open→Open path, even on replay. + mockStore.AssertNotCalled(t, "GetLastStateByChannelID", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "HasSignedFinalize", mock.Anything) + }) + + t.Run("EscrowDepositFinalized", func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: "0x1234567890123456789012345678901234567890", + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.EscrowDepositFinalizedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil).Times(2) + mockStore.On("UpdateChannel", mock.Anything).Return(nil).Times(2) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "", "").Return(nil).Times(2) + + require.NoError(t, service.HandleEscrowDepositFinalized(ctx, mockStore, event)) + require.NoError(t, service.HandleEscrowDepositFinalized(ctx, mockStore, event)) + + mockStore.AssertExpectations(t) + }) + + t.Run("EscrowWithdrawalInitiated", func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: "0x1234567890123456789012345678901234567890", + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.EscrowWithdrawalInitiatedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil).Times(2) + mockStore.On("UpdateChannel", mock.Anything).Return(nil).Times(2) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "", "").Return(nil).Times(2) + + require.NoError(t, service.HandleEscrowWithdrawalInitiated(ctx, mockStore, event)) + require.NoError(t, service.HandleEscrowWithdrawalInitiated(ctx, mockStore, event)) + + mockStore.AssertExpectations(t) + }) + + t.Run("EscrowWithdrawalFinalized", func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: "0x1234567890123456789012345678901234567890", + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.EscrowWithdrawalFinalizedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil).Times(2) + mockStore.On("UpdateChannel", mock.Anything).Return(nil).Times(2) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "", "").Return(nil).Times(2) + + require.NoError(t, service.HandleEscrowWithdrawalFinalized(ctx, mockStore, event)) + require.NoError(t, service.HandleEscrowWithdrawalFinalized(ctx, mockStore, event)) + + mockStore.AssertExpectations(t) + }) + + // §C.1 caveat: ScheduleInitiateEscrowDeposit is NOT idempotent on same-version + // replay — scheduleStateEnforcement unconditionally INSERTs a new blockchain_actions + // row, so equal-version replay enqueues a duplicate action. This sub-test pins the + // CURRENT (buggy) behaviour as a regression target for §F.6's scheduler-dedup follow-up. + // When that follow-up lands, this assertion should be flipped from .Times(2) to .Once(). + t.Run("EscrowDepositInitiated_DuplicateScheduleOnReplay", func(t *testing.T) { + mockStore := new(MockStore) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service := &EventHandlerService{} + + channelID := "0xEscrowChannel123" + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: "0x1234567890123456789012345678901234567890", + Asset: "usdc", + Type: core.ChannelTypeEscrow, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + state := &core.State{ + ID: "state123", + Version: 5, + HomeLedger: core.Ledger{ + BlockchainID: 1, + }, + } + + event := &core.EscrowDepositInitiatedEvent{ + ChannelID: channelID, + StateVersion: 5, + } + + mockStore.On("GetChannelByID", channelID).Return(channel, nil).Times(2) + mockStore.On("UpdateChannel", mock.Anything).Return(nil).Times(2) + mockStore.On("GetStateByChannelIDAndVersion", channelID, uint64(5)).Return(state, nil).Times(2) + // CAVEAT: schedule is called twice — this is the §C.1 / §F.6 latent issue + // (scheduler-dedup gap). The `<` guard admits the equal-version replay, and + // scheduleStateEnforcement does not dedup on (state_id, action_type). Flagged + // for follow-up; assert the duplicate so the regression target is explicit. + mockStore.On("ScheduleInitiateEscrowDeposit", "state123", uint64(1)).Return(nil).Times(2) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), "", "").Return(nil).Times(2) + + require.NoError(t, service.HandleEscrowDepositInitiated(ctx, mockStore, event)) + require.NoError(t, service.HandleEscrowDepositInitiated(ctx, mockStore, event)) + + mockStore.AssertExpectations(t) + mockStore.AssertNumberOfCalls(t, "ScheduleInitiateEscrowDeposit", 2) + }) +} + +// §E.11 — Scenario-4 sequence test: outer ChallengeChannel dropped because an +// inner higher-version Checkpointed already landed. The guard fires, the chain-state +// refresh runs, and the row converges to the chain's authoritative Challenged view — +// closing the observability gap where the Node would otherwise stay Open and admit the +// channel via CheckActiveChannel despite the chain being DISPUTED. +// +// This is the canonical §B test: the older event is dropped (no payload write), but the +// refresher fetches the authoritative on-chain status (Challenged) and the row is +// updated accordingly. RefreshUserEnforcedBalance and UpdateStateSigsIfMissing run with +// the refreshed sig at the refreshed version. See spec §B.2. +func TestScenario4_OuterChallengeDroppedTriggersRefresh(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "usdc" + + // Setup matches the spec: inner higher-version Checkpointed already landed, + // row is Open at version 5 with no expiry. The outer Challenged at version 3 + // is about to arrive. + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.HomeChannelChallengedEvent{ + ChannelID: channelID, + StateVersion: 3, // lower than current 5 → guard fires + ChallengeExpiry: uint64(time.Now().Add(time.Hour).Unix()), + } + + // Authoritative on-chain view: Challenged at version 5, with a real expiry. + chainExpiry := time.Now().Add(2 * time.Hour) + chainSig := "0xab1234567890" + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusChallenged, + StateVersion: 5, + ChallengeExpiresAt: &chainExpiry, + LastStateUserSig: chainSig, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusChallenged && + ch.StateVersion == 5 && + ch.ChallengeExpiresAt != nil && + ch.ChallengeExpiresAt.Unix() == chainExpiry.Unix() + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(5), chainSig, "").Return(nil) + + err := service.HandleHomeChannelChallenged(ctx, mockStore, mockHub, event) + require.NoError(t, err) + + require.Equal(t, core.ChannelStatusChallenged, channel.Status, "row must converge to chain Challenged") + require.Equal(t, uint64(5), channel.StateVersion, "row must keep the chain version, not the stale event's") + require.NotNil(t, channel.ChallengeExpiresAt) + require.Equal(t, chainExpiry.Unix(), channel.ChallengeExpiresAt.Unix()) + + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + mockHub.AssertNumberOfCalls(t, "FetchChannel", 1) +} + +// §E.11 (Checkpointed-guard-path variant). Fires HandleHomeChannelCheckpointed +// with a regression version where the chain has moved on and is now Challenged. The +// §A.1 guard fires, the refresher returns the chain Challenged view, and the row +// converges. This catches A.1's refresh hook independently from the Challenged handler. +func TestScenario4_OuterChallengeDroppedTriggersRefresh_CheckpointedGuardPath(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "usdc" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 8, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 4, // regression + UserSig: "0xstaleusersig", + } + + chainExpiry := time.Now().Add(time.Hour) + chainSig := "0xab1234567890" + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusChallenged, + StateVersion: 8, + ChallengeExpiresAt: &chainExpiry, + LastStateUserSig: chainSig, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.Status == core.ChannelStatusChallenged && + ch.StateVersion == 8 && + ch.ChallengeExpiresAt != nil && + ch.ChallengeExpiresAt.Unix() == chainExpiry.Unix() + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(8), chainSig, "").Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, mockHub, event) + require.NoError(t, err) + + require.Equal(t, core.ChannelStatusChallenged, channel.Status) + require.Equal(t, uint64(8), channel.StateVersion) + require.NotNil(t, channel.ChallengeExpiresAt) + + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // The wasChallenged branch's stale work must not run. + mockStore.AssertNotCalled(t, "HasSignedFinalize", mock.Anything) + mockStore.AssertNotCalled(t, "GetLastStateByChannelID", mock.Anything, mock.Anything) +} + +// §E.11 (Closed-guard-path variant). Fires HandleHomeChannelClosed with a +// regression version where the chain has progressed further (Closed at the higher +// version). The §A.2 guard fires, refresher returns the chain Closed snapshot, row +// converges. Per §A.2 the chain MAY have actually closed past the stale event's +// version; the refresh path picks up that authoritative view. +func TestScenario4_OuterChallengeDroppedTriggersRefresh_ClosedGuardPath(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + asset := "usdc" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: asset, + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 12, + } + + event := &core.HomeChannelClosedEvent{ + ChannelID: channelID, + StateVersion: 7, // regression — older Closed event + } + + chainSig := "0xab1234567890" + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusClosed, + StateVersion: 12, + ChallengeExpiresAt: nil, + LastStateUserSig: chainSig, + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + return ch.Status == core.ChannelStatusClosed && + ch.StateVersion == 12 && + ch.ChallengeExpiresAt == nil + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, asset).Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(12), chainSig, "").Return(nil) + + err := service.HandleHomeChannelClosed(ctx, mockStore, mockHub, event) + require.NoError(t, err) + + require.Equal(t, core.ChannelStatusClosed, channel.Status) + require.Equal(t, uint64(12), channel.StateVersion) + require.Nil(t, channel.ChallengeExpiresAt) + + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // Rescue branch belongs to the happy-path close, not the refresh path. + mockStore.AssertNotCalled(t, "SumNetTransitionAmountAfterVersion", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) +} + +// §E.12 — Refresher-error retry-and-continue test (Challenged guard path). +// Per the Hybrid retry-and-continue error contract: when ReadOnlyChannelHub.FetchChannel +// fails, the handler retries up to refreshMaxAttempts with bounded backoff, +// then logs at Error level and returns nil so the outer reactor +// transaction commits (dedup row recorded, listener advances). The local channel +// row stays at whatever the inner higher-version event already set it to — no +// convergence happens. This trades transient divergence for not killing the +// node on a sustained RPC outage. +func TestGuardDrop_RefresherErrorLoggedAndIgnored(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + // Disable sleeps between retries so the test does not actually wait. + service.refreshBackoff = []time.Duration{0, 0} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusOpen, + StateVersion: 5, + } + + event := &core.HomeChannelChallengedEvent{ + ChannelID: channelID, + StateVersion: 3, // regression + ChallengeExpiry: uint64(time.Now().Add(time.Hour).Unix()), + } + + rpcErr := errors.New("rpc unavailable") + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + // All refreshMaxAttempts attempts fail; the handler must exhaust them and + // then fall back to log-and-continue. + mockHub.On("FetchChannel", mock.Anything, channelID).Return(nil, rpcErr).Times(refreshMaxAttempts) + + err := service.HandleHomeChannelChallenged(ctx, mockStore, mockHub, event) + + require.NoError(t, err, "refresher error must be logged and swallowed so the reactor tx commits") + // Channel row must be unchanged — no convergence happened. + require.Equal(t, uint64(5), channel.StateVersion, "row must not be mutated on refresh failure") + require.Equal(t, core.ChannelStatusOpen, channel.Status, "row must not be mutated on refresh failure") + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // Bounded retry: FetchChannel called exactly refreshMaxAttempts times. + mockHub.AssertNumberOfCalls(t, "FetchChannel", refreshMaxAttempts) + // No convergence write. + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "RefreshUserEnforcedBalance", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// §E.12 (Checkpointed guard path variant). +func TestGuardDrop_RefresherErrorLoggedAndIgnored_CheckpointedGuardPath(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + service.refreshBackoff = []time.Duration{0, 0} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // regression + UserSig: "0xstaleusersig", + } + + rpcErr := errors.New("rpc unavailable") + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(nil, rpcErr).Times(refreshMaxAttempts) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, mockHub, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusChallenged, channel.Status) + require.NotNil(t, channel.ChallengeExpiresAt) + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + mockHub.AssertNumberOfCalls(t, "FetchChannel", refreshMaxAttempts) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "RefreshUserEnforcedBalance", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} + +// §E.12 (Closed guard path variant). +func TestGuardDrop_RefresherErrorLoggedAndIgnored_ClosedGuardPath(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + service.refreshBackoff = []time.Duration{0, 0} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelClosedEvent{ + ChannelID: channelID, + StateVersion: 5, // regression + } + + rpcErr := errors.New("rpc unavailable") + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(nil, rpcErr).Times(refreshMaxAttempts) + + err := service.HandleHomeChannelClosed(ctx, mockStore, mockHub, event) + + require.NoError(t, err) + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusChallenged, channel.Status) + require.NotNil(t, channel.ChallengeExpiresAt) + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + mockHub.AssertNumberOfCalls(t, "FetchChannel", refreshMaxAttempts) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "RefreshUserEnforcedBalance", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "SumNetTransitionAmountAfterVersion", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "StoreUserState", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "RecordTransaction", mock.Anything, mock.Anything) +} + +// TestGuardDrop_RefresherSucceedsOnSecondAttempt verifies the retry mechanism: +// when the first FetchChannel attempt fails but a subsequent attempt returns a +// valid snapshot, the handler converges the row to that snapshot and the dedup +// ledger advances. Pinned on the Checkpointed guard path — the most common +// reentrancy reorder source in production. +func TestGuardDrop_RefresherSucceedsOnSecondAttempt(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + ctx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + + service, _ := newTestEventHandlerService(t) + service.refreshBackoff = []time.Duration{0, 0} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // regression — triggers guard-drop refresh + UserSig: "0xstaleusersig", + } + + // First FetchChannel attempt fails, second returns the authoritative snapshot. + rpcErr := errors.New("rpc transient") + refreshed := &core.OnChainChannelSnapshot{ + Status: core.ChannelStatusOpen, + StateVersion: 12, + ChallengeExpiresAt: nil, + LastStateUserSig: "0xchainusersig", + } + + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + mockHub.On("FetchChannel", mock.Anything, channelID).Return(nil, rpcErr).Once() + mockHub.On("FetchChannel", mock.Anything, channelID).Return(refreshed, nil).Once() + mockStore.On("UpdateChannel", mock.MatchedBy(func(ch core.Channel) bool { + // Row must converge to the refreshed chain snapshot, not the stale event payload. + return ch.ChannelID == channelID && + ch.Status == core.ChannelStatusOpen && + ch.StateVersion == 12 && + ch.ChallengeExpiresAt == nil + })).Return(nil) + mockStore.On("RefreshUserEnforcedBalance", userWallet, "usdc").Return(nil) + mockStore.On("UpdateStateSigsIfMissing", channelID, uint64(12), "0xchainusersig", "").Return(nil) + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, mockHub, event) + + require.NoError(t, err) + require.Equal(t, uint64(12), channel.StateVersion, "row must converge to chain snapshot") + require.Equal(t, core.ChannelStatusOpen, channel.Status, "status must converge to chain snapshot") + require.Nil(t, channel.ChallengeExpiresAt) + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + mockHub.AssertNumberOfCalls(t, "FetchChannel", 2) +} + +// TestGuardDrop_RefresherContextCancelledDuringBackoff verifies that +// ctx cancellation during a retry backoff returns nil immediately and does NOT +// propagate cancellation upward. Propagating ctx.Err() would surface as a +// non-nil handler return and the listener would escalate to logger.Fatal, so +// this invariant pins the cancellation-safety guarantee promised in the helper +// doc-comment. Mocks confirm only the first FetchChannel attempt fired and the +// row stays untouched. +func TestGuardDrop_RefresherContextCancelledDuringBackoff(t *testing.T) { + mockStore := new(MockStore) + mockHub := new(MockReadOnlyChannelHub) + baseCtx := log.SetContextLogger(context.Background(), log.NewNoopLogger()) + ctx, cancel := context.WithCancel(baseCtx) + + service, _ := newTestEventHandlerService(t) + // Long backoff so the cancellation lands inside the sleep before attempt #2. + service.refreshBackoff = []time.Duration{1 * time.Hour, 1 * time.Hour} + + channelID := "0xHomeChannel123" + userWallet := "0x1234567890123456789012345678901234567890" + expiryTime := time.Now().Add(time.Hour) + + channel := &core.Channel{ + ChannelID: channelID, + UserWallet: userWallet, + Asset: "usdc", + Type: core.ChannelTypeHome, + Status: core.ChannelStatusChallenged, + StateVersion: 10, + ChallengeExpiresAt: &expiryTime, + } + + event := &core.HomeChannelCheckpointedEvent{ + ChannelID: channelID, + StateVersion: 5, // regression + UserSig: "0xstaleusersig", + } + + rpcErr := errors.New("rpc unavailable") + mockStore.On("LockUserStateForHomeChannel", channelID).Return(channel, nil) + // First attempt fails; the handler enters backoff and observes the cancellation. + mockHub.On("FetchChannel", mock.Anything, channelID). + Run(func(mock.Arguments) { + // Cancel right after the first attempt completes so the next select + // hits ctx.Done before time.After. + cancel() + }). + Return(nil, rpcErr).Once() + + err := service.HandleHomeChannelCheckpointed(ctx, mockStore, mockHub, event) + + require.NoError(t, err, "ctx cancellation must NOT propagate — listener would escalate to logger.Fatal") + // Row must remain untouched — no convergence on cancellation. + require.Equal(t, uint64(10), channel.StateVersion) + require.Equal(t, core.ChannelStatusChallenged, channel.Status) + require.NotNil(t, channel.ChallengeExpiresAt) + mockStore.AssertExpectations(t) + mockHub.AssertExpectations(t) + // Only the first attempt fired; the cancellation aborted the backoff before attempt #2. + mockHub.AssertNumberOfCalls(t, "FetchChannel", 1) + mockStore.AssertNotCalled(t, "UpdateChannel", mock.Anything) + mockStore.AssertNotCalled(t, "RefreshUserEnforcedBalance", mock.Anything, mock.Anything) + mockStore.AssertNotCalled(t, "UpdateStateSigsIfMissing", mock.Anything, mock.Anything, mock.Anything, mock.Anything) +} diff --git a/nitronode/event_handlers/testing.go b/nitronode/event_handlers/testing.go index dfe5be52f..9c2014401 100644 --- a/nitronode/event_handlers/testing.go +++ b/nitronode/event_handlers/testing.go @@ -97,12 +97,6 @@ func (m *MockStore) RefreshUserEnforcedBalance(wallet, asset string) error { return args.Error(0) } -// UpdateUserStaked mocks updating the total staked amount for a user -func (m *MockStore) UpdateUserStaked(wallet string, blockchainID uint64, amount decimal.Decimal) error { - args := m.Called(wallet, blockchainID, amount) - return args.Error(0) -} - // UpdateStateSigsIfMissing mocks backfilling missing user and/or node signatures // for a stored state. func (m *MockStore) UpdateStateSigsIfMissing(channelID string, version uint64, userSig, nodeSig string) error { @@ -123,13 +117,22 @@ func (m *MockStore) LockUserState(wallet, asset string) (decimal.Decimal, error) return args.Get(0).(decimal.Decimal), args.Error(1) } +// LockUserStateForHomeChannel mocks locking the balance row of the channel owner and +// returning the channel read under that lock. +func (m *MockStore) LockUserStateForHomeChannel(channelID string) (*core.Channel, error) { + args := m.Called(channelID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*core.Channel), args.Error(1) +} + // HasSignedFinalize mocks the existence check for a node-signed Finalize state on the given home channel. func (m *MockStore) HasSignedFinalize(channelID string) (bool, error) { args := m.Called(channelID) return args.Bool(0), args.Error(1) } - // StoreUserState mocks persisting a user state row. func (m *MockStore) StoreUserState(state core.State, applicationID string) error { args := m.Called(state, applicationID) diff --git a/nitronode/main.go b/nitronode/main.go index 74375ab50..dd9b44416 100644 --- a/nitronode/main.go +++ b/nitronode/main.go @@ -43,20 +43,15 @@ func main() { vl := bb.ValidationLimits rpcRouterCfg := api.RPCRouterConfig{ - NodeVersion: bb.NodeVersion, - MinChallenge: bb.ChannelMinChallengeDuration, - MaxChallenge: bb.ChannelMaxChallengeDuration, - AppRegistryEnabled: bb.AppRegistryEnabled, - MaxParticipants: vl.MaxParticipants, - MaxSessionDataLen: vl.MaxSessionDataLen, - MaxAppMetadataLen: vl.MaxAppMetadataLen, - MaxRebalanceSignedUpdates: vl.MaxSignedUpdates, - MaxSessionKeyIDs: vl.MaxSessionKeyIDs, - MaxSessionKeysPerUser: vl.MaxSessionKeysPerUser, - RateLimitPerSec: bb.RateLimitPerSec, - RateLimitBurst: bb.RateLimitBurst, + NodeVersion: bb.NodeVersion, + MinChallenge: bb.ChannelMinChallengeDuration, + MaxChallenge: bb.ChannelMaxChallengeDuration, + MaxParticipants: vl.MaxParticipants, + MaxSessionDataLen: vl.MaxSessionDataLen, + MaxSessionKeyIDs: vl.MaxSessionKeyIDs, + MaxSessionKeysPerUser: vl.MaxSessionKeysPerUser, } - api.NewRPCRouter(rpcRouterCfg, bb.RpcNode, bb.StateSigner, bb.DbStore, bb.MemoryStore, bb.ActionGateway, bb.RuntimeMetrics, bb.Logger) + api.NewRPCRouter(rpcRouterCfg, bb.RpcNode, bb.StateSigner, bb.DbStore, bb.MemoryStore, bb.RuntimeMetrics, bb.Logger) rpcListenAddr := ":7824" rpcListenEndpoint := "/ws" @@ -111,6 +106,14 @@ func main() { logger.Fatal("failed to create EVM client") } + // Bind a read-only ChannelHub view for this chain so the reactor can issue + // getChannelData reads from home-channel guard-drop paths. + channelHubCaller, err := evm.NewChannelHubCaller(common.HexToAddress(b.ChannelHubAddress), client) + if err != nil { + logger.Fatal("failed to create ChannelHub caller", "error", err, "blockchainID", b.ID) + } + channelHubReader := evm.NewChannelHubReader(channelHubCaller) + sigValidators, err := bb.MemoryStore.GetChannelSigValidators(b.ID) if err != nil { logger.Fatal("failed to get channel signature validators from memory store", "error", err, "blockchainID", b.ID) @@ -124,9 +127,37 @@ func main() { return wrapInTx(func(s database.DatabaseStore) error { return h(s) }) } - reactor := evm.NewChannelHubReactor(b.ID, bb.StateSigner.PublicKey().Address().String(), eventHandlerService, bb.MemoryStore, useCHRStoreInTx) + reactor := evm.NewChannelHubReactor(b.ID, bb.StateSigner.PublicKey().Address().String(), eventHandlerService, bb.MemoryStore, useCHRStoreInTx, bb.DbStore, channelHubReader) reactor.SetOnEventProcessed(bb.RuntimeMetrics.IncBlockchainEvent) - l := evm.NewListener(common.HexToAddress(b.ChannelHubAddress), client, b.ID, b.BlockStep, logger, reactor.HandleEvent, bb.DbStore) + + confirmationDelay := time.Duration(b.ConfirmationDelaySecs) * time.Second + var liveHandler evm.HandleEvent + var flushDownstream func() + if confirmationDelay > 0 { + gate, err := evm.NewConfirmationGate(confirmationDelay, b.ID, reactor.HandleEvent, logger) + if err != nil { + logger.Fatal("failed to create confirmation gate", "error", err, "blockchainID", b.ID) + } + gate.Start(blockchainCtx, func(err error) { + if err != nil { + logger.Fatal("confirmation gate stopped", "error", err, "blockchainID", b.ID) + } + }) + liveHandler = gate.HandleEvent + flushDownstream = gate.FlushPending + } else { + liveHandler = reactor.HandleEvent + // flushDownstream stays nil: no gate to flush on the no-delay path. + } + + // Live events flow through the confirmation gate (when delay > 0) or directly to the + // reactor (when delay == 0). Historical events from eth_getLogs are routed per-event + // based on block age: events older than confirmationDelay go directly to the reactor + // (past the reorg window); recent events still flow through the live handler because + // their blocks may still be reorged. + // flushDownstream is nil when confirmationDelay == 0; NewListener skips the flush call + // when it is nil, preserving no-gate behavior bit-for-bit. + l := evm.NewListener(common.HexToAddress(b.ChannelHubAddress), client, b.ID, b.BlockStep, confirmationDelay, logger, liveHandler, reactor.HandleEvent, bb.DbStore, flushDownstream) l.Listen(blockchainCtx, func(err error) { if err != nil { logger.Fatal("blockchain listener stopped", "error", err, "blockchainID", b.ID) @@ -142,30 +173,6 @@ func main() { } else { logger.Info("channel hub address is not configured for blockchain", "blockchainID", b.ID) } - - if b.LockingContractAddress != "" { - appRegistryClient, err := evm.NewLockingClient(common.HexToAddress(b.LockingContractAddress), client, b.ID) - if err != nil { - logger.Fatal("failed to create locking client", "error", err, "blockchainID", b.ID) - } - - useLCRStoreInTx := func(h evm.LockingContractReactorStoreTxHandler) error { - return wrapInTx(func(s database.DatabaseStore) error { return h(s) }) - } - - reactor, err := evm.NewLockingContractReactor(b.ID, eventHandlerService, appRegistryClient.GetTokenDecimals, useLCRStoreInTx) - if err != nil { - logger.Fatal("failed to create app registry reactor", "error", err, "blockchainID", b.ID) - } - - reactor.SetOnEventProcessed(bb.RuntimeMetrics.IncBlockchainEvent) - l := evm.NewListener(common.HexToAddress(b.LockingContractAddress), client, b.ID, b.BlockStep, logger, reactor.HandleEvent, bb.DbStore) - l.Listen(blockchainCtx, func(err error) { - if err != nil { - logger.Fatal("blockchain listener stopped", "error", err, "blockchainID", b.ID) - } - }) - } } go runStoreMetricsExporter(ctx, 30*time.Second, bb.DbStore, bb.StoreMetrics, logger) diff --git a/nitronode/metrics/exporter.go b/nitronode/metrics/exporter.go index a95f8319a..8852ee172 100644 --- a/nitronode/metrics/exporter.go +++ b/nitronode/metrics/exporter.go @@ -251,7 +251,7 @@ func NewRuntimeMetricExporter(reg prometheus.Registerer) (RuntimeMetricExporter, Help: "Total transactions recorded. Labels: asset, tx_type (see " + "core.TransactionType — transfer / release / commit / home_deposit " + "/ home_withdrawal / mutual_lock / escrow_deposit / escrow_lock / " + - "escrow_withdraw / migrate / rebalance / finalize), application_id. " + + "escrow_withdraw / migrate / finalize), application_id. " + "Pair with transactions_amount_total for value-weighted views.", }, []string{"asset", "tx_type", "application_id"}), transactionsAmountTotal: prometheus.NewCounterVec(prometheus.CounterOpts{ diff --git a/nitronode/metrics/interface.go b/nitronode/metrics/interface.go index ccb2b9f64..350f568f5 100644 --- a/nitronode/metrics/interface.go +++ b/nitronode/metrics/interface.go @@ -80,12 +80,12 @@ func (noopRuntimeMetricExporter) IncAppSessionUpdateSigValidation(string, app.Ap } func (noopRuntimeMetricExporter) IncBlockchainAction(string, uint64, string, bool) { } -func (noopRuntimeMetricExporter) IncBlockchainEvent(uint64, bool) {} -func (noopRuntimeMetricExporter) IncRPCInflight(string) {} -func (noopRuntimeMetricExporter) DecRPCInflight(string) {} -func (noopRuntimeMetricExporter) ObserveDBQueryDuration(string, time.Duration) {} +func (noopRuntimeMetricExporter) IncBlockchainEvent(uint64, bool) {} +func (noopRuntimeMetricExporter) IncRPCInflight(string) {} +func (noopRuntimeMetricExporter) DecRPCInflight(string) {} +func (noopRuntimeMetricExporter) ObserveDBQueryDuration(string, time.Duration) {} func (noopRuntimeMetricExporter) SeedRPCMethodMetrics([]string, map[string][]string) {} -func (noopRuntimeMetricExporter) SeedBlockchainEventMetrics([]uint64) {} +func (noopRuntimeMetricExporter) SeedBlockchainEventMetrics([]uint64) {} // StoreMetricExporter defines the interface for setting metrics that are stored and updated by a separate metric worker. // diff --git a/nitronode/runtime.go b/nitronode/runtime.go index 3cdd7f3e8..11ca96268 100644 --- a/nitronode/runtime.go +++ b/nitronode/runtime.go @@ -15,7 +15,6 @@ import ( "github.com/joho/godotenv" "github.com/prometheus/client_golang/prometheus" - "github.com/layer-3/nitrolite/nitronode/action_gateway" "github.com/layer-3/nitrolite/nitronode/metrics" "github.com/layer-3/nitrolite/nitronode/store/database" "github.com/layer-3/nitrolite/nitronode/store/memory" @@ -36,16 +35,12 @@ type Backbone struct { NodeVersion string ChannelMinChallengeDuration uint32 ChannelMaxChallengeDuration uint32 - AppRegistryEnabled bool BlockchainRPCs map[uint64]string BlockchainGasLimit uint64 ValidationLimits ValidationLimits - RateLimitPerSec float64 - RateLimitBurst float64 DbStore database.DatabaseStore MemoryStore memory.MemoryStore - ActionGateway action_gateway.ActionAllower RpcNode rpc.Node StateSigner sign.Signer TxSigner sign.Signer @@ -70,8 +65,6 @@ type FullConfig struct { Database database.DatabaseConfig ChannelMinChallengeDuration uint32 `yaml:"channel_min_challenge_duration" env:"NITRONODE_CHANNEL_MIN_CHALLENGE_DURATION" env-default:"86400"` // 24 hours ChannelMaxChallengeDuration uint32 `yaml:"channel_max_challenge_duration" env:"NITRONODE_CHANNEL_MAX_CHALLENGE_DURATION" env-default:"604800"` // 7 days - ActionLimitsEnabled bool `yaml:"action_limits_enabled" env:"NITRONODE_ACTION_LIMITS_ENABLED"` - AppRegistryEnabled bool `yaml:"app_registry_enabled" env:"NITRONODE_APP_REGISTRY_ENABLED"` Signer SignerConfig `yaml:"signer"` SignerType string `yaml:"signer_type" env:"NITRONODE_SIGNER_TYPE" env-default:"key"` // "key" or "gcp-kms" SignerKey string `yaml:"signer_key" env:"NITRONODE_SIGNER_KEY"` // required when signer_type=key @@ -103,11 +96,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"` + MaxSessionKeyIDs int `yaml:"max_session_key_ids" env:"NITRONODE_MAX_SESSION_KEY_IDS" env-default:"10"` + // MaxSessionKeysPerUser is a soft per-user cap on active session keys, a DoS/storage + // bound enforced when a submit activates a key (new registration or reactivation). + // A value <= 0 disables the cap entirely (unlimited). Default 100. MaxSessionKeysPerUser int `yaml:"max_session_keys_per_user" env:"NITRONODE_MAX_SESSION_KEYS_PER_USER" env-default:"100"` } @@ -150,6 +144,21 @@ func validateChannelChallengeConfig(minChallenge, maxChallenge uint32) error { return nil } +// maxParticipantsUint16Safe is the largest MaxParticipants value that cannot overflow the +// uint16 quorum-weight accumulators: 257 × 255 = 65535 = math.MaxUint16. +const maxParticipantsUint16Safe = 257 + +func validateValidationLimits(vl ValidationLimits) error { + if vl.MaxParticipants > maxParticipantsUint16Safe { + return fmt.Errorf( + "NITRONODE_MAX_PARTICIPANTS must be ≤ %d to prevent quorum-weight overflow (uint16 ceiling), got %d", + maxParticipantsUint16Safe, + vl.MaxParticipants, + ) + } + return nil +} + // InitBackbone initializes the backbone components of the application. func InitBackbone() *Backbone { closers := []func() error{} // collect closer functions for resources that need cleanup @@ -176,6 +185,9 @@ func InitBackbone() *Backbone { if err := validateBlockchainGasLimit(conf.BlockchainGasLimit); err != nil { logger.Fatal("invalid blockchain gas limit config", "error", err) } + if err := validateValidationLimits(conf.ValidationLimits); err != nil { + logger.Fatal("invalid validation limits config", "error", err) + } logger.Info("config loaded", "version", Version) @@ -198,21 +210,6 @@ func InitBackbone() *Backbone { logger.Fatal("failed to load blockchains", "error", err) } - // ------------------------------------------------ - // Action Gateway - // ------------------------------------------------ - - var actionGateway action_gateway.ActionAllower - if conf.ActionLimitsEnabled { - actionGateway, err = action_gateway.NewActionGatewayFromYaml(configDirPath) - if err != nil { - logger.Fatal("failed to initialize action gateway", "error", err) - } - } else { - actionGateway = action_gateway.NewPermissiveActionAllower() - logger.Info("action limits disabled, using permissive action allower") - } - // ------------------------------------------------ // Signer // ------------------------------------------------ @@ -286,6 +283,21 @@ func InitBackbone() *Backbone { ) } } + + // Per-connection request-count budget. Enforced at the frame layer alongside + // the byte budget so a flood of tiny frames — malformed or unknown-method + // frames included, which never reach the handler chain — is throttled before + // it can be parsed. Set <=0 to disable. + reqPerSec := conf.RateLimitPerSec + reqBurst := conf.RateLimitBurst + if reqPerSec > 0 && reqBurst < 1 { + logger.Fatal( + "NITRONODE_RATE_LIMIT_BURST must be >= 1 when NITRONODE_RATE_LIMIT_PER_SEC is enabled", + "rate_limit_burst", reqBurst, + "rate_limit_per_sec", reqPerSec, + ) + } + rpcNode, err := rpc.NewWebsocketNode(rpc.WebsocketNodeConfig{ Logger: logger, ObserveConnections: runtimeMetrics.SetRPCConnections, @@ -293,10 +305,21 @@ func InitBackbone() *Backbone { WsConnWriteBufferSize: conf.WsWriteBufferSize, WsConnMaxMessageSize: conf.WsMaxMessageSize, NewFrameRateLimiter: func() rpc.FrameRateLimiter { - if bytesPerSec <= 0 { + var limiters rpc.CompositeFrameRateLimiter + if bytesPerSec > 0 { + limiters = append(limiters, rpc.NewByteTokenBucket(bytesPerSec, bytesBurst)) + } + if reqPerSec > 0 { + limiters = append(limiters, rpc.NewRequestTokenBucket(reqPerSec, reqBurst)) + } + switch len(limiters) { + case 0: return rpc.NoopFrameRateLimiter{} + case 1: + return limiters[0] + default: + return limiters } - return rpc.NewByteTokenBucket(bytesPerSec, bytesBurst) }, }) if err != nil { @@ -322,16 +345,12 @@ func InitBackbone() *Backbone { NodeVersion: Version, ChannelMinChallengeDuration: conf.ChannelMinChallengeDuration, ChannelMaxChallengeDuration: conf.ChannelMaxChallengeDuration, - AppRegistryEnabled: conf.AppRegistryEnabled, BlockchainRPCs: blockchainRPCs, BlockchainGasLimit: conf.BlockchainGasLimit, ValidationLimits: conf.ValidationLimits, - RateLimitPerSec: conf.RateLimitPerSec, - RateLimitBurst: conf.RateLimitBurst, DbStore: dbStore, MemoryStore: memoryStore, - ActionGateway: actionGateway, RpcNode: rpcNode, StateSigner: stateSigner, TxSigner: txSigner, @@ -461,9 +480,9 @@ func initBlockchainRPCs(logger log.Logger, memoryStore memory.MemoryStore) map[u // checkChainId connects to an RPC endpoint and verifies it returns the expected chain ID. // This ensures the RPC URL points to the correct blockchain network. -// The function uses a 5-second timeout for the connection and chain ID query. +// Bounded by evm.RPCCallTimeout for the connection and chain ID query. func checkChainId(blockchainRPC string, expectedChainID uint64) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), evm.RpcCallTimeout) defer cancel() client, err := ethclient.DialContext(ctx, blockchainRPC) @@ -486,9 +505,9 @@ func checkChainId(blockchainRPC string, expectedChainID uint64) error { // checkChannelHubVersion verifies that the ChannelHub contract at the given address // has the expected VERSION constant value. -// The function uses a 5-second timeout for the connection and contract calls. +// Bounded by evm.RPCCallTimeout for the connection and contract calls. func checkChannelHubVersion(blockchainRPC string, channelHubAddress common.Address, expectedVersion uint8) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), evm.RpcCallTimeout) defer cancel() client, err := ethclient.DialContext(ctx, blockchainRPC) diff --git a/nitronode/store/database/action_log.go b/nitronode/store/database/action_log.go deleted file mode 100644 index d6e331cad..000000000 --- a/nitronode/store/database/action_log.go +++ /dev/null @@ -1,91 +0,0 @@ -package database - -import ( - "fmt" - "strings" - "time" - - "github.com/google/uuid" - "github.com/layer-3/nitrolite/pkg/core" -) - -type ActionLogEntryV1 struct { - ID uuid.UUID `gorm:"type:char(36);primaryKey"` - UserWallet string `gorm:"column:user_wallet;not null"` - GatedAction uint8 `gorm:"column:gated_action;not null"` - CreatedAt time.Time -} - -func (ActionLogEntryV1) TableName() string { - return "action_log_v1" -} - -// RecordAction inserts a new action log entry for a user. -func (s *DBStore) RecordAction(wallet string, gatedAction core.GatedAction) error { - if gatedAction.ID() == 0 { - return fmt.Errorf("invalid gated action ID") - } - - wallet = strings.ToLower(wallet) - - entry := ActionLogEntryV1{ - ID: uuid.New(), - UserWallet: wallet, - GatedAction: gatedAction.ID(), - CreatedAt: time.Now(), - } - - if err := s.db.Create(&entry).Error; err != nil { - return fmt.Errorf("failed to record action log entry: %w", err) - } - - return nil -} - -// GetUserActionCount returns the number of actions matching the given wallet and gated action -// within the specified time window (counting backwards from now). -func (s *DBStore) GetUserActionCount(wallet string, gatedAction core.GatedAction, window time.Duration) (uint64, error) { - wallet = strings.ToLower(wallet) - since := time.Now().Add(-window) - - query := s.db.Model(&ActionLogEntryV1{}). - Where("user_wallet = ? AND gated_action = ? AND created_at >= ?", wallet, gatedAction.ID(), since) - - var count int64 - if err := query.Count(&count).Error; err != nil { - return 0, fmt.Errorf("failed to get user action count: %w", err) - } - - return uint64(count), nil -} - -func (s *DBStore) GetUserActionCounts(userWallet string, window time.Duration) (map[core.GatedAction]uint64, error) { - userWallet = strings.ToLower(userWallet) - since := time.Now().Add(-window) - - query := s.db.Model(&ActionLogEntryV1{}). - Select("gated_action, COUNT(id) as count"). - Where("user_wallet = ? AND created_at >= ?", userWallet, since). - Group("gated_action") - - type Result struct { - GatedAction uint8 - Count int64 - } - - var results []Result - if err := query.Scan(&results).Error; err != nil { - return nil, fmt.Errorf("failed to get user action counts: %w", err) - } - - counts := make(map[core.GatedAction]uint64) - for _, r := range results { - action, ok := core.GatedActionFromID(r.GatedAction) - if !ok { - continue - } - counts[action] = uint64(r.Count) - } - - return counts, nil -} diff --git a/nitronode/store/database/action_log_test.go b/nitronode/store/database/action_log_test.go deleted file mode 100644 index a0a5cf262..000000000 --- a/nitronode/store/database/action_log_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package database - -import ( - "testing" - "time" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRecordAction(t *testing.T) { - t.Run("records action successfully", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - err := store.RecordAction("0xUser123", core.GatedActionTransfer) - require.NoError(t, err) - - count, err := store.GetUserActionCount("0xuser123", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(1), count) - }) - - t.Run("records multiple actions", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - - count, err := store.GetUserActionCount("0xuser", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(3), count) - }) - - t.Run("normalizes wallet to lowercase", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, store.RecordAction("0xABCDEF", core.GatedActionTransfer)) - - count, err := store.GetUserActionCount("0xabcdef", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(1), count) - }) -} - -func TestGetUserActionCount(t *testing.T) { - t.Run("returns zero for no actions", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - count, err := store.GetUserActionCount("0xuser", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(0), count) - }) - - t.Run("filters by gated action", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionAppSessionOperation)) - - transferCount, err := store.GetUserActionCount("0xuser", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(2), transferCount) - - opCount, err := store.GetUserActionCount("0xuser", core.GatedActionAppSessionOperation, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(1), opCount) - }) - - t.Run("filters by wallet", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, store.RecordAction("0xuser1", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser1", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser2", core.GatedActionTransfer)) - - count, err := store.GetUserActionCount("0xuser1", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(2), count) - - count, err = store.GetUserActionCount("0xuser2", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(1), count) - }) - - t.Run("respects time window", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - oldEntry := ActionLogEntryV1{ - ID: [16]byte{1}, - UserWallet: "0xuser", - GatedAction: core.GatedActionTransfer.ID(), - CreatedAt: time.Now().Add(-2 * time.Hour), - } - require.NoError(t, db.Create(&oldEntry).Error) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - - count, err := store.GetUserActionCount("0xuser", core.GatedActionTransfer, time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(1), count) - - count, err = store.GetUserActionCount("0xuser", core.GatedActionTransfer, 3*time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(2), count) - }) -} - -func TestGetUserActionCounts(t *testing.T) { - t.Run("returns empty map for no actions", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - counts, err := store.GetUserActionCounts("0xuser", time.Hour) - require.NoError(t, err) - assert.Empty(t, counts) - }) - - t.Run("groups by gated action", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionAppSessionOperation)) - - counts, err := store.GetUserActionCounts("0xuser", time.Hour) - require.NoError(t, err) - - assert.Equal(t, uint64(2), counts[core.GatedActionTransfer]) - assert.Equal(t, uint64(1), counts[core.GatedActionAppSessionOperation]) - }) - - t.Run("filters by wallet", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, store.RecordAction("0xuser1", core.GatedActionTransfer)) - require.NoError(t, store.RecordAction("0xuser2", core.GatedActionTransfer)) - - counts, err := store.GetUserActionCounts("0xuser1", time.Hour) - require.NoError(t, err) - assert.Len(t, counts, 1) - }) - - t.Run("respects time window", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - oldEntry := ActionLogEntryV1{ - ID: [16]byte{1}, - UserWallet: "0xuser", - GatedAction: core.GatedActionTransfer.ID(), - CreatedAt: time.Now().Add(-2 * time.Hour), - } - require.NoError(t, db.Create(&oldEntry).Error) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - - counts, err := store.GetUserActionCounts("0xuser", time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(1), counts[core.GatedActionTransfer]) - - counts, err = store.GetUserActionCounts("0xuser", 3*time.Hour) - require.NoError(t, err) - assert.Equal(t, uint64(2), counts[core.GatedActionTransfer]) - }) - - t.Run("skips unknown gated action IDs", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - // Insert an entry with an unknown gated action ID directly - entry := ActionLogEntryV1{ - ID: [16]byte{99}, - UserWallet: "0xuser", - GatedAction: 255, // unknown ID - CreatedAt: time.Now(), - } - require.NoError(t, db.Create(&entry).Error) - require.NoError(t, store.RecordAction("0xuser", core.GatedActionTransfer)) - - counts, err := store.GetUserActionCounts("0xuser", time.Hour) - require.NoError(t, err) - assert.Len(t, counts, 1) - assert.Equal(t, uint64(1), counts[core.GatedActionTransfer]) - }) -} - -func TestGetAppCount(t *testing.T) { - t.Run("returns zero for no apps", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - count, err := store.GetAppCount("0xowner") - require.NoError(t, err) - assert.Equal(t, uint64(0), count) - }) - - t.Run("counts apps for owner", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, db.Create(&AppV1{ID: "app1", OwnerWallet: "0xowner1", Metadata: "{}"}).Error) - require.NoError(t, db.Create(&AppV1{ID: "app2", OwnerWallet: "0xowner1", Metadata: "{}"}).Error) - require.NoError(t, db.Create(&AppV1{ID: "app3", OwnerWallet: "0xowner2", Metadata: "{}"}).Error) - - count, err := store.GetAppCount("0xowner1") - require.NoError(t, err) - assert.Equal(t, uint64(2), count) - - count, err = store.GetAppCount("0xowner2") - require.NoError(t, err) - assert.Equal(t, uint64(1), count) - }) - - t.Run("normalizes wallet to lowercase", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - store := NewDBStore(db) - - require.NoError(t, db.Create(&AppV1{ID: "app1", OwnerWallet: "0xabcdef", Metadata: "{}"}).Error) - - count, err := store.GetAppCount("0xABCDEF") - require.NoError(t, err) - assert.Equal(t, uint64(1), count) - }) -} diff --git a/nitronode/store/database/app.go b/nitronode/store/database/app.go deleted file mode 100644 index 5df3757f8..000000000 --- a/nitronode/store/database/app.go +++ /dev/null @@ -1,118 +0,0 @@ -package database - -import ( - "fmt" - "strings" - "time" - - "github.com/layer-3/nitrolite/pkg/app" - "github.com/layer-3/nitrolite/pkg/core" - "gorm.io/gorm" -) - -// AppV1 represents an application registry entry in the database. -type AppV1 struct { - ID string `gorm:"primaryKey"` - OwnerWallet string `gorm:"column:owner_wallet;not null"` - Metadata string `gorm:"column:metadata;type:text;not null"` - Version uint64 `gorm:"column:version;default:1"` - CreationApprovalNotRequired bool `gorm:"column:creation_approval_not_required"` - CreatedAt time.Time - UpdatedAt time.Time -} - -func (AppV1) TableName() string { - return "apps_v1" -} - -// CreateApp registers a new application. Returns an error if the app ID already exists. -func (s *DBStore) CreateApp(entry app.AppV1) error { - dbApp := AppV1{ - ID: strings.ToLower(entry.ID), - OwnerWallet: strings.ToLower(entry.OwnerWallet), - Metadata: entry.Metadata, - Version: entry.Version, - CreationApprovalNotRequired: entry.CreationApprovalNotRequired, - } - - if err := s.db.Create(&dbApp).Error; err != nil { - return fmt.Errorf("failed to create app: %w", err) - } - - return nil -} - -// GetApp retrieves a single application by ID. Returns nil if not found. -func (s *DBStore) GetApp(appID string) (*app.AppInfoV1, error) { - var dbApp AppV1 - err := s.db.Where("id = ?", strings.ToLower(appID)).First(&dbApp).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, nil - } - return nil, fmt.Errorf("failed to get app: %w", err) - } - - result := databaseAppToCore(&dbApp) - return &result, nil -} - -// GetApps retrieves applications with optional filtering by app ID, owner wallet, and pagination. -func (s *DBStore) GetApps(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) { - query := s.db.Model(&AppV1{}) - - if appID != nil && *appID != "" { - query = query.Where("id = ?", strings.ToLower(*appID)) - } - - if ownerWallet != nil && *ownerWallet != "" { - query = query.Where("owner_wallet = ?", strings.ToLower(*ownerWallet)) - } - - var totalCount int64 - if err := query.Count(&totalCount).Error; err != nil { - return nil, core.PaginationMetadata{}, fmt.Errorf("failed to count apps: %w", err) - } - - offset, limit := pagination.GetOffsetAndLimit(DefaultLimit, MaxLimit) - - query = query.Order("created_at DESC").Offset(int(offset)).Limit(int(limit)) - - var dbApps []AppV1 - if err := query.Find(&dbApps).Error; err != nil { - return nil, core.PaginationMetadata{}, fmt.Errorf("failed to get apps: %w", err) - } - - apps := make([]app.AppInfoV1, len(dbApps)) - for i, dbApp := range dbApps { - apps[i] = databaseAppToCore(&dbApp) - } - - metadata := calculatePaginationMetadata(totalCount, offset, limit) - - return apps, metadata, nil -} - -func databaseAppToCore(dbApp *AppV1) app.AppInfoV1 { - return app.AppInfoV1{ - App: app.AppV1{ - ID: dbApp.ID, - OwnerWallet: dbApp.OwnerWallet, - Metadata: dbApp.Metadata, - Version: dbApp.Version, - CreationApprovalNotRequired: dbApp.CreationApprovalNotRequired, - }, - CreatedAt: dbApp.CreatedAt, - UpdatedAt: dbApp.UpdatedAt, - } -} - -func (s *DBStore) GetAppCount(ownerWallet string) (uint64, error) { - var count int64 - err := s.db.Model(&AppV1{}).Where("owner_wallet = ?", strings.ToLower(ownerWallet)).Count(&count).Error - if err != nil { - return 0, fmt.Errorf("failed to count apps: %w", err) - } - - return uint64(count), nil -} diff --git a/nitronode/store/database/app_session.go b/nitronode/store/database/app_session.go index 223a79972..175480e39 100644 --- a/nitronode/store/database/app_session.go +++ b/nitronode/store/database/app_session.go @@ -116,7 +116,7 @@ func (s *DBStore) GetAppSessions(appSessionID *string, participant *string, stat offset, limit := pagination.GetOffsetAndLimit(DefaultLimit, MaxLimit) - query = query.Preload("Participants").Order("created_at DESC").Offset(int(offset)).Limit(int(limit)) + query = query.Preload("Participants").Order("created_at DESC").Offset(core.SafeOffset(offset)).Limit(int(limit)) var dbSessions []AppSessionV1 if err := query.Find(&dbSessions).Error; err != nil { @@ -128,7 +128,10 @@ func (s *DBStore) GetAppSessions(appSessionID *string, participant *string, stat sessions[i] = *databaseAppSessionToCore(&dbSession) } - metadata := calculatePaginationMetadata(totalCount, offset, limit) + metadata, err := calculatePaginationMetadata(totalCount, offset, limit) + if err != nil { + return nil, core.PaginationMetadata{}, fmt.Errorf("failed to calculate pagination: %w", err) + } return sessions, metadata, nil } diff --git a/nitronode/store/database/app_session_key_state.go b/nitronode/store/database/app_session_key_state.go index 522b17754..f8cb626f8 100644 --- a/nitronode/store/database/app_session_key_state.go +++ b/nitronode/store/database/app_session_key_state.go @@ -6,6 +6,7 @@ import ( "time" "github.com/layer-3/nitrolite/pkg/app" + "github.com/layer-3/nitrolite/pkg/core" "gorm.io/gorm" ) @@ -139,7 +140,7 @@ func (s *DBStore) GetLastAppSessionKeyStates(wallet string, sessionKey *string, Preload("AppSessionIDs"). Order("app_session_key_states_v1.created_at DESC, app_session_key_states_v1.id ASC"). Limit(int(limit)). - Offset(int(offset)) + Offset(core.SafeOffset(offset)) if sessionKey != nil && *sessionKey != "" { query = query.Where("c.session_key = ?", strings.ToLower(*sessionKey)) } diff --git a/nitronode/store/database/app_test.go b/nitronode/store/database/app_test.go deleted file mode 100644 index 2293ddada..000000000 --- a/nitronode/store/database/app_test.go +++ /dev/null @@ -1,317 +0,0 @@ -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 TestAppV1_TableName(t *testing.T) { - a := AppV1{} - assert.Equal(t, "apps_v1", a.TableName()) -} - -func TestDBStore_CreateApp(t *testing.T) { - t.Run("Success", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - entry := app.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0xabcdef", - Version: 1, - CreationApprovalNotRequired: true, - } - - err := store.CreateApp(entry) - require.NoError(t, err) - - // Verify app was created - var dbApp AppV1 - err = db.Where("id = ?", "test-app").First(&dbApp).Error - require.NoError(t, err) - - assert.Equal(t, "test-app", dbApp.ID) - assert.Equal(t, "0x1111111111111111111111111111111111111111", dbApp.OwnerWallet) - assert.Equal(t, "0xabcdef", dbApp.Metadata) - assert.Equal(t, uint64(1), dbApp.Version) - assert.True(t, dbApp.CreationApprovalNotRequired) - }) - - t.Run("Duplicate ID error", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - entry := app.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0xabcdef", - Version: 1, - } - - err := store.CreateApp(entry) - require.NoError(t, err) - - // Try to create again with same ID - err = store.CreateApp(entry) - assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to create app") - }) - - t.Run("Stores lowercase ID and wallet", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - entry := app.AppV1{ - ID: "My-App", - OwnerWallet: "0xABCD1234567890ABCDEF1234567890ABCDEF1234", - Metadata: "0x00", - Version: 1, - } - - err := store.CreateApp(entry) - require.NoError(t, err) - - var dbApp AppV1 - err = db.Where("id = ?", "my-app").First(&dbApp).Error - require.NoError(t, err) - - assert.Equal(t, "my-app", dbApp.ID) - assert.Equal(t, "0xabcd1234567890abcdef1234567890abcdef1234", dbApp.OwnerWallet) - }) -} - -func TestDBStore_GetApp(t *testing.T) { - t.Run("Found", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - entry := app.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0xabcdef", - Version: 1, - CreationApprovalNotRequired: true, - } - require.NoError(t, store.CreateApp(entry)) - - result, err := store.GetApp("test-app") - require.NoError(t, err) - require.NotNil(t, result) - - assert.Equal(t, "test-app", result.App.ID) - assert.Equal(t, "0x1111111111111111111111111111111111111111", result.App.OwnerWallet) - assert.Equal(t, "0xabcdef", result.App.Metadata) - assert.Equal(t, uint64(1), result.App.Version) - assert.True(t, result.App.CreationApprovalNotRequired) - }) - - t.Run("Not found returns nil", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - result, err := store.GetApp("nonexistent") - require.NoError(t, err) - assert.Nil(t, result) - }) - - t.Run("Case-insensitive lookup", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - entry := app.AppV1{ - ID: "test-app", - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x00", - Version: 1, - } - require.NoError(t, store.CreateApp(entry)) - - // Look up with different casing - result, err := store.GetApp("Test-App") - require.NoError(t, err) - require.NotNil(t, result) - assert.Equal(t, "test-app", result.App.ID) - }) -} - -func TestDBStore_GetApps(t *testing.T) { - t.Run("No filter", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-1", OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x01", Version: 1, - })) - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-2", OwnerWallet: "0x2222222222222222222222222222222222222222", - Metadata: "0x02", Version: 1, - })) - - // Small delay to ensure different created_at times - time.Sleep(10 * time.Millisecond) - - pagination := &core.PaginationParams{} - apps, metadata, err := store.GetApps(nil, nil, pagination) - require.NoError(t, err) - - assert.Len(t, apps, 2) - assert.Equal(t, uint32(2), metadata.TotalCount) - }) - - t.Run("Filter by appID", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-1", OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x01", Version: 1, - })) - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-2", OwnerWallet: "0x2222222222222222222222222222222222222222", - Metadata: "0x02", Version: 1, - })) - - appID := "app-1" - pagination := &core.PaginationParams{} - apps, metadata, err := store.GetApps(&appID, nil, pagination) - require.NoError(t, err) - - assert.Len(t, apps, 1) - assert.Equal(t, uint32(1), metadata.TotalCount) - assert.Equal(t, "app-1", apps[0].App.ID) - }) - - t.Run("Filter by ownerWallet", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - owner := "0x1111111111111111111111111111111111111111" - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-1", OwnerWallet: owner, Metadata: "0x01", Version: 1, - })) - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-2", OwnerWallet: "0x2222222222222222222222222222222222222222", - Metadata: "0x02", Version: 1, - })) - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-3", OwnerWallet: owner, Metadata: "0x03", Version: 1, - })) - - pagination := &core.PaginationParams{} - apps, metadata, err := store.GetApps(nil, &owner, pagination) - require.NoError(t, err) - - assert.Len(t, apps, 2) - assert.Equal(t, uint32(2), metadata.TotalCount) - for _, a := range apps { - assert.Equal(t, owner, a.App.OwnerWallet) - } - }) - - t.Run("Combined filters", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - owner := "0x1111111111111111111111111111111111111111" - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-1", OwnerWallet: owner, Metadata: "0x01", Version: 1, - })) - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-2", OwnerWallet: owner, Metadata: "0x02", Version: 1, - })) - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-3", OwnerWallet: "0x2222222222222222222222222222222222222222", - Metadata: "0x03", Version: 1, - })) - - appID := "app-1" - pagination := &core.PaginationParams{} - apps, metadata, err := store.GetApps(&appID, &owner, pagination) - require.NoError(t, err) - - assert.Len(t, apps, 1) - assert.Equal(t, uint32(1), metadata.TotalCount) - assert.Equal(t, "app-1", apps[0].App.ID) - assert.Equal(t, owner, apps[0].App.OwnerWallet) - }) - - t.Run("Pagination", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - for i := 1; i <= 3; i++ { - require.NoError(t, store.CreateApp(app.AppV1{ - ID: "app-" + string(rune(i+'0')), - OwnerWallet: "0x1111111111111111111111111111111111111111", - Metadata: "0x00", - Version: 1, - })) - time.Sleep(10 * time.Millisecond) - } - - limit := uint32(2) - offset := uint32(0) - pagination := &core.PaginationParams{Limit: &limit, Offset: &offset} - - apps, metadata, err := store.GetApps(nil, nil, pagination) - require.NoError(t, err) - - assert.Len(t, apps, 2) - assert.Equal(t, uint32(3), metadata.TotalCount) - assert.Equal(t, uint32(1), metadata.Page) - assert.Equal(t, uint32(2), metadata.PerPage) - - // Second page - offset = 2 - pagination.Offset = &offset - apps, metadata, err = store.GetApps(nil, nil, pagination) - require.NoError(t, err) - - assert.Len(t, apps, 1) - assert.Equal(t, uint32(2), metadata.Page) - }) - - t.Run("Empty results", func(t *testing.T) { - db, cleanup := SetupTestDB(t) - defer cleanup() - - store := NewDBStore(db) - - appID := "nonexistent" - pagination := &core.PaginationParams{} - apps, metadata, err := store.GetApps(&appID, nil, pagination) - require.NoError(t, err) - - assert.Empty(t, apps) - assert.Equal(t, uint32(0), metadata.TotalCount) - }) -} diff --git a/nitronode/store/database/blockchain_action_test.go b/nitronode/store/database/blockchain_action_test.go index 1605b2a0d..3fae6b712 100644 --- a/nitronode/store/database/blockchain_action_test.go +++ b/nitronode/store/database/blockchain_action_test.go @@ -34,7 +34,7 @@ func TestDBStore_ScheduleCheckpoint(t *testing.T) { UserBalance: decimal.NewFromInt(1000), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) err := store.ScheduleCheckpoint(state.ID, 0) require.NoError(t, err) @@ -73,7 +73,7 @@ func TestDBStore_ScheduleInitiateEscrowWithdrawal(t *testing.T) { UserBalance: decimal.NewFromInt(500), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) err := store.ScheduleInitiateEscrowWithdrawal(state.ID, 0) require.NoError(t, err) @@ -107,7 +107,7 @@ func TestDBStore_ScheduleFinalizeEscrowDeposit(t *testing.T) { UserBalance: decimal.NewFromInt(100), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) err := store.ScheduleFinalizeEscrowDeposit(state.ID, 0) require.NoError(t, err) @@ -139,7 +139,7 @@ func TestDBStore_ScheduleFinalizeEscrowWithdrawal(t *testing.T) { UserBalance: decimal.NewFromInt(200), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) err := store.ScheduleFinalizeEscrowWithdrawal(state.ID, 0) require.NoError(t, err) @@ -171,7 +171,7 @@ func TestDBStore_Fail(t *testing.T) { UserBalance: decimal.NewFromInt(300), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.ScheduleCheckpoint(state.ID, 0)) var action BlockchainAction @@ -211,7 +211,7 @@ func TestDBStore_FailNoRetry(t *testing.T) { UserBalance: decimal.NewFromInt(400), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.ScheduleCheckpoint(state.ID, 0)) var action BlockchainAction @@ -251,7 +251,7 @@ func TestDBStore_RecordAttempt(t *testing.T) { UserBalance: decimal.NewFromInt(150), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.ScheduleCheckpoint(state.ID, 0)) var action BlockchainAction @@ -291,7 +291,7 @@ func TestDBStore_Complete(t *testing.T) { UserBalance: decimal.NewFromInt(600), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.ScheduleCheckpoint(state.ID, 0)) var action BlockchainAction @@ -329,7 +329,7 @@ func TestDBStore_Complete(t *testing.T) { UserBalance: decimal.NewFromInt(250), }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.ScheduleCheckpoint(state.ID, 0)) var action BlockchainAction @@ -390,9 +390,9 @@ func TestDBStore_GetActions(t *testing.T) { HomeLedger: core.Ledger{UserBalance: decimal.NewFromInt(300)}, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) - require.NoError(t, store.StoreUserState(state3, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) + require.NoError(t, storeLocked(t, store, state3, "")) require.NoError(t, store.ScheduleCheckpoint(state1.ID, 0)) require.NoError(t, store.ScheduleInitiateEscrowWithdrawal(state2.ID, 0)) @@ -425,7 +425,7 @@ func TestDBStore_GetActions(t *testing.T) { Transition: core.Transition{}, HomeLedger: core.Ledger{UserBalance: decimal.NewFromInt(int64(100 * (i + 1)))}, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.ScheduleCheckpoint(state.ID, 0)) } @@ -471,9 +471,9 @@ func TestDBStore_GetActions(t *testing.T) { HomeLedger: core.Ledger{UserBalance: decimal.NewFromInt(300)}, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) - require.NoError(t, store.StoreUserState(state3, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) + require.NoError(t, storeLocked(t, store, state3, "")) require.NoError(t, store.ScheduleCheckpoint(state1.ID, 0)) require.NoError(t, store.ScheduleCheckpoint(state2.ID, 0)) diff --git a/nitronode/store/database/channel.go b/nitronode/store/database/channel.go index 33a94c45e..39c1846aa 100644 --- a/nitronode/store/database/channel.go +++ b/nitronode/store/database/channel.go @@ -189,7 +189,7 @@ func (s *DBStore) GetUserChannels(wallet string, status *core.ChannelStatus, ass } var dbChannels []Channel - if err := query.Order("created_at DESC").Limit(int(limit)).Offset(int(offset)).Find(&dbChannels).Error; err != nil { + if err := query.Order("created_at DESC").Limit(int(limit)).Offset(core.SafeOffset(offset)).Find(&dbChannels).Error; err != nil { return nil, 0, fmt.Errorf("failed to get user channels: %w", err) } @@ -225,14 +225,17 @@ func (s *DBStore) GetChannelsCountByLabels() ([]ChannelCount, error) { return results, nil } -// UpdateChannel persists changes to a channel's metadata (status, version, etc). +// UpdateChannel persists changes to a channel's mutable lifecycle fields +// (status, state_version, challenge_expires_at). Immutable configuration fields +// are not touched here — see CreateChannel. func (s *DBStore) UpdateChannel(channel core.Channel) error { + // Only mutable lifecycle fields are persisted here. Immutable identity and + // configuration fields (blockchain_id, token, nonce, ...) are set once in + // CreateChannel and intentionally excluded so a partial or stale core.Channel + // passed by a caller cannot corrupt a channel's metadata. updates := map[string]interface{}{ "status": channel.Status, "state_version": channel.StateVersion, - "blockchain_id": channel.BlockchainID, - "token": strings.ToLower(channel.TokenAddress), - "nonce": channel.Nonce, "challenge_expires_at": channel.ChallengeExpiresAt, "updated_at": time.Now(), } diff --git a/nitronode/store/database/channel_session_key_state.go b/nitronode/store/database/channel_session_key_state.go index 750b36cdb..5662d2bd0 100644 --- a/nitronode/store/database/channel_session_key_state.go +++ b/nitronode/store/database/channel_session_key_state.go @@ -120,7 +120,7 @@ func (s *DBStore) GetLastChannelSessionKeyStates(wallet string, sessionKey *stri Preload("Assets"). Order("channel_session_key_states_v1.created_at DESC, channel_session_key_states_v1.id ASC"). Limit(int(limit)). - Offset(int(offset)) + Offset(core.SafeOffset(offset)) if sessionKey != nil && *sessionKey != "" { query = query.Where("c.session_key = ?", strings.ToLower(*sessionKey)) } diff --git a/nitronode/store/database/channel_test.go b/nitronode/store/database/channel_test.go index 54ce7aa07..44d07d4c2 100644 --- a/nitronode/store/database/channel_test.go +++ b/nitronode/store/database/channel_test.go @@ -2,6 +2,7 @@ package database import ( "fmt" + "os" "testing" "time" @@ -9,6 +10,8 @@ import ( "github.com/shopspring/decimal" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gorm.io/gorm" + "gorm.io/gorm/clause" ) func TestChannel_TableName(t *testing.T) { @@ -194,6 +197,141 @@ func TestDBStore_GetChannelByID(t *testing.T) { }) } +func TestDBStore_LockUserStateForHomeChannel(t *testing.T) { + t.Run("Success - returns channel and ensures balance row", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + channel := core.Channel{ + ChannelID: "0xhomechannel123", + UserWallet: "0xuser123", + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + TokenAddress: "0xtoken123", + ChallengeDuration: 86400, + Nonce: 1, + Status: core.ChannelStatusOpen, + StateVersion: 7, + } + require.NoError(t, store.CreateChannel(channel)) + + result, err := store.LockUserStateForHomeChannel("0xhomechannel123") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "0xhomechannel123", result.ChannelID) + assert.Equal(t, "0xuser123", result.UserWallet) + assert.Equal(t, core.ChannelStatusOpen, result.Status) + assert.Equal(t, uint64(7), result.StateVersion) + + // The balance row keyed by the channel's (wallet, asset) is ensured by the lock. + balances, err := store.GetUserBalances("0xuser123") + require.NoError(t, err) + require.Len(t, balances, 1) + assert.Equal(t, "usdc", balances[0].Asset) + }) + + t.Run("Channel not found returns nil", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + result, err := store.LockUserStateForHomeChannel("0xnonexistent") + require.NoError(t, err) + assert.Nil(t, result) + }) + + // Regression for MF3-H02: a status flip that commits while LockUserStateForHomeChannel is + // blocked on the balance lock must be reflected in the returned channel. Requires real + // FOR UPDATE concurrency, so it only runs against Postgres. + t.Run("Postgres - returns status committed while waiting on the lock", func(t *testing.T) { + if os.Getenv("TEST_DB_DRIVER") != "postgres" { + t.Skip("requires postgres for FOR UPDATE concurrency semantics") + } + + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + channel := core.Channel{ + ChannelID: "0xhomechannel123", + UserWallet: "0xuser123", + Asset: "usdc", + Type: core.ChannelTypeHome, + BlockchainID: 1, + Status: core.ChannelStatusOpen, + StateVersion: 7, + } + require.NoError(t, store.CreateChannel(channel)) + // Ensure the balance row exists so the competing transaction has a row to lock. + _, err := store.LockUserStateForHomeChannel(channel.ChannelID) + require.NoError(t, err) + + locked := make(chan struct{}) + proceed := make(chan struct{}) + txDone := make(chan error, 1) + + // Competing transaction: hold the balance lock, flip status to Closing, commit only + // once the main call is blocking on the lock. + go func() { + txDone <- db.Transaction(func(tx *gorm.DB) error { + var b UserBalance + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("user_wallet = ? AND asset = ?", channel.UserWallet, channel.Asset). + First(&b).Error; err != nil { + return err + } + if err := tx.Model(&Channel{}). + Where("channel_id = ?", channel.ChannelID). + Update("status", core.ChannelStatusClosing).Error; err != nil { + return err + } + close(locked) + <-proceed + return nil + }) + }() + + <-locked + + type lockResult struct { + channel *core.Channel + err error + } + got := make(chan lockResult, 1) + go func() { + ch, err := store.LockUserStateForHomeChannel(channel.ChannelID) + got <- lockResult{channel: ch, err: err} + }() + + // Wait until the call is actually blocked on the balance-row lock before letting the + // competing transaction commit Closing. A fixed sleep is non-deterministic: under load + // the goroutine might not yet be in SELECT ... FOR UPDATE, so the competitor would commit + // before the lock is contended and the race would never be exercised. Poll pg_locks for an + // ungranted lock instead — that only appears once a backend is waiting on the lock. + require.Eventually(t, func() bool { + var waiting int64 + if err := db.Raw(`SELECT count(*) FROM pg_locks WHERE NOT granted`).Scan(&waiting).Error; err != nil { + return false + } + return waiting > 0 + }, 5*time.Second, 5*time.Millisecond, "LockUserStateForHomeChannel never blocked on the balance lock") + close(proceed) + + require.NoError(t, <-txDone) + + res := <-got + require.NoError(t, res.err) + require.NotNil(t, res.channel) + assert.Equal(t, core.ChannelStatusClosing, res.channel.Status, + "channel status must reflect the transition committed while waiting on the lock") + }) +} + func TestDBStore_GetActiveHomeChannel(t *testing.T) { t.Run("Success - Get active home channel", func(t *testing.T) { db, cleanup := SetupTestDB(t) @@ -234,7 +372,7 @@ func TestDBStore_GetActiveHomeChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetActiveHomeChannel("0xuser123", "USDC") require.NoError(t, err) @@ -295,7 +433,7 @@ func TestDBStore_GetActiveHomeChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetActiveHomeChannel("0xuser123", "USDC") require.NoError(t, err) @@ -341,7 +479,7 @@ func TestDBStore_GetActiveHomeChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetActiveHomeChannel("0xuser123", "USDC") require.NoError(t, err) @@ -369,7 +507,7 @@ func TestDBStore_GetActiveHomeChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetActiveHomeChannel("0xuser123", "USDC") require.NoError(t, err) @@ -441,7 +579,6 @@ func TestDBStore_GetNotClosedHomeChannel(t *testing.T) { }) } - func TestDBStore_CheckActiveChannel(t *testing.T) { t.Run("Success - Has open channel", func(t *testing.T) { db, cleanup := SetupTestDB(t) @@ -483,7 +620,7 @@ func TestDBStore_CheckActiveChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "USDC") require.NoError(t, err) @@ -543,7 +680,7 @@ func TestDBStore_CheckActiveChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "USDC") require.NoError(t, err) @@ -590,7 +727,7 @@ func TestDBStore_CheckActiveChannel(t *testing.T) { NodeNetFlow: decimal.Zero, }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) // Check for different asset approvedSigValidators, status, err := store.CheckActiveChannel("0xuser123", "ETH") @@ -712,7 +849,7 @@ func TestDBStore_UpdateChannel(t *testing.T) { assert.NotNil(t, result.ChallengeExpiresAt) }) - t.Run("Success - Update blockchain and token", func(t *testing.T) { + t.Run("Immutable config fields are not modified", func(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() @@ -732,20 +869,26 @@ func TestDBStore_UpdateChannel(t *testing.T) { } require.NoError(t, store.CreateChannel(channel)) - // Update blockchain and token + // A caller mutating immutable identity/config fields must not corrupt the + // stored channel — UpdateChannel persists only mutable lifecycle fields. channel.BlockchainID = 137 channel.TokenAddress = "0xnewtoken456" + channel.Nonce = 999 + channel.StateVersion = 5 err := store.UpdateChannel(channel) require.NoError(t, err) - // Verify update result, err := store.GetChannelByID("0xhomechannel123") require.NoError(t, err) require.NotNil(t, result) - assert.Equal(t, uint64(137), result.BlockchainID) - assert.Equal(t, "0xnewtoken456", result.TokenAddress) + // Immutable fields keep their CreateChannel values. + assert.Equal(t, uint64(1), result.BlockchainID) + assert.Equal(t, "0xtoken123", result.TokenAddress) + assert.Equal(t, uint64(1), result.Nonce) + // Mutable lifecycle field is updated. + assert.Equal(t, uint64(5), result.StateVersion) }) t.Run("Error - Update non-existent channel", func(t *testing.T) { diff --git a/nitronode/store/database/contract_event.go b/nitronode/store/database/contract_event.go index cdc5af2db..5c6b8fa64 100644 --- a/nitronode/store/database/contract_event.go +++ b/nitronode/store/database/contract_event.go @@ -17,6 +17,7 @@ type ContractEvent struct { BlockchainID uint64 `gorm:"column:blockchain_id"` Name string `gorm:"column:name"` BlockNumber uint64 `gorm:"column:block_number"` + BlockHash string `gorm:"column:block_hash"` TransactionHash string `gorm:"column:transaction_hash"` LogIndex uint32 `gorm:"column:log_index"` CreatedAt time.Time `gorm:"column:created_at"` @@ -34,6 +35,7 @@ func (s *DBStore) StoreContractEvent(ev core.BlockchainEvent) error { BlockchainID: ev.BlockchainID, Name: ev.Name, BlockNumber: ev.BlockNumber, + BlockHash: ev.BlockHash, TransactionHash: strings.ToLower(ev.TransactionHash), LogIndex: ev.LogIndex, CreatedAt: time.Now(), @@ -55,11 +57,12 @@ func (s *DBStore) GetLatestContractEventBlockNumber(contractAddress string, bloc return blockNumber, nil } -// IsContractEventPresent checks whether a specific contract event has already been stored. -func (s *DBStore) IsContractEventPresent(blockchainID, blockNumber uint64, txHash string, logIndex uint32) (bool, error) { +// IsContractEventProcessed reports whether an event identified by (txHash, logIndex, blockchainID) +// has already been committed, regardless of which block it appeared in. +func (s *DBStore) IsContractEventProcessed(txHash string, logIndex uint32, blockchainID uint64) (bool, error) { var ev ContractEvent - err := s.db.Where("blockchain_id = ? AND block_number = ? AND transaction_hash = ? AND log_index = ?", - blockchainID, blockNumber, strings.ToLower(txHash), logIndex). + err := s.db.Where("transaction_hash = ? AND log_index = ? AND blockchain_id = ?", + strings.ToLower(txHash), logIndex, blockchainID). Take(&ev).Error if errors.Is(err, gorm.ErrRecordNotFound) { return false, nil @@ -69,3 +72,37 @@ func (s *DBStore) IsContractEventPresent(blockchainID, blockNumber uint64, txHas } return true, nil } + +// GetLatestContractEventBlockHashAndNumber returns the block_number and block_hash of the +// highest stored event for the given contract. Returns (0, "", nil) when no rows exist. +func (s *DBStore) GetLatestContractEventBlockHashAndNumber(contractAddress string, blockchainID uint64) (uint64, string, error) { + var ev ContractEvent + err := s.db.Where("blockchain_id = ? AND contract_address = ?", blockchainID, strings.ToLower(contractAddress)). + Order("block_number DESC"). + First(&ev).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, "", nil + } + if err != nil { + return 0, "", err + } + return ev.BlockNumber, ev.BlockHash, nil +} + +// GetPreviousDistinctBlockHash returns the block_number and block_hash of the highest +// stored event whose block_number is strictly below belowBlockNumber. Returns (0, "", nil) +// when no such row exists (signals genesis fallback). +func (s *DBStore) GetPreviousDistinctBlockHash(contractAddress string, blockchainID uint64, belowBlockNumber uint64) (uint64, string, error) { + var ev ContractEvent + err := s.db.Where("blockchain_id = ? AND contract_address = ? AND block_number < ?", + blockchainID, strings.ToLower(contractAddress), belowBlockNumber). + Order("block_number DESC"). + First(&ev).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, "", nil + } + if err != nil { + return 0, "", err + } + return ev.BlockNumber, ev.BlockHash, nil +} diff --git a/nitronode/store/database/contract_event_test.go b/nitronode/store/database/contract_event_test.go index 11a28ab02..1e14e5127 100644 --- a/nitronode/store/database/contract_event_test.go +++ b/nitronode/store/database/contract_event_test.go @@ -82,7 +82,7 @@ func TestGetLatestContractEventBlockNumber(t *testing.T) { }) } -func TestIsContractEventPresent(t *testing.T) { +func TestIsContractEventProcessed(t *testing.T) { db, cleanup := SetupTestDB(t) defer cleanup() @@ -100,38 +100,32 @@ func TestIsContractEventPresent(t *testing.T) { require.NoError(t, store.StoreContractEvent(ev)) t.Run("existing event returns true", func(t *testing.T) { - present, err := store.IsContractEventPresent(1, 500, ev.TransactionHash, 3) + present, err := store.IsContractEventProcessed(ev.TransactionHash, 3, 1) require.NoError(t, err) assert.True(t, present) }) t.Run("case-insensitive txHash match", func(t *testing.T) { // Query with uppercase — stored value was lowercased by StoreContractEvent - present, err := store.IsContractEventPresent(1, 500, "0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890", 3) + present, err := store.IsContractEventProcessed("0xABCDEF1234567890ABCDEF1234567890ABCDEF1234567890ABCDEF1234567890", 3, 1) require.NoError(t, err) assert.True(t, present) }) - t.Run("wrong block number returns false", func(t *testing.T) { - present, err := store.IsContractEventPresent(1, 501, ev.TransactionHash, 3) - require.NoError(t, err) - assert.False(t, present) - }) - t.Run("wrong log index returns false", func(t *testing.T) { - present, err := store.IsContractEventPresent(1, 500, ev.TransactionHash, 4) + present, err := store.IsContractEventProcessed(ev.TransactionHash, 4, 1) require.NoError(t, err) assert.False(t, present) }) t.Run("wrong blockchain returns false", func(t *testing.T) { - present, err := store.IsContractEventPresent(2, 500, ev.TransactionHash, 3) + present, err := store.IsContractEventProcessed(ev.TransactionHash, 3, 2) require.NoError(t, err) assert.False(t, present) }) t.Run("wrong txHash returns false", func(t *testing.T) { - present, err := store.IsContractEventPresent(1, 500, "0x0000000000000000000000000000000000000000000000000000000000000000", 3) + present, err := store.IsContractEventProcessed("0x0000000000000000000000000000000000000000000000000000000000000000", 3, 1) require.NoError(t, err) assert.False(t, present) }) diff --git a/nitronode/store/database/current_session_key_state.go b/nitronode/store/database/current_session_key_state.go index 4e4dcb166..bbae87921 100644 --- a/nitronode/store/database/current_session_key_state.go +++ b/nitronode/store/database/current_session_key_state.go @@ -92,12 +92,14 @@ func upsertCurrentSessionKeyState(tx *gorm.DB, userAddress, sessionKey string, k // 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. +// Seed-row permanence: the version=0 row written below is part of the caller's transaction, +// so it persists only when that transaction commits. Failure paths that abort the tx (version +// mismatch, cap exceeded, mid-tx errors) roll the seed back with everything else, and callers +// must guard against committing a seed for an unauthorized claim — e.g. the submit handlers +// reject a revoke at version 1, so a wallet cannot stake a claim on (session_key, kind) without +// a prior delegation it proved possession of. Once a submit does commit, the ownership bind is +// permanent: no other wallet can take that (session_key, kind). CountSessionKeysForUser excludes +// version=0 rows so the per-user cap is unaffected. // // When locked.Version > 0, the matching history row's expires_at is also returned so callers // can distinguish a reactivation (prev inactive → submitted active) from a rotation/update diff --git a/nitronode/store/database/database.go b/nitronode/store/database/database.go index 87796ed8e..ee376ad5c 100644 --- a/nitronode/store/database/database.go +++ b/nitronode/store/database/database.go @@ -246,7 +246,6 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { func migrateSqlite(db *gorm.DB) error { if err := db.AutoMigrate( - &AppV1{}, &AppLedgerEntryV1{}, &Channel{}, &AppSessionV1{}, @@ -262,8 +261,6 @@ func migrateSqlite(db *gorm.DB) error { &ChannelSessionKeyAssetV1{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, - &UserStakedV1{}, - &ActionLogEntryV1{}, &LifespanMetric{}, ); err != nil { return err diff --git a/nitronode/store/database/db_store.go b/nitronode/store/database/db_store.go index a9f146795..78b684819 100644 --- a/nitronode/store/database/db_store.go +++ b/nitronode/store/database/db_store.go @@ -47,8 +47,8 @@ func (s *DBStore) GetUserBalances(wallet string) ([]core.BalanceEntry, error) { result := make([]core.BalanceEntry, 0, len(balances)) for _, balance := range balances { result = append(result, core.BalanceEntry{ - Asset: balance.Asset, - Balance: balance.Balance, + Asset: balance.Asset, + Balance: balance.Balance, Enforced: balance.Enforced, }) } @@ -145,6 +145,66 @@ func (s *DBStore) LockUserState(wallet, asset string) (decimal.Decimal, error) { return balance.Balance, nil } +// LockUserStateForHomeChannel acquires the balance-row lock of the user owning channelID and +// returns the channel read *after* the lock is held. The order matters: the lock is taken first, +// then the channel is read in a separate statement, so a concurrent transaction (e.g. submit_state +// co-signing a Finalize and flipping the channel to Closing) that commits while we wait on the lock +// is reflected in the returned status. Callers that previously did GetChannelByID followed by +// LockUserState must use this instead — the separate read is read-before-lock and races. +// +// Returns (nil, nil) if the channel does not exist. Channel-type checks remain the caller's +// responsibility. +func (s *DBStore) LockUserStateForHomeChannel(channelID string) (*core.Channel, error) { + channelID = strings.ToLower(channelID) + if !strings.HasPrefix(channelID, "0x") { + channelID = "0x" + channelID + } + + // Non-postgres (sqlite in tests) cannot SELECT ... FOR UPDATE and has no real concurrency + // in those paths; resolve, ensure the balance row via LockUserState, and read directly. + if s.db.Dialector.Name() != "postgres" { + channel, err := s.GetChannelByID(channelID) + if err != nil || channel == nil { + return channel, err + } + if _, err := s.LockUserState(channel.UserWallet, channel.Asset); err != nil { + return nil, err + } + return channel, nil + } + + // Resolve the channel's (wallet, asset) lock key. These columns are immutable for a given + // channel, so reading them at this statement's snapshot is safe even though status is not. + var key struct { + UserWallet string + Asset string + } + result := s.db.Raw(`SELECT user_wallet, asset FROM channels WHERE channel_id = ?`, channelID).Scan(&key) + if result.Error != nil { + return nil, fmt.Errorf("failed to resolve lock key for channel %s: %w", channelID, result.Error) + } + if result.RowsAffected == 0 { + return nil, nil + } + + // Acquire the (wallet, asset) balance-row lock first (ensures the row, then SELECT ... FOR + // UPDATE). This blocks until any concurrent transaction holding the row commits. + if _, err := s.LockUserState(key.UserWallet, key.Asset); err != nil { + return nil, err + } + + // Read the channel only after the lock is held. A single SELECT ... FOR UPDATE OF b that + // joins channels would return c.* from the statement-start snapshot — a Finalize that flips + // status to Closing while we wait on the balance lock would not be reflected. This separate + // statement takes a fresh snapshot once the lock is acquired, so the returned status reflects + // any such committed transition. + // + // NOTE: requires READ COMMITTED isolation (Postgres default, and nitronode never overrides it). + // Under REPEATABLE READ or SERIALIZABLE this statement still sees the transaction-start + // snapshot, returning the stale pre-lock status and negating the fix. + return s.GetChannelByID(channelID) +} + // EnsureNoOngoingStateTransitions validates that no conflicting blockchain operations are pending. // This method prevents race conditions by ensuring blockchain state versions // match the user's last signed state version before accepting new transitions. @@ -164,6 +224,7 @@ func (s *DBStore) EnsureNoOngoingStateTransitions(wallet, asset string) error { TransitionType core.TransitionType StateVersion uint64 HomeChannelVersion *uint64 + HomeChannelStatus *core.ChannelStatus EscrowChannelVersion *uint64 } @@ -173,6 +234,7 @@ func (s *DBStore) EnsureNoOngoingStateTransitions(wallet, asset string) error { s.transition_type as transition_type, s.version as state_version, hc.state_version as home_channel_version, + hc.status as home_channel_status, ec.state_version as escrow_channel_version FROM channel_states s LEFT JOIN channels hc ON hc.channel_id = s.home_channel_id @@ -198,14 +260,24 @@ func (s *DBStore) EnsureNoOngoingStateTransitions(wallet, asset string) error { // Validation logic by transition type switch result.TransitionType { case core.TransitionTypeHomeDeposit: - // Verify last_state.version == home_channel.state_version - if result.HomeChannelVersion == nil || result.StateVersion != *result.HomeChannelVersion { + // Verify last_state.version == home_channel.state_version AND channel is Open. + // Defence-in-depth: without the status check, a Void channel + // (status=Void, state_version=0) trivially matches a state_version=0 signed + // HomeDeposit and the gate would treat the deposit as settled on-chain before + // any confirmation event lands. + if result.HomeChannelVersion == nil || + result.HomeChannelStatus == nil || + result.StateVersion != *result.HomeChannelVersion || + *result.HomeChannelStatus != core.ChannelStatusOpen { 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 { + // Verify last_state.version == home_channel.state_version AND channel is Open. + if result.HomeChannelVersion == nil || + result.HomeChannelStatus == nil || + result.StateVersion != *result.HomeChannelVersion || + *result.HomeChannelStatus != core.ChannelStatusOpen { return fmt.Errorf("home withdrawal is still ongoing") } diff --git a/nitronode/store/database/db_store_test.go b/nitronode/store/database/db_store_test.go index 72125e980..324098eb6 100644 --- a/nitronode/store/database/db_store_test.go +++ b/nitronode/store/database/db_store_test.go @@ -35,11 +35,7 @@ func TestDBStore_GetUserBalances(t *testing.T) { }, } - // Lock user state before storing (ensures row exists in user_balances) - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) balances, err := store.GetUserBalances("0xuser123") require.NoError(t, err) @@ -91,14 +87,8 @@ func TestDBStore_GetUserBalances(t *testing.T) { }, } - // Lock user states before storing (ensures rows exist in user_balances) - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - _, err = store.LockUserState("0xuser123", "ETH") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) balances, err := store.GetUserBalances("0xuser123") require.NoError(t, err) @@ -163,12 +153,8 @@ func TestDBStore_GetUserBalances(t *testing.T) { }, } - // Lock user state before storing (ensures row exists in user_balances) - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) balances, err := store.GetUserBalances("0xuser123") require.NoError(t, err) @@ -219,12 +205,8 @@ func TestDBStore_GetUserBalances(t *testing.T) { }, } - // Lock user state before storing (ensures row exists in user_balances) - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) balances, err := store.GetUserBalances("0xuser123") require.NoError(t, err) @@ -302,13 +284,9 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { NodeSig: &nodeSig, } - // Lock user state before storing - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") require.NoError(t, err) }) @@ -358,13 +336,9 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { NodeSig: &nodeSig, } - // Lock user state before storing - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") assert.Error(t, err) assert.Contains(t, err.Error(), "home deposit is still ongoing") }) @@ -438,13 +412,9 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { NodeSig: &nodeSig, } - // Lock user state before storing - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") require.NoError(t, err) }) @@ -517,13 +487,9 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { NodeSig: &nodeSig, } - // Lock user state before storing - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) + require.NoError(t, storeLocked(t, store, state, "")) - require.NoError(t, store.StoreUserState(state, "")) - - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") assert.Error(t, err) assert.Contains(t, err.Error(), "mutual lock is still ongoing") }) @@ -582,13 +548,9 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { NodeSig: &nodeSig, } - // Lock user state before storing - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) + require.NoError(t, storeLocked(t, store, state, "")) - require.NoError(t, store.StoreUserState(state, "")) - - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") require.NoError(t, err) }) @@ -635,14 +597,10 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { // No signatures } - // Lock user state before storing - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) // Should not error because there's no signed state - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") require.NoError(t, err) }) @@ -717,9 +675,7 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { 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, "")) + require.NoError(t, storeLocked(t, store, state, "")) } t.Run("HomeDeposit - home channel missing from DB - block", func(t *testing.T) { @@ -809,6 +765,46 @@ func TestDBStore_EnsureNoOngoingStateTransitions(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "home chain migration is still ongoing") }) + + t.Run("HomeDeposit - Void channel version=0 must block", func(t *testing.T) { + // Pre-fix bug: a Void channel (status=Void, state_version=0) trivially + // matched a node-signed HomeDeposit at version=0, so the gate let the user + // spend funds that were never deposited on-chain. The fix requires + // channel.status == Open in addition to the version match. + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + voidChannel := newHomeChannel(0) + voidChannel.Status = core.ChannelStatusVoid + require.NoError(t, store.CreateChannel(voidChannel)) + + storeState(t, store, newSignedState(0, 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 - Void channel version=0 must block", func(t *testing.T) { + // Mirror of the HomeDeposit Void/v=0 regression: the version-match-only gate + // would have let a HomeWithdrawal at version=0 settle against a Void channel. + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + voidChannel := newHomeChannel(0) + voidChannel.Status = core.ChannelStatusVoid + require.NoError(t, store.CreateChannel(voidChannel)) + + storeState(t, store, newSignedState(0, core.TransitionTypeHomeWithdrawal, false)) + + err := store.EnsureNoOngoingStateTransitions(wallet, asset) + require.Error(t, err) + assert.Contains(t, err.Error(), "home withdrawal is still ongoing") + }) } func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { @@ -842,9 +838,6 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { // 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{ @@ -866,7 +859,7 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { UserSig: &bilateralUserSig, NodeSig: &bilateralNodeSig, } - require.NoError(t, store.StoreUserState(bilateral, "")) + require.NoError(t, storeLocked(t, store, bilateral, "")) nodeOnly := core.State{ ID: "state2", @@ -886,10 +879,10 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { }, NodeSig: &nodeSig, } - require.NoError(t, store.StoreUserState(nodeOnly, "")) + require.NoError(t, storeLocked(t, store, nodeOnly, "")) // Pre-backfill: gate sees bilateral row at version 1, channel.state_version is 2 → mismatch. - err = store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") + err := store.EnsureNoOngoingStateTransitions("0xuser123", "USDC") require.Error(t, err) // Backfill the user signature recovered from the on-chain event. @@ -931,9 +924,6 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { } require.NoError(t, store.CreateChannel(channel)) - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) - state := core.State{ ID: "state1", Asset: "USDC", @@ -953,7 +943,7 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { UserSig: &userSig, NodeSig: &nodeSig, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) // Replayed event would carry a different (or any) sig; existing one must not be overwritten. require.NoError(t, store.UpdateStateSigsIfMissing(homeChannelID, 1, "0xshould-not-overwrite", "0xshould-not-overwrite-node")) @@ -1005,8 +995,6 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { StateVersion: 1, })) - _, err := store.LockUserState("0xuser123", "USDC") - require.NoError(t, err) userSig := "0xusersigonly" state := core.State{ ID: "state-user-only", @@ -1024,7 +1012,7 @@ func TestDBStore_UpdateStateSigsIfMissing(t *testing.T) { }, UserSig: &userSig, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) require.NoError(t, store.UpdateStateSigsIfMissing(homeChannelID, 1, "0xshould-not-overwrite-user", "0xrecoverednode")) @@ -1088,9 +1076,7 @@ func TestDBStore_SumNetTransitionAmountAfterVersion(t *testing.T) { nodeSig := "0xnodesig" s.NodeSig = &nodeSig } - _, err := store.LockUserState(wallet, asset) - require.NoError(t, err) - require.NoError(t, store.StoreUserState(s, "")) + require.NoError(t, storeLocked(t, store, s, "")) } t.Run("Scenario 3 - dust during challenge (unsigned receive only)", func(t *testing.T) { @@ -1276,9 +1262,7 @@ func TestDBStore_HasSignedFinalize(t *testing.T) { nodeSig := "0xnodesig" s.NodeSig = &nodeSig } - _, err := store.LockUserState(wallet, asset) - require.NoError(t, err) - require.NoError(t, store.StoreUserState(s, "")) + require.NoError(t, storeLocked(t, store, s, "")) } t.Run("True when node-signed Finalize exists", func(t *testing.T) { @@ -1395,9 +1379,7 @@ func TestDBStore_EnsureNoOngoingEscrowOperation(t *testing.T) { 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, "")) + require.NoError(t, storeLocked(t, store, state, "")) } t.Run("No previous state - allow", func(t *testing.T) { diff --git a/nitronode/store/database/interface.go b/nitronode/store/database/interface.go index 98d5fc408..b832e1c05 100644 --- a/nitronode/store/database/interface.go +++ b/nitronode/store/database/interface.go @@ -28,6 +28,13 @@ type DatabaseStore interface { // Returns the current balance or zero if the row was just inserted. LockUserState(wallet, asset string) (decimal.Decimal, error) + // LockUserStateForHomeChannel locks the balance row of the user owning channelID (must be used + // within a transaction). On postgres the lock key is derived from the channel in SQL and the + // channel is read under the lock, avoiding the stale pre-lock snapshot of a GetChannelByID + + // LockUserState pair; on non-postgres (sqlite in tests) the snapshot is taken before the lock + // for test compatibility. Returns nil if the channel does not exist. + LockUserStateForHomeChannel(channelID string) (*core.Channel, error) + // GetUserTransactions retrieves transaction history for a user with optional filters. GetUserTransactions(wallet string, asset *string, txType *core.TransactionType, fromTime *uint64, toTime *uint64, paginate *core.PaginationParams) ([]core.Transaction, core.PaginationMetadata, error) @@ -109,7 +116,6 @@ type DatabaseStore interface { // channel status field has been temporarily overwritten by an on-chain challenge. HasSignedFinalize(channelID string) (bool, error) - // SumNetTransitionAmountAfterVersion returns the net effect on the user's // home-channel balance of transitions stored against channelID strictly above // minVersion. Receiver credits (TransferReceive, Release) contribute positively; @@ -163,20 +169,6 @@ type DatabaseStore interface { // GetStateByID retrieves a state by its deterministic ID. GetStateByID(stateID string) (*core.State, error) - // --- App Registry Operations --- - - // CreateApp registers a new application. Returns an error if the app ID already exists. - CreateApp(entry app.AppV1) error - - // GetApp retrieves a single application by ID. Returns nil if not found. - GetApp(appID string) (*app.AppInfoV1, error) - - // GetApps retrieves applications with optional filtering by app ID, owner wallet, and pagination. - GetApps(appID *string, ownerWallet *string, pagination *core.PaginationParams) ([]app.AppInfoV1, core.PaginationMetadata, error) - - // GetAppCount returns the total number of applications owned by a specific wallet. - GetAppCount(ownerWallet string) (uint64, error) - // --- App Session Operations --- // CreateAppSession initializes a new application session. @@ -294,26 +286,6 @@ type DatabaseStore interface { // GetUserBalanceSummary returns the off-chain liquidity requirement per blockchain and asset. GetUserBalanceSummary() ([]UserBalanceSummary, error) - // --- User Staked Operations --- - - // UpdateUserStaked upserts the staked amount for a user on a specific blockchain. - UpdateUserStaked(wallet string, blockchainID uint64, amount decimal.Decimal) error - - // GetTotalUserStaked returns the total staked amount for a user across all blockchains. - GetTotalUserStaked(wallet string) (decimal.Decimal, error) - - // --- Action Log Operations --- - - // RecordAction inserts a new action log entry for a user. - RecordAction(wallet string, gatedAction core.GatedAction) error - - // GetUserActionCount returns the number of actions matching the given wallet, method, and path - // within the specified time window. - GetUserActionCount(wallet string, gatedAction core.GatedAction, window time.Duration) (uint64, error) - - // GetUserActionCounts returns a map of gated actions to their respective counts for a user within the specified time window. - GetUserActionCounts(userWallet string, window time.Duration) (map[core.GatedAction]uint64, error) - // --- Contract Event Operations --- // StoreContractEvent stores a blockchain event to prevent duplicate processing. @@ -322,6 +294,18 @@ type DatabaseStore interface { // GetLatestContractEventBlockNumber returns the highest block number for a given contract. GetLatestContractEventBlockNumber(contractAddress string, blockchainID uint64) (lastBlock uint64, err error) - // IsContractEventPresent checks if a specific contract event has already been stored. - IsContractEventPresent(blockchainID, blockNumber uint64, txHash string, logIndex uint32) (isPresent bool, err error) + // IsContractEventProcessed reports whether an event identified by (txHash, logIndex, blockchainID) + // has already been committed, regardless of which block it appeared in. + // NOTE: uses block-level logIndex — does not detect reorged events where the same tx + // re-mines with a different block-level log position (see nitronode/docs/reorg-fix.md §6.6). + IsContractEventProcessed(txHash string, logIndex uint32, blockchainID uint64) (bool, error) + + // GetLatestContractEventBlockHashAndNumber returns the block_number and block_hash of + // the highest stored event for the given contract. Returns (0, "", nil) when no rows exist. + GetLatestContractEventBlockHashAndNumber(contractAddress string, blockchainID uint64) (blockNumber uint64, blockHash string, err error) + + // GetPreviousDistinctBlockHash returns the block_number and block_hash of the highest + // stored event with block_number strictly below belowBlockNumber. Returns (0, "", nil) + // when no such row exists. + GetPreviousDistinctBlockHash(contractAddress string, blockchainID uint64, belowBlockNumber uint64) (blockNumber uint64, blockHash string, err error) } diff --git a/nitronode/store/database/lifespan_metric_test.go b/nitronode/store/database/lifespan_metric_test.go index 565456c9f..d41023e1e 100644 --- a/nitronode/store/database/lifespan_metric_test.go +++ b/nitronode/store/database/lifespan_metric_test.go @@ -274,9 +274,9 @@ func TestGetUserBalanceSummary(t *testing.T) { r := results[0] assert.Equal(t, "1", r.BlockchainID) assert.Equal(t, "usdc", r.Asset) - assert.True(t, decimal.NewFromInt(350).Equal(r.Total)) // 100+50+200 - assert.True(t, decimal.NewFromInt(40).Equal(r.Underfunded)) // only user1: 100-60 - assert.True(t, decimal.NewFromInt(30).Equal(r.Releasable)) // only user2: 80-50 + assert.True(t, decimal.NewFromInt(350).Equal(r.Total)) // 100+50+200 + assert.True(t, decimal.NewFromInt(40).Equal(r.Underfunded)) // only user1: 100-60 + assert.True(t, decimal.NewFromInt(30).Equal(r.Releasable)) // only user2: 80-50 }) t.Run("groups by blockchain and asset", func(t *testing.T) { diff --git a/nitronode/store/database/state.go b/nitronode/store/database/state.go index 01357a973..248f8eebf 100644 --- a/nitronode/store/database/state.go +++ b/nitronode/store/database/state.go @@ -153,16 +153,22 @@ func (s *DBStore) StoreUserState(state core.State, applicationID string) error { wallet := strings.ToLower(state.UserWallet) balance := dbState.HomeUserBalance - err = s.db.Model(&UserBalance{}). + res := s.db.Model(&UserBalance{}). Where("user_wallet = ? AND asset = ?", wallet, state.Asset). Updates(map[string]interface{}{ "balance": balance, "home_blockchain_id": state.HomeLedger.BlockchainID, "updated_at": time.Now(), - }).Error + }) - if err != nil { - return fmt.Errorf("failed to update user balance: %w", err) + if res.Error != nil { + return fmt.Errorf("failed to update user balance: %w", res.Error) + } + // The balance row must already exist (callers invoke LockUserState first, which + // inserts it). A zero-row update means the lock-before-store invariant was violated, + // so fail the transaction instead of leaving channel_states and user_balances divergent. + if res.RowsAffected != 1 { + return fmt.Errorf("failed to update user balance: expected 1 row affected, got %d (missing LockUserState?)", res.RowsAffected) } return nil @@ -268,7 +274,6 @@ func (s *DBStore) HasSignedFinalize(channelID string) (bool, error) { return count > 0, nil } - // SumNetTransitionAmountAfterVersion returns the net effect on the user's home-channel // balance of transitions stored against channelID strictly above minVersion. Receiver // credits (TransferReceive, Release) contribute positively; sender debits diff --git a/nitronode/store/database/state_test.go b/nitronode/store/database/state_test.go index 74af2795c..8755cfb5d 100644 --- a/nitronode/store/database/state_test.go +++ b/nitronode/store/database/state_test.go @@ -14,6 +14,17 @@ func TestState_TableName(t *testing.T) { assert.Equal(t, "channel_states", state.TableName()) } +// storeLocked mirrors the production invariant for tests: callers lock the user balance row +// (which creates it if absent) before storing a state. StoreUserState requires the balance +// row to exist, so tests that exercise it as a setup primitive must establish it the same way. +func storeLocked(t *testing.T, store DatabaseStore, state core.State, applicationID string) error { + t.Helper() + if _, err := store.LockUserState(state.UserWallet, state.Asset); err != nil { + return err + } + return store.StoreUserState(state, applicationID) +} + func TestDBStore_StoreUserState_ApplicationID(t *testing.T) { newState := func(id string) core.State { homeChannelID := "0xhomechannel123" @@ -43,10 +54,8 @@ func TestDBStore_StoreUserState_ApplicationID(t *testing.T) { defer cleanup() store := NewDBStore(db) - _, err := store.LockUserState("0xuserapp", "USDC") - require.NoError(t, err) - require.NoError(t, store.StoreUserState(newState("state-app"), "my-app")) + require.NoError(t, storeLocked(t, store, newState("state-app"), "my-app")) var dbState State require.NoError(t, db.Where("id = ?", "state-app").First(&dbState).Error) @@ -59,10 +68,8 @@ func TestDBStore_StoreUserState_ApplicationID(t *testing.T) { defer cleanup() store := NewDBStore(db) - _, err := store.LockUserState("0xuserapp", "USDC") - require.NoError(t, err) - require.NoError(t, store.StoreUserState(newState("state-noapp"), "")) + require.NoError(t, storeLocked(t, store, newState("state-noapp"), "")) var dbState State require.NoError(t, db.Where("id = ?", "state-noapp").First(&dbState).Error) @@ -103,7 +110,7 @@ func TestDBStore_StoreUserState(t *testing.T) { NodeSig: &nodeSig, } - err := store.StoreUserState(state, "") + err := storeLocked(t, store, state, "") require.NoError(t, err) // Verify state was stored @@ -163,7 +170,7 @@ func TestDBStore_StoreUserState(t *testing.T) { NodeSig: &nodeSig, } - err := store.StoreUserState(state, "") + err := storeLocked(t, store, state, "") require.NoError(t, err) // Verify state was stored @@ -200,13 +207,44 @@ func TestDBStore_StoreUserState(t *testing.T) { }, } - err := store.StoreUserState(state, "") + err := storeLocked(t, store, state, "") require.NoError(t, err) // Try to store again with same ID - err = store.StoreUserState(state, "") + err = storeLocked(t, store, state, "") assert.Error(t, err) }) + + t.Run("Error - No locked balance row", func(t *testing.T) { + db, cleanup := SetupTestDB(t) + defer cleanup() + + store := NewDBStore(db) + + homeChannelID := "0xhomechannel123" + state := core.State{ + ID: "state-nolock", + Asset: "USDC", + UserWallet: "0xuser123", + Epoch: 1, + Version: 1, + HomeChannelID: &homeChannelID, + Transition: core.Transition{}, + HomeLedger: core.Ledger{ + UserBalance: decimal.NewFromInt(1000), + UserNetFlow: decimal.Zero, + NodeBalance: decimal.Zero, + NodeNetFlow: decimal.Zero, + }, + } + + // No LockUserState first: the user_balances row does not exist, so the balance + // update matches zero rows and StoreUserState must error. Callers wrap this in a + // transaction, so the error rolls back the orphaned state row. + err := store.StoreUserState(state, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "expected 1 row affected") + }) } func TestDBStore_GetLastUserState(t *testing.T) { @@ -263,8 +301,8 @@ func TestDBStore_GetLastUserState(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) // Get last state result, err := store.GetLastUserState("0xuser123", "USDC", false) @@ -334,8 +372,8 @@ func TestDBStore_GetLastUserState(t *testing.T) { NodeSig: &nodeSig, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) // Get last signed state should return state2 result, err := store.GetLastUserState("0xuser123", "USDC", true) @@ -411,8 +449,8 @@ func TestDBStore_GetLastUserState(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) // Get last state - should prioritize higher epoch result, err := store.GetLastUserState("0xuser123", "USDC", false) @@ -463,7 +501,7 @@ func TestDBStore_GetLastStateByChannelID(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetLastStateByChannelID(homeChannelID, false) require.NoError(t, err) @@ -530,7 +568,7 @@ func TestDBStore_GetLastStateByChannelID(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetLastStateByChannelID(escrowChannelID, false) require.NoError(t, err) @@ -601,8 +639,8 @@ func TestDBStore_GetLastStateByChannelID(t *testing.T) { NodeSig: &nodeSig, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) result, err := store.GetLastStateByChannelID(homeChannelID, true) require.NoError(t, err) @@ -679,8 +717,8 @@ func TestDBStore_GetStateByChannelIDAndVersion(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) // Get version 1 result, err := store.GetStateByChannelIDAndVersion(homeChannelID, 1) @@ -757,7 +795,7 @@ func TestDBStore_GetStateByChannelIDAndVersion(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetStateByChannelIDAndVersion(escrowChannelID, 5) require.NoError(t, err) @@ -806,7 +844,7 @@ func TestDBStore_GetStateByChannelIDAndVersion(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state, "")) + require.NoError(t, storeLocked(t, store, state, "")) result, err := store.GetStateByChannelIDAndVersion(homeChannelID, 999) require.NoError(t, err) diff --git a/nitronode/store/database/test/postgres_integration_test.go b/nitronode/store/database/test/postgres_integration_test.go index 76be9150a..271e00602 100644 --- a/nitronode/store/database/test/postgres_integration_test.go +++ b/nitronode/store/database/test/postgres_integration_test.go @@ -16,6 +16,16 @@ import ( "gorm.io/gorm" ) +// storeLocked mirrors the production invariant: callers lock the user balance row (creating +// it if absent) before storing a state. StoreUserState requires the balance row to exist. +func storeLocked(t *testing.T, store database.DatabaseStore, state core.State, applicationID string) error { + t.Helper() + if _, err := store.LockUserState(state.UserWallet, state.Asset); err != nil { + return err + } + return store.StoreUserState(state, applicationID) +} + // PostgreSQL connection string should be provided via environment variable // Example: POSTGRES_DSN="host=localhost user=postgres password=postgres dbname=nitrolite_test port=5432 sslmode=disable" func getPostgresDB(t *testing.T) *gorm.DB { @@ -113,7 +123,7 @@ func TestPostgres_StateOperations(t *testing.T) { }, } - err := store.StoreUserState(state, "") + err := storeLocked(t, store, state, "") require.NoError(t, err) retrieved, err := store.GetLastUserState(wallet, asset, false) @@ -169,9 +179,9 @@ func TestPostgres_StateOperations(t *testing.T) { }, } - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) - require.NoError(t, store.StoreUserState(state3, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) + require.NoError(t, storeLocked(t, store, state3, "")) retrieved, err := store.GetLastUserState(wallet2, asset, false) require.NoError(t, err) @@ -446,8 +456,8 @@ func TestPostgres_UserBalances(t *testing.T) { _, err = store.LockUserState(wallet, "ETH") require.NoError(t, err) - require.NoError(t, store.StoreUserState(state1, "")) - require.NoError(t, store.StoreUserState(state2, "")) + require.NoError(t, storeLocked(t, store, state1, "")) + require.NoError(t, storeLocked(t, store, state2, "")) balances, err := store.GetUserBalances(wallet) require.NoError(t, err) @@ -496,7 +506,7 @@ func TestPostgres_DecimalPrecision(t *testing.T) { }, } - err := store.StoreUserState(state, "") + err := storeLocked(t, store, state, "") require.NoError(t, err) retrieved, err := store.GetLastUserState(wallet, "USDC", false) @@ -524,7 +534,7 @@ func TestPostgres_DecimalPrecision(t *testing.T) { }, } - err := store.StoreUserState(state, "") + err := storeLocked(t, store, state, "") require.NoError(t, err) retrieved, err := store.GetLastUserState(wallet, "ETH", false) diff --git a/nitronode/store/database/testing.go b/nitronode/store/database/testing.go index 24be889b6..138080f6f 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{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}) + err = database.AutoMigrate(&AppLedgerEntryV1{}, &AppSessionV1{}, &AppParticipantV1{}, &BlockchainAction{}, &Channel{}, &ContractEvent{}, &State{}, &Transaction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, &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{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}) + err = database.AutoMigrate(&AppLedgerEntryV1{}, &Channel{}, &AppSessionV1{}, &AppParticipantV1{}, &ContractEvent{}, &State{}, &Transaction{}, &BlockchainAction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &ChannelSessionKeyStateV1{}, &ChannelSessionKeyAssetV1{}, &CurrentSessionKeyStateV1{}, &UserBalance{}, &LifespanMetric{}) if err != nil { t.Fatalf("Failed to run migrations: %v", err) } diff --git a/nitronode/store/database/transaction.go b/nitronode/store/database/transaction.go index d8dd946b9..79eae1ac2 100644 --- a/nitronode/store/database/transaction.go +++ b/nitronode/store/database/transaction.go @@ -14,10 +14,12 @@ var ( ErrRecordTransaction = "failed to record transaction" ) -// Transaction represents an immutable transaction in the system -// ID is deterministic based on transaction initiation: -// 1) Initiated by User: Hash(ToAccount, SenderNewStateID) -// 2) Initiated by Node: Hash(FromAccount, ReceiverNewStateID) +// Transaction represents an immutable transaction in the system. +// ID equals the TxID of the transition that references this transaction +// (the single source of truth, canonicalised and validated in the state +// advancer). The TxID is deterministic: Hash(transition.AccountID, newStateID). +// For transfers, the sender's TransferSend and the receiver's TransferReceive +// share one TxID and therefore reference this single transaction. type Transaction struct { // ID is a 64-character deterministic hash ID string `gorm:"column:id;primaryKey;size:64"` @@ -106,7 +108,7 @@ func (s *DBStore) GetUserTransactions(accountID string, asset *string, txType *c offset, limit := paginate.GetOffsetAndLimit(DefaultLimit, MaxLimit) - query = query.Order("created_at DESC").Offset(int(offset)).Limit(int(limit)) + query = query.Order("created_at DESC").Offset(core.SafeOffset(offset)).Limit(int(limit)) var dbTransactions []Transaction if err := query.Find(&dbTransactions).Error; err != nil { @@ -118,7 +120,10 @@ func (s *DBStore) GetUserTransactions(accountID string, asset *string, txType *c transactions[i] = *toCoreTransaction(&dbTx) } - metadata := calculatePaginationMetadata(totalCount, offset, limit) + metadata, err := calculatePaginationMetadata(totalCount, offset, limit) + if err != nil { + return nil, core.PaginationMetadata{}, fmt.Errorf("failed to calculate pagination: %w", err) + } return transactions, metadata, nil } diff --git a/nitronode/store/database/user_staked.go b/nitronode/store/database/user_staked.go deleted file mode 100644 index 01db0fe18..000000000 --- a/nitronode/store/database/user_staked.go +++ /dev/null @@ -1,75 +0,0 @@ -package database - -import ( - "fmt" - "strings" - "time" - - "github.com/shopspring/decimal" - "gorm.io/gorm/clause" -) - -type UserStakedV1 struct { - UserWallet string `gorm:"column:user_wallet;primaryKey;not null"` - BlockchainID uint64 `gorm:"column:blockchain_id;primaryKey;not null"` - Amount decimal.Decimal `gorm:"column:amount;type:varchar(78);not null"` - CreatedAt time.Time - UpdatedAt time.Time -} - -func (UserStakedV1) TableName() string { - return "user_staked_v1" -} - -// UpdateUserStaked upserts the staked amount for a user on a specific blockchain. -func (s *DBStore) UpdateUserStaked(wallet string, blockchainID uint64, amount decimal.Decimal) error { - wallet = strings.ToLower(wallet) - - if wallet == "" { - return fmt.Errorf("wallet address must not be empty") - } - if blockchainID == 0 { - return fmt.Errorf("blockchain ID must not be zero") - } - if amount.IsNegative() { - return fmt.Errorf("staked amount must not be negative") - } - - now := time.Now() - - record := UserStakedV1{ - UserWallet: wallet, - BlockchainID: blockchainID, - Amount: amount, - CreatedAt: now, - UpdatedAt: now, - } - - err := s.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "user_wallet"}, {Name: "blockchain_id"}}, - DoUpdates: clause.AssignmentColumns([]string{"amount", "updated_at"}), - }).Create(&record).Error - if err != nil { - return fmt.Errorf("failed to update user staked amount: %w", err) - } - - return nil -} - -// GetTotalUserStaked returns the total staked amount for a user across all blockchains. -func (s *DBStore) GetTotalUserStaked(wallet string) (decimal.Decimal, error) { - wallet = strings.ToLower(wallet) - - var result struct { - Total decimal.Decimal `gorm:"column:total"` - } - err := s.db.Model(&UserStakedV1{}). - Where("user_wallet = ?", wallet). - Select("COALESCE(SUM(amount), 0) AS total"). - Scan(&result).Error - if err != nil { - return decimal.Zero, fmt.Errorf("failed to get user staked total: %w", err) - } - - return result.Total, nil -} diff --git a/nitronode/store/database/utils.go b/nitronode/store/database/utils.go index 5f7af224e..ae4ad4a68 100644 --- a/nitronode/store/database/utils.go +++ b/nitronode/store/database/utils.go @@ -172,18 +172,20 @@ func toCoreTransaction(dbTx *Transaction) *core.Transaction { } } -// calculatePaginationMetadata computes pagination metadata from total count, offset, and limit -func calculatePaginationMetadata(totalCount int64, offset, limit uint32) core.PaginationMetadata { - pageCount := uint32((totalCount + int64(limit) - 1) / int64(limit)) - currentPage := uint32(1) - if limit > 0 { - currentPage = offset/limit + 1 +// calculatePaginationMetadata computes pagination metadata from total count, offset, and limit. +// Returns an error if limit is 0, since a zero-sized page has no valid metadata representation. +func calculatePaginationMetadata(totalCount int64, offset, limit uint32) (core.PaginationMetadata, error) { + if limit == 0 { + return core.PaginationMetadata{}, fmt.Errorf("pagination limit must be greater than 0") } + pageCount := uint32((totalCount + int64(limit) - 1) / int64(limit)) + currentPage := offset/limit + 1 + return core.PaginationMetadata{ Page: currentPage, PerPage: limit, TotalCount: uint32(totalCount), PageCount: pageCount, - } + }, nil } diff --git a/nitronode/store/database/utils_test.go b/nitronode/store/database/utils_test.go new file mode 100644 index 000000000..af0b37154 --- /dev/null +++ b/nitronode/store/database/utils_test.go @@ -0,0 +1,30 @@ +package database + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCalculatePaginationMetadata_ZeroLimit(t *testing.T) { + _, err := calculatePaginationMetadata(100, 0, 0) + require.Error(t, err) + assert.Contains(t, err.Error(), "limit must be greater than 0") +} + +func TestCalculatePaginationMetadata_Normal(t *testing.T) { + meta, err := calculatePaginationMetadata(25, 0, 10) + require.NoError(t, err) + assert.Equal(t, uint32(3), meta.PageCount) + assert.Equal(t, uint32(1), meta.Page) + assert.Equal(t, uint32(10), meta.PerPage) + assert.Equal(t, uint32(25), meta.TotalCount) +} + +func TestCalculatePaginationMetadata_ExactPage(t *testing.T) { + meta, err := calculatePaginationMetadata(20, 10, 10) + require.NoError(t, err) + assert.Equal(t, uint32(2), meta.PageCount) + assert.Equal(t, uint32(2), meta.Page) +} diff --git a/nitronode/store/memory/asset_config.go b/nitronode/store/memory/asset_config.go index 3a00ea5f6..c75b6329f 100644 --- a/nitronode/store/memory/asset_config.go +++ b/nitronode/store/memory/asset_config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "gopkg.in/yaml.v3" ) @@ -21,6 +22,14 @@ type AssetsConfig struct { // AssetConfig represents configuration for a single asset (e.g., USDC, USDT). // An asset can have multiple token representations across different blockchains. +// +// All tokens listed under one asset are treated as fully fungible 1:1 +// representations of the same asset: off-chain credit denominated in this asset +// can be redeemed from any of these token inventories, regardless of which one +// originally backed it. Operators MUST NOT group economically non-equivalent +// tokens (e.g. a test token and production USDC) under one symbol — equivalence +// cannot be verified programmatically and is an operator responsibility. See +// docs/protocol/security-and-limitations.md ("Asset-symbol equivalence"). type AssetConfig struct { // Name is the human-readable name of the asset (e.g., "USD Coin") // If empty, it will inherit the Symbol value during validation @@ -95,6 +104,13 @@ func LoadAssets(configDirPath string) (AssetsConfig, error) { // Note: This method modifies token fields during validation but changes are not persisted // back to the original slice due to Go's value semantics in range loops. func verifyAssetsConfig(cfg *AssetsConfig) error { + // seenSymbols maps a canonical (lowercased) symbol to the index of the first + // enabled asset that declared it. Downstream logic matches asset symbols + // case-sensitively in some places (the in-memory supported-asset registry) + // and case-insensitively in others (the persistence layer), so allowing both + // "usdt" and "USDT" would make asset handling ambiguous. Fail closed instead. + seenSymbols := make(map[string]int) + for i, asset := range cfg.Assets { if asset.Disabled { continue @@ -103,6 +119,12 @@ func verifyAssetsConfig(cfg *AssetsConfig) error { if asset.Symbol == "" { return fmt.Errorf("missing asset symbol for asset[%d]", i) } + canonicalSymbol := strings.ToLower(asset.Symbol) + if prev, dup := seenSymbols[canonicalSymbol]; dup { + return fmt.Errorf("duplicate asset symbol '%s' (asset[%d]) conflicts with '%s' (asset[%d]) on a case-insensitive basis", + asset.Symbol, i, cfg.Assets[prev].Symbol, prev) + } + seenSymbols[canonicalSymbol] = i if asset.Name == "" { cfg.Assets[i].Name = asset.Symbol } diff --git a/nitronode/store/memory/asset_config_test.go b/nitronode/store/memory/asset_config_test.go index e15ce63d0..38ee2a414 100644 --- a/nitronode/store/memory/asset_config_test.go +++ b/nitronode/store/memory/asset_config_test.go @@ -122,6 +122,64 @@ func TestAssetsConfig_verifyVariables(t *testing.T) { require.NoError(t, err) }) + // Test case-insensitive duplicate asset symbol (should fail) + t.Run("case-insensitive duplicate asset symbol", func(t *testing.T) { + cfg := AssetsConfig{ + Assets: []AssetConfig{ + { + Name: "Tether USD", + Symbol: "usdt", + SuggestedBlockchainID: 1, + }, + { + Name: "Tether USD", + Symbol: "USDT", + SuggestedBlockchainID: 137, + }, + }, + } + err := verifyAssetsConfig(&cfg) + require.Error(t, err) + assert.Equal(t, "duplicate asset symbol 'USDT' (asset[1]) conflicts with 'usdt' (asset[0]) on a case-insensitive basis", err.Error()) + }) + + // Test exact duplicate asset symbol (should fail) + t.Run("exact duplicate asset symbol", func(t *testing.T) { + cfg := AssetsConfig{ + Assets: []AssetConfig{ + {Symbol: "usdt", SuggestedBlockchainID: 1}, + {Symbol: "usdt", SuggestedBlockchainID: 137}, + }, + } + err := verifyAssetsConfig(&cfg) + require.Error(t, err) + assert.Equal(t, "duplicate asset symbol 'usdt' (asset[1]) conflicts with 'usdt' (asset[0]) on a case-insensitive basis", err.Error()) + }) + + // Test distinct asset symbols (should pass) + t.Run("distinct asset symbols", func(t *testing.T) { + cfg := AssetsConfig{ + Assets: []AssetConfig{ + {Symbol: "usdt", SuggestedBlockchainID: 1}, + {Symbol: "usdc", SuggestedBlockchainID: 1}, + }, + } + err := verifyAssetsConfig(&cfg) + require.NoError(t, err) + }) + + // Test duplicate symbol where one asset is disabled (should pass — disabled assets are skipped) + t.Run("duplicate symbol with disabled asset", func(t *testing.T) { + cfg := AssetsConfig{ + Assets: []AssetConfig{ + {Symbol: "usdt", SuggestedBlockchainID: 1}, + {Symbol: "USDT", Disabled: true}, + }, + } + err := verifyAssetsConfig(&cfg) + require.NoError(t, err) + }) + // Test custom symbol for token (inherits from asset when empty) t.Run("custom symbol for token", func(t *testing.T) { cfg := AssetsConfig{ diff --git a/nitronode/store/memory/blockchain_config.go b/nitronode/store/memory/blockchain_config.go index 27e89f688..c968c9a9e 100644 --- a/nitronode/store/memory/blockchain_config.go +++ b/nitronode/store/memory/blockchain_config.go @@ -43,8 +43,9 @@ type BlockchainConfig struct { ChannelHubAddress string `yaml:"channel_hub_address"` // ChannelHubSigValidators maps validator IDs to the addresses of signature validators for the ChannelHub contract on this blockchain ChannelHubSigValidators map[uint8]string `yaml:"channel_hub_sig_validators"` - // LockingContractAddress is the address of the locking contract on this blockchain - LockingContractAddress string `yaml:"locking_contract_address"` + // ConfirmationDelaySecs is the number of seconds to wait before processing an event. + // Set to 0 to process events immediately (disables the confirmation gate). + ConfirmationDelaySecs uint32 `yaml:"confirmation_delay_secs"` } // LoadEnabledBlockchains loads and validates blockchain configurations from a YAML file. @@ -86,18 +87,14 @@ func verifyBlockchainsConfig(cfg *BlockchainsConfig) error { return fmt.Errorf("invalid blockchain name '%s', should match snake_case format", bc.Name) } - if bc.ChannelHubAddress == "" && bc.LockingContractAddress == "" { - return fmt.Errorf("blockchain '%s' must specify at least one of channel_hub_address or locking_contract_address", bc.Name) + if bc.ChannelHubAddress == "" { + return fmt.Errorf("blockchain '%s' must specify channel_hub_address", bc.Name) } - if bc.ChannelHubAddress != "" && !contractAddressRegex.MatchString(bc.ChannelHubAddress) { + if !contractAddressRegex.MatchString(bc.ChannelHubAddress) { return fmt.Errorf("invalid channel hub address '%s' for blockchain '%s'", bc.ChannelHubAddress, bc.Name) } - if bc.LockingContractAddress != "" && !contractAddressRegex.MatchString(bc.LockingContractAddress) { - return fmt.Errorf("invalid locking contract address '%s' for blockchain '%s'", bc.LockingContractAddress, bc.Name) - } - if bc.BlockStep == 0 { cfg.Blockchains[i].BlockStep = defaultBlockStep } diff --git a/nitronode/store/memory/blockchain_config_test.go b/nitronode/store/memory/blockchain_config_test.go index 60dcb579b..5c518d620 100644 --- a/nitronode/store/memory/blockchain_config_test.go +++ b/nitronode/store/memory/blockchain_config_test.go @@ -26,9 +26,10 @@ func TestBlockchainConfig_verifyVariables(t *testing.T) { ChannelHubSigValidators: map[uint8]string{1: "0x3333333333333333333333333333333333333333"}, }, { - ID: 11155111, - Name: "ethereum_sepolia", - LockingContractAddress: "0x2222222222222222222222222222222222222222", + ID: 11155111, + Name: "ethereum_sepolia", + ChannelHubAddress: "0x2222222222222222222222222222222222222222", + ChannelHubSigValidators: map[uint8]string{1: "0x3333333333333333333333333333333333333333"}, }, }, }, @@ -46,7 +47,7 @@ func TestBlockchainConfig_verifyVariables(t *testing.T) { sepoliaCfg := blockchains[1] assert.Equal(t, "ethereum_sepolia", sepoliaCfg.Name) assert.Equal(t, uint64(11155111), sepoliaCfg.ID) - assert.Equal(t, "0x2222222222222222222222222222222222222222", sepoliaCfg.LockingContractAddress) + assert.Equal(t, "0x2222222222222222222222222222222222222222", sepoliaCfg.ChannelHubAddress) assert.False(t, sepoliaCfg.Disabled) assert.Equal(t, defaultBlockStep, sepoliaCfg.BlockStep) }, diff --git a/nitronode/store/memory/memory_store.go b/nitronode/store/memory/memory_store.go index 75dad40f1..055e4acb3 100644 --- a/nitronode/store/memory/memory_store.go +++ b/nitronode/store/memory/memory_store.go @@ -33,11 +33,11 @@ func NewMemoryStoreV1(assetsConfig AssetsConfig, blockchainsConfig map[uint64]Bl } blockchains = append(blockchains, core.Blockchain{ - ID: bc.ID, - Name: bc.Name, - ChannelHubAddress: bc.ChannelHubAddress, - LockingContractAddress: bc.LockingContractAddress, - BlockStep: bc.BlockStep, + ID: bc.ID, + Name: bc.Name, + ChannelHubAddress: bc.ChannelHubAddress, + BlockStep: bc.BlockStep, + ConfirmationDelaySecs: bc.ConfirmationDelaySecs, }) } slices.SortFunc(blockchains, func(a, b core.Blockchain) int { @@ -239,11 +239,11 @@ func (ms *MemoryStoreV1) GetTokenDecimals(blockchainID uint64, tokenAddress stri decimalsOnChain, ok := ms.tokenDecimals[blockchainID] if !ok { - return 0, fmt.Errorf("blockchain with ID '%d' is not supported", blockchainID) + return 0, fmt.Errorf("blockchain with ID '%d' has no configured tokens: %w", blockchainID, core.ErrTokenNotSupported) } decimals, ok := decimalsOnChain[tokenAddress] if !ok { - return 0, fmt.Errorf("token %s is not supported on blockchain with ID '%d'", tokenAddress, blockchainID) + return 0, fmt.Errorf("token %s is not supported on blockchain with ID '%d': %w", tokenAddress, blockchainID, core.ErrTokenNotSupported) } return decimals, nil } @@ -254,11 +254,11 @@ func (ms *MemoryStoreV1) GetTokenAsset(blockchainID uint64, tokenAddress string) assetsOnChain, ok := ms.tokenAssets[blockchainID] if !ok { - return "", fmt.Errorf("blockchain with ID '%d' is not supported", blockchainID) + return "", fmt.Errorf("blockchain with ID '%d' has no configured tokens: %w", blockchainID, core.ErrTokenNotSupported) } asset, ok := assetsOnChain[tokenAddress] if !ok { - return "", fmt.Errorf("token %s is not supported on blockchain with ID '%d'", tokenAddress, blockchainID) + return "", fmt.Errorf("token %s is not supported on blockchain with ID '%d': %w", tokenAddress, blockchainID, core.ErrTokenNotSupported) } return asset.Symbol, nil } diff --git a/nitronode/store/memory/memory_store_test.go b/nitronode/store/memory/memory_store_test.go new file mode 100644 index 000000000..2086c4022 --- /dev/null +++ b/nitronode/store/memory/memory_store_test.go @@ -0,0 +1,76 @@ +package memory + +import ( + "errors" + "testing" + + "github.com/layer-3/nitrolite/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + testBlockchainID uint64 = 137 + testEnabledNoTokensID uint64 = 480 // ChannelHub-enabled chain with no configured tokens + testTokenAddress = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + testUnknownTokenAddress = "0x000000000000000000000000000000000000dead" +) + +func newTestStore() *MemoryStoreV1 { + return &MemoryStoreV1{ + tokenAssets: map[uint64]map[string]core.Asset{ + testBlockchainID: {testTokenAddress: {Symbol: "usdc"}}, + }, + tokenDecimals: map[uint64]map[string]uint8{ + testBlockchainID: {testTokenAddress: 6}, + }, + } +} + +func TestMemoryStoreV1_GetTokenAsset(t *testing.T) { + ms := newTestStore() + + t.Run("configured token returns asset", func(t *testing.T) { + asset, err := ms.GetTokenAsset(testBlockchainID, testTokenAddress) + require.NoError(t, err) + assert.Equal(t, "usdc", asset) + }) + + t.Run("unknown token on configured chain wraps ErrTokenNotSupported", func(t *testing.T) { + _, err := ms.GetTokenAsset(testBlockchainID, testUnknownTokenAddress) + require.Error(t, err) + assert.True(t, errors.Is(err, core.ErrTokenNotSupported)) + }) + + // MF3-H03 regression: a ChannelHub-enabled chain with no configured tokens + // must also surface ErrTokenNotSupported so the reactor skips the event + // instead of stopping the listener. + t.Run("chain with no configured tokens wraps ErrTokenNotSupported", func(t *testing.T) { + _, err := ms.GetTokenAsset(testEnabledNoTokensID, testTokenAddress) + require.Error(t, err) + assert.True(t, errors.Is(err, core.ErrTokenNotSupported)) + }) +} + +func TestMemoryStoreV1_GetTokenDecimals(t *testing.T) { + ms := newTestStore() + + t.Run("configured token returns decimals", func(t *testing.T) { + decimals, err := ms.GetTokenDecimals(testBlockchainID, testTokenAddress) + require.NoError(t, err) + assert.Equal(t, uint8(6), decimals) + }) + + t.Run("unknown token on configured chain wraps ErrTokenNotSupported", func(t *testing.T) { + _, err := ms.GetTokenDecimals(testBlockchainID, testUnknownTokenAddress) + require.Error(t, err) + assert.True(t, errors.Is(err, core.ErrTokenNotSupported)) + }) + + // MF3-H03 regression: see GetTokenAsset counterpart above. + t.Run("chain with no configured tokens wraps ErrTokenNotSupported", func(t *testing.T) { + _, err := ms.GetTokenDecimals(testEnabledNoTokensID, testTokenAddress) + require.Error(t, err) + assert.True(t, errors.Is(err, core.ErrTokenNotSupported)) + }) +} diff --git a/pkg/app/app_session_v1.go b/pkg/app/app_session_v1.go index b65c578d4..2f39c8ebf 100644 --- a/pkg/app/app_session_v1.go +++ b/pkg/app/app_session_v1.go @@ -9,7 +9,6 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" - "github.com/layer-3/nitrolite/pkg/core" "github.com/shopspring/decimal" ) @@ -20,7 +19,6 @@ const ( AppStateUpdateIntentDeposit AppStateUpdateIntentWithdraw AppStateUpdateIntentClose - AppStateUpdateIntentRebalance ) // AllAppStateUpdateIntents enumerates every defined intent. Kept beside the @@ -31,7 +29,6 @@ var AllAppStateUpdateIntents = []AppStateUpdateIntent{ AppStateUpdateIntentDeposit, AppStateUpdateIntentWithdraw, AppStateUpdateIntentClose, - AppStateUpdateIntentRebalance, } func (intent AppStateUpdateIntent) String() string { @@ -44,26 +41,11 @@ func (intent AppStateUpdateIntent) String() string { return "withdraw" case AppStateUpdateIntentClose: return "close" - case AppStateUpdateIntentRebalance: - return "rebalance" default: return "unknown" } } -func (intent AppStateUpdateIntent) GatedAction() core.GatedAction { - switch intent { - case AppStateUpdateIntentOperate: - return core.GatedActionAppSessionOperation - case AppStateUpdateIntentDeposit: - return core.GatedActionAppSessionDeposit - case AppStateUpdateIntentWithdraw: - return core.GatedActionAppSessionWithdrawal - default: - return "" - } -} - type AppSessionStatus uint8 const ( @@ -152,12 +134,6 @@ type AppDefinitionV1 struct { Nonce uint64 } -// AppSessionVersionV1 represents a session ID and version pair for rebalancing operations. -type AppSessionVersionV1 struct { - SessionID string - Version uint64 -} - // AppAllocationV1 represents the allocation of assets to a participant in an app session. type AppAllocationV1 struct { Participant string @@ -370,69 +346,3 @@ func GenerateAppSessionIDV1(definition AppDefinitionV1) (string, error) { // Return the Keccak256 hash as hex string return crypto.Keccak256Hash(packed).Hex(), nil } - -// GenerateRebalanceBatchIDV1 creates a deterministic batch ID from session versions using ABI encoding. -// The batch ID is generated by hashing the list of (sessionID, version) pairs. -func GenerateRebalanceBatchIDV1(sessionVersions []AppSessionVersionV1) (string, error) { - // Define the session version tuple type - sessionVersionType, err := abi.NewType("tuple", "", []abi.ArgumentMarshaling{ - {Name: "sessionID", Type: "bytes32"}, - {Name: "version", Type: "uint64"}, - }) - if err != nil { - return "", fmt.Errorf("failed to create session version type: %w", err) - } - - // Define the arguments structure - args := abi.Arguments{ - {Type: abi.Type{T: abi.SliceTy, Elem: &sessionVersionType}}, // session versions array - } - - // Convert session versions to the format expected by ABI packing - sessionVersionsArray := make([]struct { - SessionID common.Hash - Version uint64 - }, len(sessionVersions)) - - for i, sv := range sessionVersions { - sessionVersionsArray[i] = struct { - SessionID common.Hash - Version uint64 - }{ - SessionID: common.HexToHash(sv.SessionID), - Version: sv.Version, - } - } - - // Pack the data using ABI encoding - packed, err := args.Pack(sessionVersionsArray) - if err != nil { - return "", fmt.Errorf("failed to pack session versions: %w", err) - } - - // Return the Keccak256 hash as hex string - return crypto.Keccak256Hash(packed).Hex(), nil -} - -// GenerateRebalanceTransactionIDV1 creates a deterministic transaction ID for a rebalance transaction using ABI encoding. -func GenerateRebalanceTransactionIDV1(batchID, sessionID, asset string) (string, error) { - // Define the arguments structure - args := abi.Arguments{ - {Type: abi.Type{T: abi.FixedBytesTy, Size: 32}}, // batchID (bytes32) - {Type: abi.Type{T: abi.FixedBytesTy, Size: 32}}, // sessionID (bytes32) - {Type: abi.Type{T: abi.StringTy}}, // asset (string) - } - - // Pack the data using ABI encoding - packed, err := args.Pack( - common.HexToHash(batchID), - common.HexToHash(sessionID), - asset, - ) - if err != nil { - return "", fmt.Errorf("failed to pack rebalance transaction data: %w", err) - } - - // Return the Keccak256 hash as hex string - return crypto.Keccak256Hash(packed).Hex(), nil -} diff --git a/pkg/app/app_session_v1_test.go b/pkg/app/app_session_v1_test.go index a5aca19d1..df880bc28 100644 --- a/pkg/app/app_session_v1_test.go +++ b/pkg/app/app_session_v1_test.go @@ -72,51 +72,12 @@ func TestGenerateAppSessionIDV1(t *testing.T) { assert.NotEqual(t, id1, id3) } -func TestGenerateRebalanceBatchIDV1(t *testing.T) { - t.Parallel() - versions := []AppSessionVersionV1{ - {SessionID: "0x1111111111111111111111111111111111111111111111111111111111111111", Version: 1}, - {SessionID: "0x2222222222222222222222222222222222222222222222222222222222222222", Version: 2}, - } - - id1, err := GenerateRebalanceBatchIDV1(versions) - require.NoError(t, err) - assert.NotEmpty(t, id1) - - // Deterministic - id2, err := GenerateRebalanceBatchIDV1(versions) - require.NoError(t, err) - assert.Equal(t, id1, id2) - - // Different version should produce a different ID - versions[0].Version = 99 - id3, err := GenerateRebalanceBatchIDV1(versions) - require.NoError(t, err) - assert.NotEqual(t, id1, id3) -} - -func TestGenerateRebalanceTransactionIDV1(t *testing.T) { - t.Parallel() - batchID := "0x1111111111111111111111111111111111111111111111111111111111111111" - sessionID := "0x2222222222222222222222222222222222222222222222222222222222222222" - asset := "USDC" - - id1, err := GenerateRebalanceTransactionIDV1(batchID, sessionID, asset) - require.NoError(t, err) - assert.NotEmpty(t, id1) - - id2, err := GenerateRebalanceTransactionIDV1(batchID, sessionID, asset) - require.NoError(t, err) - assert.Equal(t, id1, id2) -} - func TestEnums(t *testing.T) { t.Parallel() assert.Equal(t, "operate", AppStateUpdateIntentOperate.String()) assert.Equal(t, "deposit", AppStateUpdateIntentDeposit.String()) assert.Equal(t, "withdraw", AppStateUpdateIntentWithdraw.String()) assert.Equal(t, "close", AppStateUpdateIntentClose.String()) - assert.Equal(t, "rebalance", AppStateUpdateIntentRebalance.String()) assert.Equal(t, "unknown", AppStateUpdateIntent(255).String()) assert.Equal(t, "", AppSessionStatusVoid.String()) diff --git a/pkg/app/app_v1.go b/pkg/app/app_v1.go index 55324bf7b..285033026 100644 --- a/pkg/app/app_v1.go +++ b/pkg/app/app_v1.go @@ -1,18 +1,9 @@ package app import ( - "fmt" "regexp" - "time" - - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/crypto" - "github.com/layer-3/nitrolite/pkg/core" ) -var AppIDV1Regex = regexp.MustCompile(`^[a-z0-9][-a-z0-9]{0,65}$`) - // ApplicationIDRegex bounds the advisory application identifier to lowercase // letters, digits, dashes and underscores, 1..66 chars — matching the DB // column width (VARCHAR(66), see @@ -24,51 +15,3 @@ var ApplicationIDRegex = regexp.MustCompile(`^[a-z0-9_-]{1,66}$`) func IsValidApplicationID(id string) bool { return ApplicationIDRegex.MatchString(id) } - -// AppV1 represents an application registry entry. -type AppV1 struct { - ID string - OwnerWallet string - Metadata string - Version uint64 - CreationApprovalNotRequired bool -} - -// AppInfoV1 represents full application info including timestamps. -type AppInfoV1 struct { - App AppV1 - CreatedAt time.Time - UpdatedAt time.Time -} - -// PackAppV1 packs the AppV1 for signing using ABI encoding. -func PackAppV1(app AppV1) ([]byte, error) { - var err error - app.OwnerWallet, err = core.NormalizeHexAddress(app.OwnerWallet) - if err != nil { - return nil, fmt.Errorf("invalid owner wallet address: %v", err) - } - - args := abi.Arguments{ - {Type: abi.Type{T: abi.StringTy}}, // id - {Type: abi.Type{T: abi.AddressTy}}, // ownerWallet - {Type: abi.Type{T: abi.FixedBytesTy, Size: 32}}, // metadata (bytes32) - {Type: abi.Type{T: abi.UintTy, Size: 64}}, // version - {Type: abi.Type{T: abi.BoolTy}}, // creationApprovalNotRequired - } - - appMetadataHash := crypto.Keccak256Hash([]byte(app.Metadata)) - - packed, err := args.Pack( - app.ID, - common.HexToAddress(app.OwnerWallet), - appMetadataHash, - app.Version, - app.CreationApprovalNotRequired, - ) - if err != nil { - return nil, fmt.Errorf("failed to pack app: %w", err) - } - - return crypto.Keccak256(packed), nil -} diff --git a/pkg/app/session_key_v1.go b/pkg/app/session_key_v1.go index f377ed71c..f3268fdf1 100644 --- a/pkg/app/session_key_v1.go +++ b/pkg/app/session_key_v1.go @@ -54,17 +54,14 @@ 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") - } - +// ValidateAppSessionKeyStateUserSigV1 verifies only UserSig over the registration payload: +// UserSig must recover to state.UserAddress (wallet authorizes the change). This is the +// revocation path (submitted expires_at <= now): the session-key holder's SessionKeySig is +// intentionally not required so a lost, unavailable, or malicious delegate cannot veto the +// wallet's revocation of its own delegation. The packed payload binds user_address, +// session_key, version and expires_at, so the signature authorizes exactly this revocation and +// cannot be replayed for another key, wallet, or version. +func ValidateAppSessionKeyStateUserSigV1(state AppSessionKeyStateV1) error { packed, err := PackAppSessionKeyStateV1(state) if err != nil { return fmt.Errorf("failed to pack session key state: %w", err) @@ -87,6 +84,35 @@ func ValidateAppSessionKeyStateV1(state AppSessionKeyStateV1) error { return fmt.Errorf("user_sig does not match user_address") } + return 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. Used for activation, extension, and rotation (submitted +// expires_at > now); revocation uses ValidateAppSessionKeyStateUserSigV1. +func ValidateAppSessionKeyStateV1(state AppSessionKeyStateV1) error { + if state.SessionKeySig == "" { + return fmt.Errorf("session_key_sig is required") + } + + if err := ValidateAppSessionKeyStateUserSigV1(state); err != nil { + return err + } + + 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) + } + sessionKeySigBytes, err := hexutil.Decode(state.SessionKeySig) if err != nil { return fmt.Errorf("failed to decode session_key_sig: %w", err) diff --git a/pkg/app/session_key_v1_test.go b/pkg/app/session_key_v1_test.go index ef3602890..deead3456 100644 --- a/pkg/app/session_key_v1_test.go +++ b/pkg/app/session_key_v1_test.go @@ -130,6 +130,46 @@ func TestValidateAppSessionKeyStateV1(t *testing.T) { assert.Error(t, ValidateAppSessionKeyStateV1(stateCrossKey)) } +func TestValidateAppSessionKeyStateUserSigV1(t *testing.T) { + t.Parallel() + userSigner, userAddress := createTestSigner(t) + _, sessionKeyAddr := createTestSigner(t) + + baseState := AppSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKeyAddr, + Version: 1, + ExpiresAt: time.Now().Add(-time.Hour), // revocation + } + + packed, err := PackAppSessionKeyStateV1(baseState) + require.NoError(t, err) + userSig, err := userSigner.Sign(packed) + require.NoError(t, err) + + state := baseState + state.UserSig = hexutil.Encode(userSig) + + // Valid user_sig alone passes — no session_key_sig required on the revocation path. + require.NoError(t, ValidateAppSessionKeyStateUserSigV1(state)) + + // A missing session_key_sig is irrelevant here (the field stays empty above). + // user_sig signed by the wrong wallet is rejected. + wrongSigner, _ := createTestSigner(t) + wrongUserSig, err := wrongSigner.Sign(packed) + require.NoError(t, err) + stateWrongUser := state + stateWrongUser.UserSig = hexutil.Encode(wrongUserSig) + err = ValidateAppSessionKeyStateUserSigV1(stateWrongUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "user_sig does not match user_address") + + // Tampered version diverges the packed bytes, so recovery no longer matches. + stateTampered := state + stateTampered.Version = 2 + assert.Error(t, ValidateAppSessionKeyStateUserSigV1(stateTampered)) +} + func TestPackAppSessionKeyStateV1(t *testing.T) { t.Parallel() expiresAt := time.Unix(1739812234, 0) diff --git a/pkg/blockchain/evm/app_registry_abi.go b/pkg/blockchain/evm/app_registry_abi.go deleted file mode 100644 index 686285cf7..000000000 --- a/pkg/blockchain/evm/app_registry_abi.go +++ /dev/null @@ -1,2154 +0,0 @@ -// Code generated - DO NOT EDIT. -// This file is a generated binding and any manual changes will be lost. - -package evm - -import ( - "errors" - "math/big" - "strings" - - ethereum "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/event" -) - -// Reference imports to suppress errors if they are not otherwise used. -var ( - _ = errors.New - _ = big.NewInt - _ = strings.NewReader - _ = ethereum.NotFound - _ = bind.Bind - _ = common.Big1 - _ = types.BloomLookup - _ = event.NewSubscription -) - -// AppRegistryMetaData contains all meta data concerning the AppRegistry contract. -var AppRegistryMetaData = &bind.MetaData{ - ABI: "[{\"type\":\"constructor\",\"inputs\":[{\"name\":\"asset_\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"unlockPeriod_\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"admin_\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"ADJUDICATOR_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"ASSET\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"DEFAULT_ADMIN_ROLE\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"UNLOCK_PERIOD\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"asset\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"address\",\"internalType\":\"address\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"balanceOf\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"getRoleAdmin\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"grantRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"hasRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"lastSlashTimestamp\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"lock\",\"inputs\":[{\"name\":\"target\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"lockStateOf\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint8\",\"internalType\":\"enumILock.LockState\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"relock\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"renounceRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"callerConfirmation\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"revokeRole\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"setSlashCooldown\",\"inputs\":[{\"name\":\"newCooldown\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"slash\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"internalType\":\"uint256\"},{\"name\":\"recipient\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"decision\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"slashCooldown\",\"inputs\":[],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"supportsInterface\",\"inputs\":[{\"name\":\"interfaceId\",\"type\":\"bytes4\",\"internalType\":\"bytes4\"}],\"outputs\":[{\"name\":\"\",\"type\":\"bool\",\"internalType\":\"bool\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"unlock\",\"inputs\":[],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"function\",\"name\":\"unlockTimestampOf\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[{\"name\":\"\",\"type\":\"uint256\",\"internalType\":\"uint256\"}],\"stateMutability\":\"view\"},{\"type\":\"function\",\"name\":\"withdraw\",\"inputs\":[{\"name\":\"destination\",\"type\":\"address\",\"internalType\":\"address\"}],\"outputs\":[],\"stateMutability\":\"nonpayable\"},{\"type\":\"event\",\"name\":\"Locked\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"deposited\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"newBalance\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Relocked\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"balance\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleAdminChanged\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"previousAdminRole\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"newAdminRole\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleGranted\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"RoleRevoked\",\"inputs\":[{\"name\":\"role\",\"type\":\"bytes32\",\"indexed\":true,\"internalType\":\"bytes32\"},{\"name\":\"account\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"sender\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"SlashCooldownUpdated\",\"inputs\":[{\"name\":\"oldCooldown\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"newCooldown\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Slashed\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"amount\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"recipient\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"decision\",\"type\":\"bytes\",\"indexed\":false,\"internalType\":\"bytes\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"UnlockInitiated\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"balance\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"},{\"name\":\"availableAt\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"event\",\"name\":\"Withdrawn\",\"inputs\":[{\"name\":\"user\",\"type\":\"address\",\"indexed\":true,\"internalType\":\"address\"},{\"name\":\"balance\",\"type\":\"uint256\",\"indexed\":false,\"internalType\":\"uint256\"}],\"anonymous\":false},{\"type\":\"error\",\"name\":\"AccessControlBadConfirmation\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"AccessControlUnauthorizedAccount\",\"inputs\":[{\"name\":\"account\",\"type\":\"address\",\"internalType\":\"address\"},{\"name\":\"neededRole\",\"type\":\"bytes32\",\"internalType\":\"bytes32\"}]},{\"type\":\"error\",\"name\":\"AlreadyUnlocking\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InsufficientBalance\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAddress\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidAmount\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"InvalidPeriod\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotLocked\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"NotUnlocking\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"RecipientIsAdjudicator\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"ReentrancyGuardReentrantCall\",\"inputs\":[]},{\"type\":\"error\",\"name\":\"SafeERC20FailedOperation\",\"inputs\":[{\"name\":\"token\",\"type\":\"address\",\"internalType\":\"address\"}]},{\"type\":\"error\",\"name\":\"SlashCooldownActive\",\"inputs\":[{\"name\":\"availableAt\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]},{\"type\":\"error\",\"name\":\"UnlockPeriodNotElapsed\",\"inputs\":[{\"name\":\"availableAt\",\"type\":\"uint256\",\"internalType\":\"uint256\"}]}]", - Bin: "0x60c03461010d57601f61176038819003918201601f19168301916001600160401b038311848410176101115780849260609460405283398101031261010d5761004781610125565b90610059604060208301519201610125565b60017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055916001600160a01b038116156100ef5781156100fe5760805260a0526001600160a01b038116156100ef576100b190610139565b5060405161157d90816101c3823960805181818161060d0152818161084c01528181610edd01526110e1015260a05181818161030b0152610aca0152f35b63e6c4247b60e01b5f5260045ffd5b6302e8f35960e31b5f5260045ffd5b5f80fd5b634e487b7160e01b5f52604160045260245ffd5b51906001600160a01b038216820361010d57565b6001600160a01b0381165f9081525f5160206117405f395f51905f52602052604090205460ff166101bd576001600160a01b03165f8181525f5160206117405f395f51905f5260205260408120805460ff191660011790553391907f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d8180a4600190565b505f9056fe60806040526004361015610011575f80fd5b5f3560e01c8063010379b214610d3257806301ffc9a714610c7357806302f70fe614610c10578063248a9ca314610bbf578063256f8f0914610aed578063259a28cf14610a95578063282d3fdf146107ad5780632f2ff15d1461075157806336568abe146106c957806338d52e0f146106c45780634800d97f146106c457806351cff8d91461058a5780635a6ca4ae1461054f57806370a08231146104ed57806391d1485414610478578063a217fddf14610440578063a43e9cf4146103c1578063a69df4b5146102b5578063c53b573d14610205578063c77f5062146101ca578063d547741f146101675763f538f7dd1461010b575f80fd5b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101635760206040517f5ad6c1ce52091d5f5a49dff6df7d4bf577735e77d8c13251e1ea9c82ce8c380d8152f35b5f80fd5b346101635760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576101c86004356101a4611074565b906101c36101be825f526002602052600160405f20015490565b611201565b61147d565b005b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576020600454604051908152f35b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261016357335f52600160205260405f20541561028d57335f525f60205260405f2054335f5260016020525f60408120556040519081527fe2d155d9d74decc198a9a9b4f5bddb24ee0842ee745c9ce57e3573b971e50d9d60203392a2005b7ff61050dc000000000000000000000000000000000000000000000000000000005f5260045ffd5b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261016357335f525f60205260405f2054801561039957335f52600160205260405f2054610371576103307f000000000000000000000000000000000000000000000000000000000000000042611105565b335f5260016020528060405f205560405191825260208201527fd73fc4aafa5101b91e88b8c2fa75d8d6db73466773addb11c079d063c1e63c0c60403392a2005b7fe47b668b000000000000000000000000000000000000000000000000000000005f5260045ffd5b7f1834e265000000000000000000000000000000000000000000000000000000005f5260045ffd5b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576104006103fb611051565b6111ba565b6040516003821015610413576020918152f35b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602160045260245ffd5b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101635760206040515f8152f35b346101635760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576104af611074565b6004355f52600260205273ffffffffffffffffffffffffffffffffffffffff60405f2091165f52602052602060ff60405f2054166040519015158152f35b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101635773ffffffffffffffffffffffffffffffffffffffff610539611051565b165f525f602052602060405f2054604051908152f35b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576020600354604051908152f35b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576105c1611051565b6105c9611268565b335f52600160205260405f2054801561028d578042106106995750335f908152602081815260408083208054908490556001909252822091909155906106479082907f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff166112df565b6040519081527f7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d560203392a260017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055005b7fc33c45d1000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b611097565b346101635760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261016357610700611074565b3373ffffffffffffffffffffffffffffffffffffffff821603610729576101c89060043561147d565b7f6697b232000000000000000000000000000000000000000000000000000000005f5260045ffd5b346101635760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576101c860043561078e611074565b906107a86101be825f526002602052600160405f20015490565b6113a9565b346101635760407ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576107e4611051565b602435906107f0611268565b8115610a6d5773ffffffffffffffffffffffffffffffffffffffff1690815f52600160205260405f2054610371576040517f70a082310000000000000000000000000000000000000000000000000000000081523060048201527f000000000000000000000000000000000000000000000000000000000000000073ffffffffffffffffffffffffffffffffffffffff16602082602481845afa9182156109e4575f92610a39575b50604051927f23b872dd000000000000000000000000000000000000000000000000000000005f52336004523060245260445260205f60648180855af160015f5114811615610a1a575b836040525f606052156109ef57826024816020937f70a082310000000000000000000000000000000000000000000000000000000082523060048301525afa9182156109e4575f926109ae575b5061095d6040917fd4665e3049283582ba6f9eba07a5b3e12dab49e02da99e8927a47af5d134bea59361113f565b835f525f60205261097181835f2054611105565b845f525f60205280835f205582519182526020820152a260017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055005b91506020823d6020116109dc575b816109c96020938361114c565b810103126101635790519061095d61092f565b3d91506109bc565b6040513d5f823e3d90fd5b7f5274afe7000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b6001811516610a3057813b15153d1516166108e2565b833d5f823e3d90fd5b9091506020813d602011610a65575b81610a556020938361114c565b8101031261016357519084610898565b3d9150610a48565b7f2c5211c6000000000000000000000000000000000000000000000000000000005f5260045ffd5b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101635760206040517f00000000000000000000000000000000000000000000000000000000000000008152f35b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261016357335f9081527fac33ff75c19e70fe83507db0d683fd3465c996598dc972688b7ace676c89077b60205260409020546004359060ff1615610b8f5760407f21dd401bad58f48f88b208aad5157305ac7e8ec5db030042aaec08ebd7f50e4c91600354908060035582519182526020820152a1005b7fe2517d3f000000000000000000000000000000000000000000000000000000005f52336004525f60245260445ffd5b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576020610c086004355f526002602052600160405f20015490565b604051908152f35b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc3601126101635773ffffffffffffffffffffffffffffffffffffffff610c5c611051565b165f526001602052602060405f2054604051908152f35b346101635760207ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc360112610163576004357fffffffff00000000000000000000000000000000000000000000000000000000811680910361016357807f7965db0b0000000000000000000000000000000000000000000000000000000060209214908115610d08575b506040519015158152f35b7f01ffc9a70000000000000000000000000000000000000000000000000000000091501482610cfd565b346101635760807ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261016357610d69611051565b60243560443573ffffffffffffffffffffffffffffffffffffffff811692838203610163576064359067ffffffffffffffff821161016357366023830112156101635781600401359067ffffffffffffffff821161016357366024838501011161016357335f9081527f7f9272e0d81bdb9b1e0d5b8b11f4dfdbe7d5ebf3410318d28f3e6024ce33d903602052604090205460ff161561100157610e0b611268565b60045460035480151580610ff8575b610fb7575b5050338614610f8f5773ffffffffffffffffffffffffffffffffffffffff1693845f525f60205260405f2054938415610f6757848211610f6757601f606093610f02847fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe094610eaf827ff204f6a8b6d1439051d5fbd742389d0f778d18d21016e81c8ad3d4558266454c9b61113f565b8b5f525f6020528060405f205515610f54575b4260045573ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000166112df565b80602460405197889687526040602088015282604088015201868601375f85828601015201168101030190a360017f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055005b8a5f5260016020525f6040812055610ec2565b7ff4d678b8000000000000000000000000000000000000000000000000000000005f5260045ffd5b7f8d3fe42e000000000000000000000000000000000000000000000000000000005f5260045ffd5b610fc091611105565b804210610fcd5780610e1f565b7f773faedc000000000000000000000000000000000000000000000000000000005f5260045260245ffd5b50811515610e1a565b7fe2517d3f000000000000000000000000000000000000000000000000000000005f52336004527f5ad6c1ce52091d5f5a49dff6df7d4bf577735e77d8c13251e1ea9c82ce8c380d60245260445ffd5b6004359073ffffffffffffffffffffffffffffffffffffffff8216820361016357565b6024359073ffffffffffffffffffffffffffffffffffffffff8216820361016357565b34610163575f7ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc36011261016357602060405173ffffffffffffffffffffffffffffffffffffffff7f0000000000000000000000000000000000000000000000000000000000000000168152f35b9190820180921161111257565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b9190820391821161111257565b90601f7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0910116810190811067ffffffffffffffff82111761118d57604052565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b73ffffffffffffffffffffffffffffffffffffffff16805f525f60205260405f2054156111fc575f52600160205260405f2054156111f757600290565b600190565b505f90565b805f52600260205260405f2073ffffffffffffffffffffffffffffffffffffffff33165f5260205260ff60405f205416156112395750565b7fe2517d3f000000000000000000000000000000000000000000000000000000005f523360045260245260445ffd5b60027f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0054146112b75760027f9b779b17422d0df92223018b32b4d1fa46e071723d6817e2486d003becc55f0055565b7f3ee5aeb5000000000000000000000000000000000000000000000000000000005f5260045ffd5b9173ffffffffffffffffffffffffffffffffffffffff604051927fa9059cbb000000000000000000000000000000000000000000000000000000005f521660045260245260205f60448180865af19060015f5114821615611388575b604052156113465750565b73ffffffffffffffffffffffffffffffffffffffff907f5274afe7000000000000000000000000000000000000000000000000000000005f521660045260245ffd5b9060018115166113a057823b15153d1516169061133b565b503d5f823e3d90fd5b805f52600260205260405f2073ffffffffffffffffffffffffffffffffffffffff83165f5260205260ff60405f205416155f1461147757805f52600260205260405f2073ffffffffffffffffffffffffffffffffffffffff83165f5260205260405f2060017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0082541617905573ffffffffffffffffffffffffffffffffffffffff339216907f2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d5f80a4600190565b50505f90565b805f52600260205260405f2073ffffffffffffffffffffffffffffffffffffffff83165f5260205260ff60405f2054165f1461147757805f52600260205260405f2073ffffffffffffffffffffffffffffffffffffffff83165f5260205260405f207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00815416905573ffffffffffffffffffffffffffffffffffffffff339216907ff6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b5f80a460019056fea2646970667358221220d76073a01636f84db6fa5e41c0f05a2695c4df18ed5b02a8e189ed7ed6d4da3a64736f6c63430008220033ac33ff75c19e70fe83507db0d683fd3465c996598dc972688b7ace676c89077b", -} - -// AppRegistryABI is the input ABI used to generate the binding from. -// Deprecated: Use AppRegistryMetaData.ABI instead. -var AppRegistryABI = AppRegistryMetaData.ABI - -// AppRegistryBin is the compiled bytecode used for deploying new contracts. -// Deprecated: Use AppRegistryMetaData.Bin instead. -var AppRegistryBin = AppRegistryMetaData.Bin - -// DeployAppRegistry deploys a new Ethereum contract, binding an instance of AppRegistry to it. -func DeployAppRegistry(auth *bind.TransactOpts, backend bind.ContractBackend, asset_ common.Address, unlockPeriod_ *big.Int, admin_ common.Address) (common.Address, *types.Transaction, *AppRegistry, error) { - parsed, err := AppRegistryMetaData.GetAbi() - if err != nil { - return common.Address{}, nil, nil, err - } - if parsed == nil { - return common.Address{}, nil, nil, errors.New("GetABI returned nil") - } - - address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(AppRegistryBin), backend, asset_, unlockPeriod_, admin_) - if err != nil { - return common.Address{}, nil, nil, err - } - return address, tx, &AppRegistry{AppRegistryCaller: AppRegistryCaller{contract: contract}, AppRegistryTransactor: AppRegistryTransactor{contract: contract}, AppRegistryFilterer: AppRegistryFilterer{contract: contract}}, nil -} - -// AppRegistry is an auto generated Go binding around an Ethereum contract. -type AppRegistry struct { - AppRegistryCaller // Read-only binding to the contract - AppRegistryTransactor // Write-only binding to the contract - AppRegistryFilterer // Log filterer for contract events -} - -// AppRegistryCaller is an auto generated read-only Go binding around an Ethereum contract. -type AppRegistryCaller struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// AppRegistryTransactor is an auto generated write-only Go binding around an Ethereum contract. -type AppRegistryTransactor struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// AppRegistryFilterer is an auto generated log filtering Go binding around an Ethereum contract events. -type AppRegistryFilterer struct { - contract *bind.BoundContract // Generic contract wrapper for the low level calls -} - -// AppRegistrySession is an auto generated Go binding around an Ethereum contract, -// with pre-set call and transact options. -type AppRegistrySession struct { - Contract *AppRegistry // Generic contract binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// AppRegistryCallerSession is an auto generated read-only Go binding around an Ethereum contract, -// with pre-set call options. -type AppRegistryCallerSession struct { - Contract *AppRegistryCaller // Generic contract caller binding to set the session for - CallOpts bind.CallOpts // Call options to use throughout this session -} - -// AppRegistryTransactorSession is an auto generated write-only Go binding around an Ethereum contract, -// with pre-set transact options. -type AppRegistryTransactorSession struct { - Contract *AppRegistryTransactor // Generic contract transactor binding to set the session for - TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session -} - -// AppRegistryRaw is an auto generated low-level Go binding around an Ethereum contract. -type AppRegistryRaw struct { - Contract *AppRegistry // Generic contract binding to access the raw methods on -} - -// AppRegistryCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. -type AppRegistryCallerRaw struct { - Contract *AppRegistryCaller // Generic read-only contract binding to access the raw methods on -} - -// AppRegistryTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. -type AppRegistryTransactorRaw struct { - Contract *AppRegistryTransactor // Generic write-only contract binding to access the raw methods on -} - -// NewAppRegistry creates a new instance of AppRegistry, bound to a specific deployed contract. -func NewAppRegistry(address common.Address, backend bind.ContractBackend) (*AppRegistry, error) { - contract, err := bindAppRegistry(address, backend, backend, backend) - if err != nil { - return nil, err - } - return &AppRegistry{AppRegistryCaller: AppRegistryCaller{contract: contract}, AppRegistryTransactor: AppRegistryTransactor{contract: contract}, AppRegistryFilterer: AppRegistryFilterer{contract: contract}}, nil -} - -// NewAppRegistryCaller creates a new read-only instance of AppRegistry, bound to a specific deployed contract. -func NewAppRegistryCaller(address common.Address, caller bind.ContractCaller) (*AppRegistryCaller, error) { - contract, err := bindAppRegistry(address, caller, nil, nil) - if err != nil { - return nil, err - } - return &AppRegistryCaller{contract: contract}, nil -} - -// NewAppRegistryTransactor creates a new write-only instance of AppRegistry, bound to a specific deployed contract. -func NewAppRegistryTransactor(address common.Address, transactor bind.ContractTransactor) (*AppRegistryTransactor, error) { - contract, err := bindAppRegistry(address, nil, transactor, nil) - if err != nil { - return nil, err - } - return &AppRegistryTransactor{contract: contract}, nil -} - -// NewAppRegistryFilterer creates a new log filterer instance of AppRegistry, bound to a specific deployed contract. -func NewAppRegistryFilterer(address common.Address, filterer bind.ContractFilterer) (*AppRegistryFilterer, error) { - contract, err := bindAppRegistry(address, nil, nil, filterer) - if err != nil { - return nil, err - } - return &AppRegistryFilterer{contract: contract}, nil -} - -// bindAppRegistry binds a generic wrapper to an already deployed contract. -func bindAppRegistry(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { - parsed, err := abi.JSON(strings.NewReader(AppRegistryABI)) - if err != nil { - return nil, err - } - return bind.NewBoundContract(address, parsed, caller, transactor, filterer), nil -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_AppRegistry *AppRegistryRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _AppRegistry.Contract.AppRegistryCaller.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_AppRegistry *AppRegistryRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _AppRegistry.Contract.AppRegistryTransactor.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_AppRegistry *AppRegistryRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _AppRegistry.Contract.AppRegistryTransactor.contract.Transact(opts, method, params...) -} - -// Call invokes the (constant) contract method with params as input values and -// sets the output to result. The result type might be a single field for simple -// returns, a slice of interfaces for anonymous returns and a struct for named -// returns. -func (_AppRegistry *AppRegistryCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { - return _AppRegistry.Contract.contract.Call(opts, result, method, params...) -} - -// Transfer initiates a plain transaction to move funds to the contract, calling -// its default method if one is available. -func (_AppRegistry *AppRegistryTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { - return _AppRegistry.Contract.contract.Transfer(opts) -} - -// Transact invokes the (paid) contract method with params as input values. -func (_AppRegistry *AppRegistryTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { - return _AppRegistry.Contract.contract.Transact(opts, method, params...) -} - -// ADJUDICATORROLE is a free data retrieval call binding the contract method 0xf538f7dd. -// -// Solidity: function ADJUDICATOR_ROLE() view returns(bytes32) -func (_AppRegistry *AppRegistryCaller) ADJUDICATORROLE(opts *bind.CallOpts) ([32]byte, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "ADJUDICATOR_ROLE") - - if err != nil { - return *new([32]byte), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - - return out0, err - -} - -// ADJUDICATORROLE is a free data retrieval call binding the contract method 0xf538f7dd. -// -// Solidity: function ADJUDICATOR_ROLE() view returns(bytes32) -func (_AppRegistry *AppRegistrySession) ADJUDICATORROLE() ([32]byte, error) { - return _AppRegistry.Contract.ADJUDICATORROLE(&_AppRegistry.CallOpts) -} - -// ADJUDICATORROLE is a free data retrieval call binding the contract method 0xf538f7dd. -// -// Solidity: function ADJUDICATOR_ROLE() view returns(bytes32) -func (_AppRegistry *AppRegistryCallerSession) ADJUDICATORROLE() ([32]byte, error) { - return _AppRegistry.Contract.ADJUDICATORROLE(&_AppRegistry.CallOpts) -} - -// ASSET is a free data retrieval call binding the contract method 0x4800d97f. -// -// Solidity: function ASSET() view returns(address) -func (_AppRegistry *AppRegistryCaller) ASSET(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "ASSET") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// ASSET is a free data retrieval call binding the contract method 0x4800d97f. -// -// Solidity: function ASSET() view returns(address) -func (_AppRegistry *AppRegistrySession) ASSET() (common.Address, error) { - return _AppRegistry.Contract.ASSET(&_AppRegistry.CallOpts) -} - -// ASSET is a free data retrieval call binding the contract method 0x4800d97f. -// -// Solidity: function ASSET() view returns(address) -func (_AppRegistry *AppRegistryCallerSession) ASSET() (common.Address, error) { - return _AppRegistry.Contract.ASSET(&_AppRegistry.CallOpts) -} - -// DEFAULTADMINROLE is a free data retrieval call binding the contract method 0xa217fddf. -// -// Solidity: function DEFAULT_ADMIN_ROLE() view returns(bytes32) -func (_AppRegistry *AppRegistryCaller) DEFAULTADMINROLE(opts *bind.CallOpts) ([32]byte, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "DEFAULT_ADMIN_ROLE") - - if err != nil { - return *new([32]byte), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - - return out0, err - -} - -// DEFAULTADMINROLE is a free data retrieval call binding the contract method 0xa217fddf. -// -// Solidity: function DEFAULT_ADMIN_ROLE() view returns(bytes32) -func (_AppRegistry *AppRegistrySession) DEFAULTADMINROLE() ([32]byte, error) { - return _AppRegistry.Contract.DEFAULTADMINROLE(&_AppRegistry.CallOpts) -} - -// DEFAULTADMINROLE is a free data retrieval call binding the contract method 0xa217fddf. -// -// Solidity: function DEFAULT_ADMIN_ROLE() view returns(bytes32) -func (_AppRegistry *AppRegistryCallerSession) DEFAULTADMINROLE() ([32]byte, error) { - return _AppRegistry.Contract.DEFAULTADMINROLE(&_AppRegistry.CallOpts) -} - -// UNLOCKPERIOD is a free data retrieval call binding the contract method 0x259a28cf. -// -// Solidity: function UNLOCK_PERIOD() view returns(uint256) -func (_AppRegistry *AppRegistryCaller) UNLOCKPERIOD(opts *bind.CallOpts) (*big.Int, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "UNLOCK_PERIOD") - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// UNLOCKPERIOD is a free data retrieval call binding the contract method 0x259a28cf. -// -// Solidity: function UNLOCK_PERIOD() view returns(uint256) -func (_AppRegistry *AppRegistrySession) UNLOCKPERIOD() (*big.Int, error) { - return _AppRegistry.Contract.UNLOCKPERIOD(&_AppRegistry.CallOpts) -} - -// UNLOCKPERIOD is a free data retrieval call binding the contract method 0x259a28cf. -// -// Solidity: function UNLOCK_PERIOD() view returns(uint256) -func (_AppRegistry *AppRegistryCallerSession) UNLOCKPERIOD() (*big.Int, error) { - return _AppRegistry.Contract.UNLOCKPERIOD(&_AppRegistry.CallOpts) -} - -// Asset is a free data retrieval call binding the contract method 0x38d52e0f. -// -// Solidity: function asset() view returns(address) -func (_AppRegistry *AppRegistryCaller) Asset(opts *bind.CallOpts) (common.Address, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "asset") - - if err != nil { - return *new(common.Address), err - } - - out0 := *abi.ConvertType(out[0], new(common.Address)).(*common.Address) - - return out0, err - -} - -// Asset is a free data retrieval call binding the contract method 0x38d52e0f. -// -// Solidity: function asset() view returns(address) -func (_AppRegistry *AppRegistrySession) Asset() (common.Address, error) { - return _AppRegistry.Contract.Asset(&_AppRegistry.CallOpts) -} - -// Asset is a free data retrieval call binding the contract method 0x38d52e0f. -// -// Solidity: function asset() view returns(address) -func (_AppRegistry *AppRegistryCallerSession) Asset() (common.Address, error) { - return _AppRegistry.Contract.Asset(&_AppRegistry.CallOpts) -} - -// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. -// -// Solidity: function balanceOf(address user) view returns(uint256) -func (_AppRegistry *AppRegistryCaller) BalanceOf(opts *bind.CallOpts, user common.Address) (*big.Int, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "balanceOf", user) - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. -// -// Solidity: function balanceOf(address user) view returns(uint256) -func (_AppRegistry *AppRegistrySession) BalanceOf(user common.Address) (*big.Int, error) { - return _AppRegistry.Contract.BalanceOf(&_AppRegistry.CallOpts, user) -} - -// BalanceOf is a free data retrieval call binding the contract method 0x70a08231. -// -// Solidity: function balanceOf(address user) view returns(uint256) -func (_AppRegistry *AppRegistryCallerSession) BalanceOf(user common.Address) (*big.Int, error) { - return _AppRegistry.Contract.BalanceOf(&_AppRegistry.CallOpts, user) -} - -// GetRoleAdmin is a free data retrieval call binding the contract method 0x248a9ca3. -// -// Solidity: function getRoleAdmin(bytes32 role) view returns(bytes32) -func (_AppRegistry *AppRegistryCaller) GetRoleAdmin(opts *bind.CallOpts, role [32]byte) ([32]byte, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "getRoleAdmin", role) - - if err != nil { - return *new([32]byte), err - } - - out0 := *abi.ConvertType(out[0], new([32]byte)).(*[32]byte) - - return out0, err - -} - -// GetRoleAdmin is a free data retrieval call binding the contract method 0x248a9ca3. -// -// Solidity: function getRoleAdmin(bytes32 role) view returns(bytes32) -func (_AppRegistry *AppRegistrySession) GetRoleAdmin(role [32]byte) ([32]byte, error) { - return _AppRegistry.Contract.GetRoleAdmin(&_AppRegistry.CallOpts, role) -} - -// GetRoleAdmin is a free data retrieval call binding the contract method 0x248a9ca3. -// -// Solidity: function getRoleAdmin(bytes32 role) view returns(bytes32) -func (_AppRegistry *AppRegistryCallerSession) GetRoleAdmin(role [32]byte) ([32]byte, error) { - return _AppRegistry.Contract.GetRoleAdmin(&_AppRegistry.CallOpts, role) -} - -// HasRole is a free data retrieval call binding the contract method 0x91d14854. -// -// Solidity: function hasRole(bytes32 role, address account) view returns(bool) -func (_AppRegistry *AppRegistryCaller) HasRole(opts *bind.CallOpts, role [32]byte, account common.Address) (bool, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "hasRole", role, account) - - if err != nil { - return *new(bool), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - - return out0, err - -} - -// HasRole is a free data retrieval call binding the contract method 0x91d14854. -// -// Solidity: function hasRole(bytes32 role, address account) view returns(bool) -func (_AppRegistry *AppRegistrySession) HasRole(role [32]byte, account common.Address) (bool, error) { - return _AppRegistry.Contract.HasRole(&_AppRegistry.CallOpts, role, account) -} - -// HasRole is a free data retrieval call binding the contract method 0x91d14854. -// -// Solidity: function hasRole(bytes32 role, address account) view returns(bool) -func (_AppRegistry *AppRegistryCallerSession) HasRole(role [32]byte, account common.Address) (bool, error) { - return _AppRegistry.Contract.HasRole(&_AppRegistry.CallOpts, role, account) -} - -// LastSlashTimestamp is a free data retrieval call binding the contract method 0xc77f5062. -// -// Solidity: function lastSlashTimestamp() view returns(uint256) -func (_AppRegistry *AppRegistryCaller) LastSlashTimestamp(opts *bind.CallOpts) (*big.Int, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "lastSlashTimestamp") - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// LastSlashTimestamp is a free data retrieval call binding the contract method 0xc77f5062. -// -// Solidity: function lastSlashTimestamp() view returns(uint256) -func (_AppRegistry *AppRegistrySession) LastSlashTimestamp() (*big.Int, error) { - return _AppRegistry.Contract.LastSlashTimestamp(&_AppRegistry.CallOpts) -} - -// LastSlashTimestamp is a free data retrieval call binding the contract method 0xc77f5062. -// -// Solidity: function lastSlashTimestamp() view returns(uint256) -func (_AppRegistry *AppRegistryCallerSession) LastSlashTimestamp() (*big.Int, error) { - return _AppRegistry.Contract.LastSlashTimestamp(&_AppRegistry.CallOpts) -} - -// LockStateOf is a free data retrieval call binding the contract method 0xa43e9cf4. -// -// Solidity: function lockStateOf(address user) view returns(uint8) -func (_AppRegistry *AppRegistryCaller) LockStateOf(opts *bind.CallOpts, user common.Address) (uint8, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "lockStateOf", user) - - if err != nil { - return *new(uint8), err - } - - out0 := *abi.ConvertType(out[0], new(uint8)).(*uint8) - - return out0, err - -} - -// LockStateOf is a free data retrieval call binding the contract method 0xa43e9cf4. -// -// Solidity: function lockStateOf(address user) view returns(uint8) -func (_AppRegistry *AppRegistrySession) LockStateOf(user common.Address) (uint8, error) { - return _AppRegistry.Contract.LockStateOf(&_AppRegistry.CallOpts, user) -} - -// LockStateOf is a free data retrieval call binding the contract method 0xa43e9cf4. -// -// Solidity: function lockStateOf(address user) view returns(uint8) -func (_AppRegistry *AppRegistryCallerSession) LockStateOf(user common.Address) (uint8, error) { - return _AppRegistry.Contract.LockStateOf(&_AppRegistry.CallOpts, user) -} - -// SlashCooldown is a free data retrieval call binding the contract method 0x5a6ca4ae. -// -// Solidity: function slashCooldown() view returns(uint256) -func (_AppRegistry *AppRegistryCaller) SlashCooldown(opts *bind.CallOpts) (*big.Int, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "slashCooldown") - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// SlashCooldown is a free data retrieval call binding the contract method 0x5a6ca4ae. -// -// Solidity: function slashCooldown() view returns(uint256) -func (_AppRegistry *AppRegistrySession) SlashCooldown() (*big.Int, error) { - return _AppRegistry.Contract.SlashCooldown(&_AppRegistry.CallOpts) -} - -// SlashCooldown is a free data retrieval call binding the contract method 0x5a6ca4ae. -// -// Solidity: function slashCooldown() view returns(uint256) -func (_AppRegistry *AppRegistryCallerSession) SlashCooldown() (*big.Int, error) { - return _AppRegistry.Contract.SlashCooldown(&_AppRegistry.CallOpts) -} - -// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. -// -// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) -func (_AppRegistry *AppRegistryCaller) SupportsInterface(opts *bind.CallOpts, interfaceId [4]byte) (bool, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "supportsInterface", interfaceId) - - if err != nil { - return *new(bool), err - } - - out0 := *abi.ConvertType(out[0], new(bool)).(*bool) - - return out0, err - -} - -// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. -// -// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) -func (_AppRegistry *AppRegistrySession) SupportsInterface(interfaceId [4]byte) (bool, error) { - return _AppRegistry.Contract.SupportsInterface(&_AppRegistry.CallOpts, interfaceId) -} - -// SupportsInterface is a free data retrieval call binding the contract method 0x01ffc9a7. -// -// Solidity: function supportsInterface(bytes4 interfaceId) view returns(bool) -func (_AppRegistry *AppRegistryCallerSession) SupportsInterface(interfaceId [4]byte) (bool, error) { - return _AppRegistry.Contract.SupportsInterface(&_AppRegistry.CallOpts, interfaceId) -} - -// UnlockTimestampOf is a free data retrieval call binding the contract method 0x02f70fe6. -// -// Solidity: function unlockTimestampOf(address user) view returns(uint256) -func (_AppRegistry *AppRegistryCaller) UnlockTimestampOf(opts *bind.CallOpts, user common.Address) (*big.Int, error) { - var out []interface{} - err := _AppRegistry.contract.Call(opts, &out, "unlockTimestampOf", user) - - if err != nil { - return *new(*big.Int), err - } - - out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) - - return out0, err - -} - -// UnlockTimestampOf is a free data retrieval call binding the contract method 0x02f70fe6. -// -// Solidity: function unlockTimestampOf(address user) view returns(uint256) -func (_AppRegistry *AppRegistrySession) UnlockTimestampOf(user common.Address) (*big.Int, error) { - return _AppRegistry.Contract.UnlockTimestampOf(&_AppRegistry.CallOpts, user) -} - -// UnlockTimestampOf is a free data retrieval call binding the contract method 0x02f70fe6. -// -// Solidity: function unlockTimestampOf(address user) view returns(uint256) -func (_AppRegistry *AppRegistryCallerSession) UnlockTimestampOf(user common.Address) (*big.Int, error) { - return _AppRegistry.Contract.UnlockTimestampOf(&_AppRegistry.CallOpts, user) -} - -// GrantRole is a paid mutator transaction binding the contract method 0x2f2ff15d. -// -// Solidity: function grantRole(bytes32 role, address account) returns() -func (_AppRegistry *AppRegistryTransactor) GrantRole(opts *bind.TransactOpts, role [32]byte, account common.Address) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "grantRole", role, account) -} - -// GrantRole is a paid mutator transaction binding the contract method 0x2f2ff15d. -// -// Solidity: function grantRole(bytes32 role, address account) returns() -func (_AppRegistry *AppRegistrySession) GrantRole(role [32]byte, account common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.GrantRole(&_AppRegistry.TransactOpts, role, account) -} - -// GrantRole is a paid mutator transaction binding the contract method 0x2f2ff15d. -// -// Solidity: function grantRole(bytes32 role, address account) returns() -func (_AppRegistry *AppRegistryTransactorSession) GrantRole(role [32]byte, account common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.GrantRole(&_AppRegistry.TransactOpts, role, account) -} - -// Lock is a paid mutator transaction binding the contract method 0x282d3fdf. -// -// Solidity: function lock(address target, uint256 amount) returns() -func (_AppRegistry *AppRegistryTransactor) Lock(opts *bind.TransactOpts, target common.Address, amount *big.Int) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "lock", target, amount) -} - -// Lock is a paid mutator transaction binding the contract method 0x282d3fdf. -// -// Solidity: function lock(address target, uint256 amount) returns() -func (_AppRegistry *AppRegistrySession) Lock(target common.Address, amount *big.Int) (*types.Transaction, error) { - return _AppRegistry.Contract.Lock(&_AppRegistry.TransactOpts, target, amount) -} - -// Lock is a paid mutator transaction binding the contract method 0x282d3fdf. -// -// Solidity: function lock(address target, uint256 amount) returns() -func (_AppRegistry *AppRegistryTransactorSession) Lock(target common.Address, amount *big.Int) (*types.Transaction, error) { - return _AppRegistry.Contract.Lock(&_AppRegistry.TransactOpts, target, amount) -} - -// Relock is a paid mutator transaction binding the contract method 0xc53b573d. -// -// Solidity: function relock() returns() -func (_AppRegistry *AppRegistryTransactor) Relock(opts *bind.TransactOpts) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "relock") -} - -// Relock is a paid mutator transaction binding the contract method 0xc53b573d. -// -// Solidity: function relock() returns() -func (_AppRegistry *AppRegistrySession) Relock() (*types.Transaction, error) { - return _AppRegistry.Contract.Relock(&_AppRegistry.TransactOpts) -} - -// Relock is a paid mutator transaction binding the contract method 0xc53b573d. -// -// Solidity: function relock() returns() -func (_AppRegistry *AppRegistryTransactorSession) Relock() (*types.Transaction, error) { - return _AppRegistry.Contract.Relock(&_AppRegistry.TransactOpts) -} - -// RenounceRole is a paid mutator transaction binding the contract method 0x36568abe. -// -// Solidity: function renounceRole(bytes32 role, address callerConfirmation) returns() -func (_AppRegistry *AppRegistryTransactor) RenounceRole(opts *bind.TransactOpts, role [32]byte, callerConfirmation common.Address) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "renounceRole", role, callerConfirmation) -} - -// RenounceRole is a paid mutator transaction binding the contract method 0x36568abe. -// -// Solidity: function renounceRole(bytes32 role, address callerConfirmation) returns() -func (_AppRegistry *AppRegistrySession) RenounceRole(role [32]byte, callerConfirmation common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.RenounceRole(&_AppRegistry.TransactOpts, role, callerConfirmation) -} - -// RenounceRole is a paid mutator transaction binding the contract method 0x36568abe. -// -// Solidity: function renounceRole(bytes32 role, address callerConfirmation) returns() -func (_AppRegistry *AppRegistryTransactorSession) RenounceRole(role [32]byte, callerConfirmation common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.RenounceRole(&_AppRegistry.TransactOpts, role, callerConfirmation) -} - -// RevokeRole is a paid mutator transaction binding the contract method 0xd547741f. -// -// Solidity: function revokeRole(bytes32 role, address account) returns() -func (_AppRegistry *AppRegistryTransactor) RevokeRole(opts *bind.TransactOpts, role [32]byte, account common.Address) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "revokeRole", role, account) -} - -// RevokeRole is a paid mutator transaction binding the contract method 0xd547741f. -// -// Solidity: function revokeRole(bytes32 role, address account) returns() -func (_AppRegistry *AppRegistrySession) RevokeRole(role [32]byte, account common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.RevokeRole(&_AppRegistry.TransactOpts, role, account) -} - -// RevokeRole is a paid mutator transaction binding the contract method 0xd547741f. -// -// Solidity: function revokeRole(bytes32 role, address account) returns() -func (_AppRegistry *AppRegistryTransactorSession) RevokeRole(role [32]byte, account common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.RevokeRole(&_AppRegistry.TransactOpts, role, account) -} - -// SetSlashCooldown is a paid mutator transaction binding the contract method 0x256f8f09. -// -// Solidity: function setSlashCooldown(uint256 newCooldown) returns() -func (_AppRegistry *AppRegistryTransactor) SetSlashCooldown(opts *bind.TransactOpts, newCooldown *big.Int) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "setSlashCooldown", newCooldown) -} - -// SetSlashCooldown is a paid mutator transaction binding the contract method 0x256f8f09. -// -// Solidity: function setSlashCooldown(uint256 newCooldown) returns() -func (_AppRegistry *AppRegistrySession) SetSlashCooldown(newCooldown *big.Int) (*types.Transaction, error) { - return _AppRegistry.Contract.SetSlashCooldown(&_AppRegistry.TransactOpts, newCooldown) -} - -// SetSlashCooldown is a paid mutator transaction binding the contract method 0x256f8f09. -// -// Solidity: function setSlashCooldown(uint256 newCooldown) returns() -func (_AppRegistry *AppRegistryTransactorSession) SetSlashCooldown(newCooldown *big.Int) (*types.Transaction, error) { - return _AppRegistry.Contract.SetSlashCooldown(&_AppRegistry.TransactOpts, newCooldown) -} - -// Slash is a paid mutator transaction binding the contract method 0x010379b2. -// -// Solidity: function slash(address user, uint256 amount, address recipient, bytes decision) returns() -func (_AppRegistry *AppRegistryTransactor) Slash(opts *bind.TransactOpts, user common.Address, amount *big.Int, recipient common.Address, decision []byte) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "slash", user, amount, recipient, decision) -} - -// Slash is a paid mutator transaction binding the contract method 0x010379b2. -// -// Solidity: function slash(address user, uint256 amount, address recipient, bytes decision) returns() -func (_AppRegistry *AppRegistrySession) Slash(user common.Address, amount *big.Int, recipient common.Address, decision []byte) (*types.Transaction, error) { - return _AppRegistry.Contract.Slash(&_AppRegistry.TransactOpts, user, amount, recipient, decision) -} - -// Slash is a paid mutator transaction binding the contract method 0x010379b2. -// -// Solidity: function slash(address user, uint256 amount, address recipient, bytes decision) returns() -func (_AppRegistry *AppRegistryTransactorSession) Slash(user common.Address, amount *big.Int, recipient common.Address, decision []byte) (*types.Transaction, error) { - return _AppRegistry.Contract.Slash(&_AppRegistry.TransactOpts, user, amount, recipient, decision) -} - -// Unlock is a paid mutator transaction binding the contract method 0xa69df4b5. -// -// Solidity: function unlock() returns() -func (_AppRegistry *AppRegistryTransactor) Unlock(opts *bind.TransactOpts) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "unlock") -} - -// Unlock is a paid mutator transaction binding the contract method 0xa69df4b5. -// -// Solidity: function unlock() returns() -func (_AppRegistry *AppRegistrySession) Unlock() (*types.Transaction, error) { - return _AppRegistry.Contract.Unlock(&_AppRegistry.TransactOpts) -} - -// Unlock is a paid mutator transaction binding the contract method 0xa69df4b5. -// -// Solidity: function unlock() returns() -func (_AppRegistry *AppRegistryTransactorSession) Unlock() (*types.Transaction, error) { - return _AppRegistry.Contract.Unlock(&_AppRegistry.TransactOpts) -} - -// Withdraw is a paid mutator transaction binding the contract method 0x51cff8d9. -// -// Solidity: function withdraw(address destination) returns() -func (_AppRegistry *AppRegistryTransactor) Withdraw(opts *bind.TransactOpts, destination common.Address) (*types.Transaction, error) { - return _AppRegistry.contract.Transact(opts, "withdraw", destination) -} - -// Withdraw is a paid mutator transaction binding the contract method 0x51cff8d9. -// -// Solidity: function withdraw(address destination) returns() -func (_AppRegistry *AppRegistrySession) Withdraw(destination common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.Withdraw(&_AppRegistry.TransactOpts, destination) -} - -// Withdraw is a paid mutator transaction binding the contract method 0x51cff8d9. -// -// Solidity: function withdraw(address destination) returns() -func (_AppRegistry *AppRegistryTransactorSession) Withdraw(destination common.Address) (*types.Transaction, error) { - return _AppRegistry.Contract.Withdraw(&_AppRegistry.TransactOpts, destination) -} - -// AppRegistryLockedIterator is returned from FilterLocked and is used to iterate over the raw logs and unpacked data for Locked events raised by the AppRegistry contract. -type AppRegistryLockedIterator struct { - Event *AppRegistryLocked // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryLockedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryLocked) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryLocked) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryLockedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryLockedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryLocked represents a Locked event raised by the AppRegistry contract. -type AppRegistryLocked struct { - User common.Address - Deposited *big.Int - NewBalance *big.Int - Raw types.Log // Blockchain specific contextual infos -} - -// FilterLocked is a free log retrieval operation binding the contract event 0xd4665e3049283582ba6f9eba07a5b3e12dab49e02da99e8927a47af5d134bea5. -// -// Solidity: event Locked(address indexed user, uint256 deposited, uint256 newBalance) -func (_AppRegistry *AppRegistryFilterer) FilterLocked(opts *bind.FilterOpts, user []common.Address) (*AppRegistryLockedIterator, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "Locked", userRule) - if err != nil { - return nil, err - } - return &AppRegistryLockedIterator{contract: _AppRegistry.contract, event: "Locked", logs: logs, sub: sub}, nil -} - -// WatchLocked is a free log subscription operation binding the contract event 0xd4665e3049283582ba6f9eba07a5b3e12dab49e02da99e8927a47af5d134bea5. -// -// Solidity: event Locked(address indexed user, uint256 deposited, uint256 newBalance) -func (_AppRegistry *AppRegistryFilterer) WatchLocked(opts *bind.WatchOpts, sink chan<- *AppRegistryLocked, user []common.Address) (event.Subscription, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "Locked", userRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryLocked) - if err := _AppRegistry.contract.UnpackLog(event, "Locked", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseLocked is a log parse operation binding the contract event 0xd4665e3049283582ba6f9eba07a5b3e12dab49e02da99e8927a47af5d134bea5. -// -// Solidity: event Locked(address indexed user, uint256 deposited, uint256 newBalance) -func (_AppRegistry *AppRegistryFilterer) ParseLocked(log types.Log) (*AppRegistryLocked, error) { - event := new(AppRegistryLocked) - if err := _AppRegistry.contract.UnpackLog(event, "Locked", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistryRelockedIterator is returned from FilterRelocked and is used to iterate over the raw logs and unpacked data for Relocked events raised by the AppRegistry contract. -type AppRegistryRelockedIterator struct { - Event *AppRegistryRelocked // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryRelockedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryRelocked) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryRelocked) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryRelockedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryRelockedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryRelocked represents a Relocked event raised by the AppRegistry contract. -type AppRegistryRelocked struct { - User common.Address - Balance *big.Int - Raw types.Log // Blockchain specific contextual infos -} - -// FilterRelocked is a free log retrieval operation binding the contract event 0xe2d155d9d74decc198a9a9b4f5bddb24ee0842ee745c9ce57e3573b971e50d9d. -// -// Solidity: event Relocked(address indexed user, uint256 balance) -func (_AppRegistry *AppRegistryFilterer) FilterRelocked(opts *bind.FilterOpts, user []common.Address) (*AppRegistryRelockedIterator, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "Relocked", userRule) - if err != nil { - return nil, err - } - return &AppRegistryRelockedIterator{contract: _AppRegistry.contract, event: "Relocked", logs: logs, sub: sub}, nil -} - -// WatchRelocked is a free log subscription operation binding the contract event 0xe2d155d9d74decc198a9a9b4f5bddb24ee0842ee745c9ce57e3573b971e50d9d. -// -// Solidity: event Relocked(address indexed user, uint256 balance) -func (_AppRegistry *AppRegistryFilterer) WatchRelocked(opts *bind.WatchOpts, sink chan<- *AppRegistryRelocked, user []common.Address) (event.Subscription, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "Relocked", userRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryRelocked) - if err := _AppRegistry.contract.UnpackLog(event, "Relocked", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseRelocked is a log parse operation binding the contract event 0xe2d155d9d74decc198a9a9b4f5bddb24ee0842ee745c9ce57e3573b971e50d9d. -// -// Solidity: event Relocked(address indexed user, uint256 balance) -func (_AppRegistry *AppRegistryFilterer) ParseRelocked(log types.Log) (*AppRegistryRelocked, error) { - event := new(AppRegistryRelocked) - if err := _AppRegistry.contract.UnpackLog(event, "Relocked", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistryRoleAdminChangedIterator is returned from FilterRoleAdminChanged and is used to iterate over the raw logs and unpacked data for RoleAdminChanged events raised by the AppRegistry contract. -type AppRegistryRoleAdminChangedIterator struct { - Event *AppRegistryRoleAdminChanged // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryRoleAdminChangedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryRoleAdminChanged) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryRoleAdminChanged) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryRoleAdminChangedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryRoleAdminChangedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryRoleAdminChanged represents a RoleAdminChanged event raised by the AppRegistry contract. -type AppRegistryRoleAdminChanged struct { - Role [32]byte - PreviousAdminRole [32]byte - NewAdminRole [32]byte - Raw types.Log // Blockchain specific contextual infos -} - -// FilterRoleAdminChanged is a free log retrieval operation binding the contract event 0xbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff. -// -// Solidity: event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole) -func (_AppRegistry *AppRegistryFilterer) FilterRoleAdminChanged(opts *bind.FilterOpts, role [][32]byte, previousAdminRole [][32]byte, newAdminRole [][32]byte) (*AppRegistryRoleAdminChangedIterator, error) { - - var roleRule []interface{} - for _, roleItem := range role { - roleRule = append(roleRule, roleItem) - } - var previousAdminRoleRule []interface{} - for _, previousAdminRoleItem := range previousAdminRole { - previousAdminRoleRule = append(previousAdminRoleRule, previousAdminRoleItem) - } - var newAdminRoleRule []interface{} - for _, newAdminRoleItem := range newAdminRole { - newAdminRoleRule = append(newAdminRoleRule, newAdminRoleItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "RoleAdminChanged", roleRule, previousAdminRoleRule, newAdminRoleRule) - if err != nil { - return nil, err - } - return &AppRegistryRoleAdminChangedIterator{contract: _AppRegistry.contract, event: "RoleAdminChanged", logs: logs, sub: sub}, nil -} - -// WatchRoleAdminChanged is a free log subscription operation binding the contract event 0xbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff. -// -// Solidity: event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole) -func (_AppRegistry *AppRegistryFilterer) WatchRoleAdminChanged(opts *bind.WatchOpts, sink chan<- *AppRegistryRoleAdminChanged, role [][32]byte, previousAdminRole [][32]byte, newAdminRole [][32]byte) (event.Subscription, error) { - - var roleRule []interface{} - for _, roleItem := range role { - roleRule = append(roleRule, roleItem) - } - var previousAdminRoleRule []interface{} - for _, previousAdminRoleItem := range previousAdminRole { - previousAdminRoleRule = append(previousAdminRoleRule, previousAdminRoleItem) - } - var newAdminRoleRule []interface{} - for _, newAdminRoleItem := range newAdminRole { - newAdminRoleRule = append(newAdminRoleRule, newAdminRoleItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "RoleAdminChanged", roleRule, previousAdminRoleRule, newAdminRoleRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryRoleAdminChanged) - if err := _AppRegistry.contract.UnpackLog(event, "RoleAdminChanged", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseRoleAdminChanged is a log parse operation binding the contract event 0xbd79b86ffe0ab8e8776151514217cd7cacd52c909f66475c3af44e129f0b00ff. -// -// Solidity: event RoleAdminChanged(bytes32 indexed role, bytes32 indexed previousAdminRole, bytes32 indexed newAdminRole) -func (_AppRegistry *AppRegistryFilterer) ParseRoleAdminChanged(log types.Log) (*AppRegistryRoleAdminChanged, error) { - event := new(AppRegistryRoleAdminChanged) - if err := _AppRegistry.contract.UnpackLog(event, "RoleAdminChanged", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistryRoleGrantedIterator is returned from FilterRoleGranted and is used to iterate over the raw logs and unpacked data for RoleGranted events raised by the AppRegistry contract. -type AppRegistryRoleGrantedIterator struct { - Event *AppRegistryRoleGranted // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryRoleGrantedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryRoleGranted) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryRoleGranted) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryRoleGrantedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryRoleGrantedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryRoleGranted represents a RoleGranted event raised by the AppRegistry contract. -type AppRegistryRoleGranted struct { - Role [32]byte - Account common.Address - Sender common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterRoleGranted is a free log retrieval operation binding the contract event 0x2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d. -// -// Solidity: event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender) -func (_AppRegistry *AppRegistryFilterer) FilterRoleGranted(opts *bind.FilterOpts, role [][32]byte, account []common.Address, sender []common.Address) (*AppRegistryRoleGrantedIterator, error) { - - var roleRule []interface{} - for _, roleItem := range role { - roleRule = append(roleRule, roleItem) - } - var accountRule []interface{} - for _, accountItem := range account { - accountRule = append(accountRule, accountItem) - } - var senderRule []interface{} - for _, senderItem := range sender { - senderRule = append(senderRule, senderItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "RoleGranted", roleRule, accountRule, senderRule) - if err != nil { - return nil, err - } - return &AppRegistryRoleGrantedIterator{contract: _AppRegistry.contract, event: "RoleGranted", logs: logs, sub: sub}, nil -} - -// WatchRoleGranted is a free log subscription operation binding the contract event 0x2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d. -// -// Solidity: event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender) -func (_AppRegistry *AppRegistryFilterer) WatchRoleGranted(opts *bind.WatchOpts, sink chan<- *AppRegistryRoleGranted, role [][32]byte, account []common.Address, sender []common.Address) (event.Subscription, error) { - - var roleRule []interface{} - for _, roleItem := range role { - roleRule = append(roleRule, roleItem) - } - var accountRule []interface{} - for _, accountItem := range account { - accountRule = append(accountRule, accountItem) - } - var senderRule []interface{} - for _, senderItem := range sender { - senderRule = append(senderRule, senderItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "RoleGranted", roleRule, accountRule, senderRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryRoleGranted) - if err := _AppRegistry.contract.UnpackLog(event, "RoleGranted", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseRoleGranted is a log parse operation binding the contract event 0x2f8788117e7eff1d82e926ec794901d17c78024a50270940304540a733656f0d. -// -// Solidity: event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender) -func (_AppRegistry *AppRegistryFilterer) ParseRoleGranted(log types.Log) (*AppRegistryRoleGranted, error) { - event := new(AppRegistryRoleGranted) - if err := _AppRegistry.contract.UnpackLog(event, "RoleGranted", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistryRoleRevokedIterator is returned from FilterRoleRevoked and is used to iterate over the raw logs and unpacked data for RoleRevoked events raised by the AppRegistry contract. -type AppRegistryRoleRevokedIterator struct { - Event *AppRegistryRoleRevoked // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryRoleRevokedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryRoleRevoked) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryRoleRevoked) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryRoleRevokedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryRoleRevokedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryRoleRevoked represents a RoleRevoked event raised by the AppRegistry contract. -type AppRegistryRoleRevoked struct { - Role [32]byte - Account common.Address - Sender common.Address - Raw types.Log // Blockchain specific contextual infos -} - -// FilterRoleRevoked is a free log retrieval operation binding the contract event 0xf6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b. -// -// Solidity: event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender) -func (_AppRegistry *AppRegistryFilterer) FilterRoleRevoked(opts *bind.FilterOpts, role [][32]byte, account []common.Address, sender []common.Address) (*AppRegistryRoleRevokedIterator, error) { - - var roleRule []interface{} - for _, roleItem := range role { - roleRule = append(roleRule, roleItem) - } - var accountRule []interface{} - for _, accountItem := range account { - accountRule = append(accountRule, accountItem) - } - var senderRule []interface{} - for _, senderItem := range sender { - senderRule = append(senderRule, senderItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "RoleRevoked", roleRule, accountRule, senderRule) - if err != nil { - return nil, err - } - return &AppRegistryRoleRevokedIterator{contract: _AppRegistry.contract, event: "RoleRevoked", logs: logs, sub: sub}, nil -} - -// WatchRoleRevoked is a free log subscription operation binding the contract event 0xf6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b. -// -// Solidity: event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender) -func (_AppRegistry *AppRegistryFilterer) WatchRoleRevoked(opts *bind.WatchOpts, sink chan<- *AppRegistryRoleRevoked, role [][32]byte, account []common.Address, sender []common.Address) (event.Subscription, error) { - - var roleRule []interface{} - for _, roleItem := range role { - roleRule = append(roleRule, roleItem) - } - var accountRule []interface{} - for _, accountItem := range account { - accountRule = append(accountRule, accountItem) - } - var senderRule []interface{} - for _, senderItem := range sender { - senderRule = append(senderRule, senderItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "RoleRevoked", roleRule, accountRule, senderRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryRoleRevoked) - if err := _AppRegistry.contract.UnpackLog(event, "RoleRevoked", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseRoleRevoked is a log parse operation binding the contract event 0xf6391f5c32d9c69d2a47ea670b442974b53935d1edc7fd64eb21e047a839171b. -// -// Solidity: event RoleRevoked(bytes32 indexed role, address indexed account, address indexed sender) -func (_AppRegistry *AppRegistryFilterer) ParseRoleRevoked(log types.Log) (*AppRegistryRoleRevoked, error) { - event := new(AppRegistryRoleRevoked) - if err := _AppRegistry.contract.UnpackLog(event, "RoleRevoked", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistrySlashCooldownUpdatedIterator is returned from FilterSlashCooldownUpdated and is used to iterate over the raw logs and unpacked data for SlashCooldownUpdated events raised by the AppRegistry contract. -type AppRegistrySlashCooldownUpdatedIterator struct { - Event *AppRegistrySlashCooldownUpdated // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistrySlashCooldownUpdatedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistrySlashCooldownUpdated) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistrySlashCooldownUpdated) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistrySlashCooldownUpdatedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistrySlashCooldownUpdatedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistrySlashCooldownUpdated represents a SlashCooldownUpdated event raised by the AppRegistry contract. -type AppRegistrySlashCooldownUpdated struct { - OldCooldown *big.Int - NewCooldown *big.Int - Raw types.Log // Blockchain specific contextual infos -} - -// FilterSlashCooldownUpdated is a free log retrieval operation binding the contract event 0x21dd401bad58f48f88b208aad5157305ac7e8ec5db030042aaec08ebd7f50e4c. -// -// Solidity: event SlashCooldownUpdated(uint256 oldCooldown, uint256 newCooldown) -func (_AppRegistry *AppRegistryFilterer) FilterSlashCooldownUpdated(opts *bind.FilterOpts) (*AppRegistrySlashCooldownUpdatedIterator, error) { - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "SlashCooldownUpdated") - if err != nil { - return nil, err - } - return &AppRegistrySlashCooldownUpdatedIterator{contract: _AppRegistry.contract, event: "SlashCooldownUpdated", logs: logs, sub: sub}, nil -} - -// WatchSlashCooldownUpdated is a free log subscription operation binding the contract event 0x21dd401bad58f48f88b208aad5157305ac7e8ec5db030042aaec08ebd7f50e4c. -// -// Solidity: event SlashCooldownUpdated(uint256 oldCooldown, uint256 newCooldown) -func (_AppRegistry *AppRegistryFilterer) WatchSlashCooldownUpdated(opts *bind.WatchOpts, sink chan<- *AppRegistrySlashCooldownUpdated) (event.Subscription, error) { - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "SlashCooldownUpdated") - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistrySlashCooldownUpdated) - if err := _AppRegistry.contract.UnpackLog(event, "SlashCooldownUpdated", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseSlashCooldownUpdated is a log parse operation binding the contract event 0x21dd401bad58f48f88b208aad5157305ac7e8ec5db030042aaec08ebd7f50e4c. -// -// Solidity: event SlashCooldownUpdated(uint256 oldCooldown, uint256 newCooldown) -func (_AppRegistry *AppRegistryFilterer) ParseSlashCooldownUpdated(log types.Log) (*AppRegistrySlashCooldownUpdated, error) { - event := new(AppRegistrySlashCooldownUpdated) - if err := _AppRegistry.contract.UnpackLog(event, "SlashCooldownUpdated", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistrySlashedIterator is returned from FilterSlashed and is used to iterate over the raw logs and unpacked data for Slashed events raised by the AppRegistry contract. -type AppRegistrySlashedIterator struct { - Event *AppRegistrySlashed // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistrySlashedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistrySlashed) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistrySlashed) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistrySlashedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistrySlashedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistrySlashed represents a Slashed event raised by the AppRegistry contract. -type AppRegistrySlashed struct { - User common.Address - Amount *big.Int - Recipient common.Address - Decision []byte - Raw types.Log // Blockchain specific contextual infos -} - -// FilterSlashed is a free log retrieval operation binding the contract event 0xf204f6a8b6d1439051d5fbd742389d0f778d18d21016e81c8ad3d4558266454c. -// -// Solidity: event Slashed(address indexed user, uint256 amount, address indexed recipient, bytes decision) -func (_AppRegistry *AppRegistryFilterer) FilterSlashed(opts *bind.FilterOpts, user []common.Address, recipient []common.Address) (*AppRegistrySlashedIterator, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - var recipientRule []interface{} - for _, recipientItem := range recipient { - recipientRule = append(recipientRule, recipientItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "Slashed", userRule, recipientRule) - if err != nil { - return nil, err - } - return &AppRegistrySlashedIterator{contract: _AppRegistry.contract, event: "Slashed", logs: logs, sub: sub}, nil -} - -// WatchSlashed is a free log subscription operation binding the contract event 0xf204f6a8b6d1439051d5fbd742389d0f778d18d21016e81c8ad3d4558266454c. -// -// Solidity: event Slashed(address indexed user, uint256 amount, address indexed recipient, bytes decision) -func (_AppRegistry *AppRegistryFilterer) WatchSlashed(opts *bind.WatchOpts, sink chan<- *AppRegistrySlashed, user []common.Address, recipient []common.Address) (event.Subscription, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - var recipientRule []interface{} - for _, recipientItem := range recipient { - recipientRule = append(recipientRule, recipientItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "Slashed", userRule, recipientRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistrySlashed) - if err := _AppRegistry.contract.UnpackLog(event, "Slashed", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseSlashed is a log parse operation binding the contract event 0xf204f6a8b6d1439051d5fbd742389d0f778d18d21016e81c8ad3d4558266454c. -// -// Solidity: event Slashed(address indexed user, uint256 amount, address indexed recipient, bytes decision) -func (_AppRegistry *AppRegistryFilterer) ParseSlashed(log types.Log) (*AppRegistrySlashed, error) { - event := new(AppRegistrySlashed) - if err := _AppRegistry.contract.UnpackLog(event, "Slashed", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistryUnlockInitiatedIterator is returned from FilterUnlockInitiated and is used to iterate over the raw logs and unpacked data for UnlockInitiated events raised by the AppRegistry contract. -type AppRegistryUnlockInitiatedIterator struct { - Event *AppRegistryUnlockInitiated // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryUnlockInitiatedIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryUnlockInitiated) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryUnlockInitiated) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryUnlockInitiatedIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryUnlockInitiatedIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryUnlockInitiated represents a UnlockInitiated event raised by the AppRegistry contract. -type AppRegistryUnlockInitiated struct { - User common.Address - Balance *big.Int - AvailableAt *big.Int - Raw types.Log // Blockchain specific contextual infos -} - -// FilterUnlockInitiated is a free log retrieval operation binding the contract event 0xd73fc4aafa5101b91e88b8c2fa75d8d6db73466773addb11c079d063c1e63c0c. -// -// Solidity: event UnlockInitiated(address indexed user, uint256 balance, uint256 availableAt) -func (_AppRegistry *AppRegistryFilterer) FilterUnlockInitiated(opts *bind.FilterOpts, user []common.Address) (*AppRegistryUnlockInitiatedIterator, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "UnlockInitiated", userRule) - if err != nil { - return nil, err - } - return &AppRegistryUnlockInitiatedIterator{contract: _AppRegistry.contract, event: "UnlockInitiated", logs: logs, sub: sub}, nil -} - -// WatchUnlockInitiated is a free log subscription operation binding the contract event 0xd73fc4aafa5101b91e88b8c2fa75d8d6db73466773addb11c079d063c1e63c0c. -// -// Solidity: event UnlockInitiated(address indexed user, uint256 balance, uint256 availableAt) -func (_AppRegistry *AppRegistryFilterer) WatchUnlockInitiated(opts *bind.WatchOpts, sink chan<- *AppRegistryUnlockInitiated, user []common.Address) (event.Subscription, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "UnlockInitiated", userRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryUnlockInitiated) - if err := _AppRegistry.contract.UnpackLog(event, "UnlockInitiated", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseUnlockInitiated is a log parse operation binding the contract event 0xd73fc4aafa5101b91e88b8c2fa75d8d6db73466773addb11c079d063c1e63c0c. -// -// Solidity: event UnlockInitiated(address indexed user, uint256 balance, uint256 availableAt) -func (_AppRegistry *AppRegistryFilterer) ParseUnlockInitiated(log types.Log) (*AppRegistryUnlockInitiated, error) { - event := new(AppRegistryUnlockInitiated) - if err := _AppRegistry.contract.UnpackLog(event, "UnlockInitiated", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} - -// AppRegistryWithdrawnIterator is returned from FilterWithdrawn and is used to iterate over the raw logs and unpacked data for Withdrawn events raised by the AppRegistry contract. -type AppRegistryWithdrawnIterator struct { - Event *AppRegistryWithdrawn // Event containing the contract specifics and raw log - - contract *bind.BoundContract // Generic contract to use for unpacking event data - event string // Event name to use for unpacking event data - - logs chan types.Log // Log channel receiving the found contract events - sub ethereum.Subscription // Subscription for errors, completion and termination - done bool // Whether the subscription completed delivering logs - fail error // Occurred error to stop iteration -} - -// Next advances the iterator to the subsequent event, returning whether there -// are any more events found. In case of a retrieval or parsing error, false is -// returned and Error() can be queried for the exact failure. -func (it *AppRegistryWithdrawnIterator) Next() bool { - // If the iterator failed, stop iterating - if it.fail != nil { - return false - } - // If the iterator completed, deliver directly whatever's available - if it.done { - select { - case log := <-it.logs: - it.Event = new(AppRegistryWithdrawn) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - default: - return false - } - } - // Iterator still in progress, wait for either a data or an error event - select { - case log := <-it.logs: - it.Event = new(AppRegistryWithdrawn) - if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { - it.fail = err - return false - } - it.Event.Raw = log - return true - - case err := <-it.sub.Err(): - it.done = true - it.fail = err - return it.Next() - } -} - -// Error returns any retrieval or parsing error occurred during filtering. -func (it *AppRegistryWithdrawnIterator) Error() error { - return it.fail -} - -// Close terminates the iteration process, releasing any pending underlying -// resources. -func (it *AppRegistryWithdrawnIterator) Close() error { - it.sub.Unsubscribe() - return nil -} - -// AppRegistryWithdrawn represents a Withdrawn event raised by the AppRegistry contract. -type AppRegistryWithdrawn struct { - User common.Address - Balance *big.Int - Raw types.Log // Blockchain specific contextual infos -} - -// FilterWithdrawn is a free log retrieval operation binding the contract event 0x7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d5. -// -// Solidity: event Withdrawn(address indexed user, uint256 balance) -func (_AppRegistry *AppRegistryFilterer) FilterWithdrawn(opts *bind.FilterOpts, user []common.Address) (*AppRegistryWithdrawnIterator, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.FilterLogs(opts, "Withdrawn", userRule) - if err != nil { - return nil, err - } - return &AppRegistryWithdrawnIterator{contract: _AppRegistry.contract, event: "Withdrawn", logs: logs, sub: sub}, nil -} - -// WatchWithdrawn is a free log subscription operation binding the contract event 0x7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d5. -// -// Solidity: event Withdrawn(address indexed user, uint256 balance) -func (_AppRegistry *AppRegistryFilterer) WatchWithdrawn(opts *bind.WatchOpts, sink chan<- *AppRegistryWithdrawn, user []common.Address) (event.Subscription, error) { - - var userRule []interface{} - for _, userItem := range user { - userRule = append(userRule, userItem) - } - - logs, sub, err := _AppRegistry.contract.WatchLogs(opts, "Withdrawn", userRule) - if err != nil { - return nil, err - } - return event.NewSubscription(func(quit <-chan struct{}) error { - defer sub.Unsubscribe() - for { - select { - case log := <-logs: - // New log arrived, parse the event and forward to the user - event := new(AppRegistryWithdrawn) - if err := _AppRegistry.contract.UnpackLog(event, "Withdrawn", log); err != nil { - return err - } - event.Raw = log - - select { - case sink <- event: - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - case err := <-sub.Err(): - return err - case <-quit: - return nil - } - } - }), nil -} - -// ParseWithdrawn is a log parse operation binding the contract event 0x7084f5476618d8e60b11ef0d7d3f06914655adb8793e28ff7f018d4c76d505d5. -// -// Solidity: event Withdrawn(address indexed user, uint256 balance) -func (_AppRegistry *AppRegistryFilterer) ParseWithdrawn(log types.Log) (*AppRegistryWithdrawn, error) { - event := new(AppRegistryWithdrawn) - if err := _AppRegistry.contract.UnpackLog(event, "Withdrawn", log); err != nil { - return nil, err - } - event.Raw = log - return event, nil -} diff --git a/pkg/blockchain/evm/blockchain_client.go b/pkg/blockchain/evm/blockchain_client.go index db95dd31b..18154fa73 100644 --- a/pkg/blockchain/evm/blockchain_client.go +++ b/pkg/blockchain/evm/blockchain_client.go @@ -3,6 +3,7 @@ package evm import ( "context" "math/big" + "time" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -16,6 +17,13 @@ import ( "github.com/layer-3/nitrolite/pkg/sign" ) +// RpcCallTimeout bounds the wall-clock duration of a single EVM RPC call. +// Used by per-operation `context.WithTimeout` wrappers across the package +// (e.g. ChannelHub reads, startup chain-ID / contract-version checks). 5s +// stays well above realistic call latency on slow public RPCs while +// preventing a hung endpoint from blocking the caller indefinitely. +const RpcCallTimeout = 5 * time.Second + var _ core.BlockchainClient = &BlockchainClient{} type BlockchainClient struct { diff --git a/pkg/blockchain/evm/channel_hub_reactor.go b/pkg/blockchain/evm/channel_hub_reactor.go index e25296e25..06aa429c7 100644 --- a/pkg/blockchain/evm/channel_hub_reactor.go +++ b/pkg/blockchain/evm/channel_hub_reactor.go @@ -81,6 +81,13 @@ type ChannelHubReactorStore interface { // in tests. LockUserState(wallet, asset string) (decimal.Decimal, error) + // LockUserStateForHomeChannel locks the balance row of the user owning channelID and + // returns the channel read under that lock, deriving the lock key from the channel in + // SQL so no stale pre-lock snapshot is used. Event handlers must use this instead of a + // GetChannelByID + LockUserState pair, which reads channel status before the lock and + // races a concurrent submit_state finalization. Returns nil if the channel is absent. + LockUserStateForHomeChannel(channelID string) (*core.Channel, error) + // UpdateStateSigsIfMissing backfills the user and/or node signatures for a stored // state when the corresponding column is currently NULL. Either signature may be // empty to skip that side. @@ -90,7 +97,6 @@ type ChannelHubReactorStore interface { // home channel. HasSignedFinalize(channelID string) (bool, error) - // SumNetTransitionAmountAfterVersion returns the net effect on the user's // home-channel balance of transitions stored against channelID strictly above // minVersion. Receiver credits (TransferReceive, Release) contribute positively; @@ -106,6 +112,12 @@ type ChannelHubReactorStore interface { // StoreContractEvent persists a blockchain event to the database. StoreContractEvent(ev core.BlockchainEvent) error + + // IsContractEventProcessed reports whether an event identified by (txHash, logIndex, blockchainID) + // has already been committed, regardless of which block it appeared in. + // NOTE: uses block-level logIndex — does not detect reorged events where the same tx + // re-mines with a different block-level log position (see nitronode/docs/reorg-fix.md §6.6). + IsContractEventProcessed(txHash string, logIndex uint32, blockchainID uint64) (bool, error) } var channelHubAbi *abi.ABI @@ -142,17 +154,27 @@ type ChannelHubReactor struct { nodeAddress string eventHandler core.ChannelHubEventHandler assetStore AssetStore + store ChannelHubReactorStore // non-transactional; used for the pre-check in HandleEvent useStoreInTx ChannelHubReactorStoreTxProvider + channelHubReader core.ReadOnlyChannelHub onEventProcessed func(blockchainID uint64, success bool) } -func NewChannelHubReactor(blockchainID uint64, nodeAddress string, eventHandler core.ChannelHubEventHandler, assetStore AssetStore, useStoreInTx ChannelHubReactorStoreTxProvider) *ChannelHubReactor { +// NewChannelHubReactor wires a reactor for a single chain. store backs the +// non-transactional pre-check in HandleEvent (event-already-processed dedup +// gate). channelHubReader is the read-only ChannelHub view for this reactor's +// chain; it is threaded into the home-channel guard-drop handlers so they can +// converge the Node row with chain when a version-regression guard drops an +// event. +func NewChannelHubReactor(blockchainID uint64, nodeAddress string, eventHandler core.ChannelHubEventHandler, assetStore AssetStore, useStoreInTx ChannelHubReactorStoreTxProvider, store ChannelHubReactorStore, channelHubReader core.ReadOnlyChannelHub) *ChannelHubReactor { return &ChannelHubReactor{ - blockchainID: blockchainID, - nodeAddress: nodeAddress, - eventHandler: eventHandler, - assetStore: assetStore, - useStoreInTx: useStoreInTx, + blockchainID: blockchainID, + nodeAddress: nodeAddress, + eventHandler: eventHandler, + assetStore: assetStore, + store: store, + useStoreInTx: useStoreInTx, + channelHubReader: channelHubReader, } } @@ -161,7 +183,13 @@ func (r *ChannelHubReactor) SetOnEventProcessed(fn func(blockchainID uint64, suc r.onEventProcessed = fn } -func (r *ChannelHubReactor) HandleEvent(ctx context.Context, l types.Log) error { +func (r *ChannelHubReactor) HandleEvent(ctx context.Context, l types.Log) (err error) { + defer func() { + if r.onEventProcessed != nil { + r.onEventProcessed(r.blockchainID, err == nil) + } + }() + logger := log.FromContext(ctx) eventID := l.Topics[0] @@ -172,7 +200,22 @@ func (r *ChannelHubReactor) HandleEvent(ctx context.Context, l types.Log) error } logger.Debug("received event", "name", eventName, "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) - err := r.useStoreInTx(func(store ChannelHubReactorStore) error { + // Pre-check: skip already-committed events without opening a transaction. + // This converts the constraint-violation rollback path into a clean early exit and + // is required for the reconciliation walk (§4.4) to replay events without errors. + // Reorged events with a changed block-level logIndex pass through this check; + // they are handled by the reactor's business-logic idempotency (see nitronode/docs/reorg-fix.md §6.6). + processed, err := r.store.IsContractEventProcessed(l.TxHash.String(), uint32(l.Index), r.blockchainID) + if err != nil { + return errors.Wrap(err, "pre-check IsContractEventProcessed failed") + } + if processed { + logger.Info("skipping re-delivered event, already committed", + "event", eventName, "txHash", l.TxHash.String(), "logIndex", l.Index, "chainID", r.blockchainID) + return nil + } + + err = r.useStoreInTx(func(store ChannelHubReactorStore) error { var err error switch eventID { case channelHubAbi.Events["NodeBalanceUpdated"].ID: @@ -239,6 +282,7 @@ func (r *ChannelHubReactor) HandleEvent(ctx context.Context, l types.Log) error ContractAddress: l.Address.Hex(), TransactionHash: l.TxHash.String(), LogIndex: uint32(l.Index), + BlockHash: l.BlockHash.Hex(), }); err != nil { logger.Warn("error storing contract event", "error", err, "event", eventName, "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) return errors.Wrap(err, "error storing contract event") @@ -247,9 +291,6 @@ func (r *ChannelHubReactor) HandleEvent(ctx context.Context, l types.Log) error logger.Info("processed event", "event", eventName, "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) return nil }) - if r.onEventProcessed != nil { - r.onEventProcessed(r.blockchainID, err == nil) - } return err } @@ -261,11 +302,17 @@ func (r *ChannelHubReactor) handleNodeBalanceUpdated(ctx context.Context, store asset, err := r.assetStore.GetTokenAsset(r.blockchainID, event.Token.String()) if err != nil { + if r.skipIfUnsupportedToken(ctx, err, event.Token, l) { + return nil + } return errors.Wrap(err, "failed to get token asset") } decimals, err := r.assetStore.GetTokenDecimals(r.blockchainID, event.Token.String()) if err != nil { + if r.skipIfUnsupportedToken(ctx, err, event.Token, l) { + return nil + } return errors.Wrap(err, "failed to get token decimals") } @@ -279,6 +326,26 @@ func (r *ChannelHubReactor) handleNodeBalanceUpdated(ctx context.Context, store return r.eventHandler.HandleNodeBalanceUpdated(ctx, store, &ev) } +// skipIfUnsupportedToken reports whether a NodeBalanceUpdated lookup error is an +// unsupported-token error that should be skipped rather than treated as fatal. +// Anyone can deposit an arbitrary ERC20 via depositToNode(), so an event for an +// unconfigured token (or a chain with no configured tokens) must not stop the +// listener. The caller returns nil so HandleEvent records the event and it is +// not replayed. +// +// Note: this is a dedup record, not a replay queue. Once an event is recorded as +// skipped, the balance change is NOT re-applied if the operator later configures +// that token — a legitimate new asset requires a manual resync. +func (r *ChannelHubReactor) skipIfUnsupportedToken(ctx context.Context, err error, token common.Address, l types.Log) bool { + if !errors.Is(err, core.ErrTokenNotSupported) { + return false + } + log.FromContext(ctx).Warn("skipping NodeBalanceUpdated for unsupported token", + "token", token.String(), "blockchainID", r.blockchainID, + "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) + return true +} + func (r *ChannelHubReactor) handleHomeChannelCreated(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { logger := log.FromContext(ctx) @@ -325,7 +392,7 @@ func (r *ChannelHubReactor) handleHomeChannelCheckpointed(ctx context.Context, s StateVersion: event.Candidate.Version, UserSig: encodeSig(event.Candidate.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleChannelDeposited(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -339,7 +406,7 @@ func (r *ChannelHubReactor) handleChannelDeposited(ctx context.Context, store Ch StateVersion: event.Candidate.Version, UserSig: encodeSig(event.Candidate.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleChannelWithdrawn(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -353,7 +420,7 @@ func (r *ChannelHubReactor) handleChannelWithdrawn(ctx context.Context, store Ch StateVersion: event.Candidate.Version, UserSig: encodeSig(event.Candidate.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleHomeChannelChallenged(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -368,7 +435,7 @@ func (r *ChannelHubReactor) handleHomeChannelChallenged(ctx context.Context, sto ChallengeExpiry: event.ChallengeExpireAt, UserSig: encodeSig(event.Candidate.UserSig), } - return r.eventHandler.HandleHomeChannelChallenged(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelChallenged(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleHomeChannelClosed(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -382,7 +449,7 @@ func (r *ChannelHubReactor) handleHomeChannelClosed(ctx context.Context, store C StateVersion: event.FinalState.Version, UserSig: encodeSig(event.FinalState.UserSig), } - return r.eventHandler.HandleHomeChannelClosed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelClosed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleEscrowDepositInitiated(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -482,7 +549,7 @@ func (r *ChannelHubReactor) handleEscrowDepositInitiatedOnHome(ctx context.Conte StateVersion: event.State.Version, UserSig: encodeSig(event.State.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleEscrowDepositFinalizedOnHome(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -496,7 +563,7 @@ func (r *ChannelHubReactor) handleEscrowDepositFinalizedOnHome(ctx context.Conte StateVersion: event.State.Version, UserSig: encodeSig(event.State.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleEscrowWithdrawalInitiatedOnHome(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -510,7 +577,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalInitiatedOnHome(ctx context.Co StateVersion: event.State.Version, UserSig: encodeSig(event.State.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } func (r *ChannelHubReactor) handleEscrowWithdrawalFinalizedOnHome(ctx context.Context, store ChannelHubReactorStore, l types.Log) error { @@ -524,7 +591,7 @@ func (r *ChannelHubReactor) handleEscrowWithdrawalFinalizedOnHome(ctx context.Co StateVersion: event.State.Version, UserSig: encodeSig(event.State.UserSig), } - return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, &ev) + return r.eventHandler.HandleHomeChannelCheckpointed(ctx, store, r.channelHubReader, &ev) } // Additional event handlers for events not yet defined in core.BlockchainEventHandler diff --git a/pkg/blockchain/evm/channel_hub_reactor_test.go b/pkg/blockchain/evm/channel_hub_reactor_test.go index 818ade62e..95111e726 100644 --- a/pkg/blockchain/evm/channel_hub_reactor_test.go +++ b/pkg/blockchain/evm/channel_hub_reactor_test.go @@ -2,6 +2,7 @@ package evm import ( "context" + "fmt" "math/big" "testing" @@ -113,12 +114,19 @@ func (m *mockChannelHubStore) LockUserState(wallet, asset string) (decimal.Decim return args.Get(0).(decimal.Decimal), args.Error(1) } +func (m *mockChannelHubStore) LockUserStateForHomeChannel(channelID string) (*core.Channel, error) { + args := m.Called(channelID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*core.Channel), args.Error(1) +} + func (m *mockChannelHubStore) HasSignedFinalize(channelID string) (bool, error) { args := m.Called(channelID) return args.Bool(0), args.Error(1) } - func (m *mockChannelHubStore) StoreUserState(state core.State, applicationID string) error { args := m.Called(state, applicationID) return args.Error(0) @@ -129,6 +137,11 @@ func (m *mockChannelHubStore) RecordTransaction(tx core.Transaction, application return args.Error(0) } +func (m *mockChannelHubStore) IsContractEventProcessed(txHash string, logIndex uint32, blockchainID uint64) (bool, error) { + args := m.Called(txHash, logIndex, blockchainID) + return args.Bool(0), args.Error(1) +} + // mockChannelHubEventHandler captures events dispatched by the reactor. type mockChannelHubEventHandler struct { mock.Mock @@ -149,18 +162,18 @@ func (m *mockChannelHubEventHandler) HandleHomeChannelMigrated(ctx context.Conte return args.Error(0) } -func (m *mockChannelHubEventHandler) HandleHomeChannelCheckpointed(ctx context.Context, tx core.ChannelHubEventHandlerStore, ev *core.HomeChannelCheckpointedEvent) error { - args := m.Called(ctx, tx, ev) +func (m *mockChannelHubEventHandler) HandleHomeChannelCheckpointed(ctx context.Context, tx core.ChannelHubEventHandlerStore, hub core.ReadOnlyChannelHub, ev *core.HomeChannelCheckpointedEvent) error { + args := m.Called(ctx, tx, hub, ev) return args.Error(0) } -func (m *mockChannelHubEventHandler) HandleHomeChannelChallenged(ctx context.Context, tx core.ChannelHubEventHandlerStore, ev *core.HomeChannelChallengedEvent) error { - args := m.Called(ctx, tx, ev) +func (m *mockChannelHubEventHandler) HandleHomeChannelChallenged(ctx context.Context, tx core.ChannelHubEventHandlerStore, hub core.ReadOnlyChannelHub, ev *core.HomeChannelChallengedEvent) error { + args := m.Called(ctx, tx, hub, ev) return args.Error(0) } -func (m *mockChannelHubEventHandler) HandleHomeChannelClosed(ctx context.Context, tx core.ChannelHubEventHandlerStore, ev *core.HomeChannelClosedEvent) error { - args := m.Called(ctx, tx, ev) +func (m *mockChannelHubEventHandler) HandleHomeChannelClosed(ctx context.Context, tx core.ChannelHubEventHandlerStore, hub core.ReadOnlyChannelHub, ev *core.HomeChannelClosedEvent) error { + args := m.Called(ctx, tx, hub, ev) return args.Error(0) } @@ -240,12 +253,17 @@ func packNonIndexed(t *testing.T, eventName string, args ...interface{}) []byte return data } -// newReactor creates a ChannelHubReactor wired to the provided mocks. +// newReactor creates a ChannelHubReactor wired to the provided mocks. Sets up +// a default IsContractEventProcessed expectation that returns (false, nil) so +// existing tests don't need to set it up individually. The reactor's read-only +// ChannelHub view is nil here because the mock event handler never reads it — +// the unit tests in this file assert dispatch only. func newReactor(blockchainID uint64, nodeAddress string, handler *mockChannelHubEventHandler, assetStore *MockAssetStore, store *mockChannelHubStore) *ChannelHubReactor { + store.On("IsContractEventProcessed", mock.Anything, mock.Anything, mock.Anything).Return(false, nil) useStoreInTx := func(fn ChannelHubReactorStoreTxHandler) error { return fn(store) } - return NewChannelHubReactor(blockchainID, nodeAddress, handler, assetStore, useStoreInTx) + return NewChannelHubReactor(blockchainID, nodeAddress, handler, assetStore, useStoreInTx, store, nil) } // expectStoreContractEvent sets up the mock expectation for StoreContractEvent. @@ -318,6 +336,63 @@ func TestChannelHubReactor_HandleNodeBalanceUpdated(t *testing.T) { require.Error(t, err) assert.False(t, processedSuccess) }) + + t.Run("unsupported token is skipped and recorded", func(t *testing.T) { + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + // Unconfigured token: anyone can deposit an arbitrary ERC20 via + // depositToNode(). The event must not be fatal and must be recorded so + // it is not replayed on restart (MF3-H03). + assetStore.On("GetTokenAsset", blockchainID, tokenAddr.String()). + Return("", fmt.Errorf("token %s is not supported: %w", tokenAddr.String(), core.ErrTokenNotSupported)) + + // StoreContractEvent must still be called so the event is not replayed. + expectStoreContractEvent(store, "NodeBalanceUpdated", 200, blockchainID) + + reactor := newReactor(blockchainID, nodeAddr.String(), handler, assetStore, store) + + var processedSuccess bool + reactor.SetOnEventProcessed(func(_ uint64, success bool) { + processedSuccess = success + }) + + err := reactor.HandleEvent(context.Background(), logEntry) + require.NoError(t, err) + assert.True(t, processedSuccess) + // Balance update must not be applied for an unsupported token. + handler.AssertNotCalled(t, "HandleNodeBalanceUpdated", mock.Anything, mock.Anything, mock.Anything) + store.AssertExpectations(t) + }) + + // Mirror of the case above, but the unsupported-token error surfaces from the + // second lookup (GetTokenDecimals) rather than the first. Both guards must + // behave identically (MF3-H03), so cover them independently. + t.Run("unsupported token from decimals lookup is skipped and recorded", func(t *testing.T) { + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + assetStore.On("GetTokenAsset", blockchainID, tokenAddr.String()).Return("usdc", nil) + assetStore.On("GetTokenDecimals", blockchainID, tokenAddr.String()). + Return(uint8(0), fmt.Errorf("token %s is not supported: %w", tokenAddr.String(), core.ErrTokenNotSupported)) + + expectStoreContractEvent(store, "NodeBalanceUpdated", 200, blockchainID) + + reactor := newReactor(blockchainID, nodeAddr.String(), handler, assetStore, store) + + var processedSuccess bool + reactor.SetOnEventProcessed(func(_ uint64, success bool) { + processedSuccess = success + }) + + err := reactor.HandleEvent(context.Background(), logEntry) + require.NoError(t, err) + assert.True(t, processedSuccess) + handler.AssertNotCalled(t, "HandleNodeBalanceUpdated", mock.Anything, mock.Anything, mock.Anything) + store.AssertExpectations(t) + }) } func TestChannelHubReactor_HandleHomeChannelCreated(t *testing.T) { @@ -407,7 +482,7 @@ func TestChannelHubReactor_HandleHomeChannelCheckpointed(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 5 })).Return(nil) @@ -447,7 +522,7 @@ func TestChannelHubReactor_HandleHomeChannelCheckpointed_ForwardsUserSig(t *test handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.UserSig == hexutil.Encode([]byte{0xde, 0xad, 0xbe, 0xef}) })).Return(nil) @@ -484,7 +559,7 @@ func TestChannelHubReactor_HandleHomeChannelChallenged(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelChallenged", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelChallengedEvent) bool { + handler.On("HandleHomeChannelChallenged", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelChallengedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 4 && ev.ChallengeExpiry == challengeExpiry @@ -522,7 +597,7 @@ func TestChannelHubReactor_HandleHomeChannelClosed(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelClosed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelClosedEvent) bool { + handler.On("HandleHomeChannelClosed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelClosedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 10 })).Return(nil) @@ -559,7 +634,7 @@ func TestChannelHubReactor_HandleChannelDeposited(t *testing.T) { assetStore := new(MockAssetStore) // ChannelDeposited dispatches HandleHomeChannelCheckpointed - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 7 })).Return(nil) @@ -596,7 +671,7 @@ func TestChannelHubReactor_HandleChannelWithdrawn(t *testing.T) { assetStore := new(MockAssetStore) // ChannelWithdrawn dispatches HandleHomeChannelCheckpointed - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 8 })).Return(nil) @@ -865,7 +940,7 @@ func TestChannelHubReactor_HandleEscrowDepositInitiatedOnHome(t *testing.T) { assetStore := new(MockAssetStore) // Dispatches HandleHomeChannelCheckpointed with the channelId topic - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 3 })).Return(nil) @@ -903,7 +978,7 @@ func TestChannelHubReactor_HandleEscrowDepositFinalizedOnHome(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 6 })).Return(nil) @@ -941,7 +1016,7 @@ func TestChannelHubReactor_HandleEscrowWithdrawalInitiatedOnHome(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 4 })).Return(nil) @@ -979,7 +1054,7 @@ func TestChannelHubReactor_HandleEscrowWithdrawalFinalizedOnHome(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(ev *core.HomeChannelCheckpointedEvent) bool { return ev.ChannelID == hexutil.Encode(channelID[:]) && ev.StateVersion == 9 })).Return(nil) @@ -1032,6 +1107,72 @@ func TestChannelHubReactor_HandleEscrowDepositsPurged(t *testing.T) { store.AssertExpectations(t) } +func TestChannelHubReactor_HandleEvent_PreCheckError_ReturnsError(t *testing.T) { + blockchainID := uint64(1) + nodeAddr := "0x1111111111111111111111111111111111111111" + tokenAddr := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + amount := big.NewInt(1_000_000) + + logEntry := types.Log{ + Topics: []common.Hash{ + channelHubAbi.Events["NodeBalanceUpdated"].ID, + common.BytesToHash(tokenAddr.Bytes()), + }, + Data: packNonIndexed(t, "NodeBalanceUpdated", amount), + BlockNumber: 100, + TxHash: common.HexToHash("0xaabbcc"), + Index: 0, + } + + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + // Pre-check returns an error — reactor must return it immediately. + store.On("IsContractEventProcessed", mock.Anything, mock.Anything, mock.Anything).Return(false, assert.AnError) + + useStoreInTx := func(fn ChannelHubReactorStoreTxHandler) error { return fn(store) } + reactor := NewChannelHubReactor(blockchainID, nodeAddr, handler, assetStore, useStoreInTx, store, nil) + + err := reactor.HandleEvent(context.Background(), logEntry) + require.Error(t, err) + require.ErrorContains(t, err, "pre-check IsContractEventProcessed failed") + + // Neither business logic nor StoreContractEvent should be called. + handler.AssertNotCalled(t, "HandleNodeBalanceUpdated", mock.Anything, mock.Anything, mock.Anything) + store.AssertNotCalled(t, "StoreContractEvent", mock.Anything) +} + +func TestChannelHubReactor_HandleEvent_AlreadyProcessed(t *testing.T) { + blockchainID := uint64(1) + nodeAddr := "0x1111111111111111111111111111111111111111" + txHash := common.HexToHash("0xaabbcc") + + logEntry := types.Log{ + Topics: []common.Hash{channelHubAbi.Events["NodeBalanceUpdated"].ID}, + BlockNumber: 100, + TxHash: txHash, + Index: 0, + } + + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + // Pre-check returns true — event already committed. + store.On("IsContractEventProcessed", txHash.String(), uint32(0), blockchainID).Return(true, nil) + + useStoreInTx := func(fn ChannelHubReactorStoreTxHandler) error { return fn(store) } + reactor := NewChannelHubReactor(blockchainID, nodeAddr, handler, assetStore, useStoreInTx, store, nil) + + err := reactor.HandleEvent(context.Background(), logEntry) + require.NoError(t, err) + + // Neither business logic nor StoreContractEvent should be called. + handler.AssertNotCalled(t, "HandleNodeBalanceUpdated", mock.Anything, mock.Anything, mock.Anything) + store.AssertNotCalled(t, "StoreContractEvent", mock.Anything) +} + func TestChannelHubReactor_UnknownEvent(t *testing.T) { blockchainID := uint64(1) nodeAddr := "0x1111111111111111111111111111111111111111" @@ -1076,7 +1217,7 @@ func TestChannelHubReactor_OnEventProcessedCallback(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything).Return(nil) + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil) expectStoreContractEvent(store, "ChannelCheckpointed", 50, blockchainID) reactor := newReactor(blockchainID, nodeAddr, handler, assetStore, store) @@ -1099,7 +1240,7 @@ func TestChannelHubReactor_OnEventProcessedCallback(t *testing.T) { handler := new(mockChannelHubEventHandler) assetStore := new(MockAssetStore) - handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything).Return(assert.AnError) + handler.On("HandleHomeChannelCheckpointed", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(assert.AnError) reactor := newReactor(blockchainID, nodeAddr, handler, assetStore, store) @@ -1112,4 +1253,28 @@ func TestChannelHubReactor_OnEventProcessedCallback(t *testing.T) { require.Error(t, err) assert.False(t, cbSuccess) }) + + t.Run("callback receives false on pre-check error", func(t *testing.T) { + store := new(mockChannelHubStore) + handler := new(mockChannelHubEventHandler) + assetStore := new(MockAssetStore) + + // Pre-check returns an error — deferred callback must still fire with success=false. + store.On("IsContractEventProcessed", mock.Anything, mock.Anything, mock.Anything).Return(false, assert.AnError) + + useStoreInTx := func(fn ChannelHubReactorStoreTxHandler) error { return fn(store) } + reactor := NewChannelHubReactor(blockchainID, nodeAddr, handler, assetStore, useStoreInTx, store, nil) + + var cbCalled bool + var cbSuccess bool + reactor.SetOnEventProcessed(func(_ uint64, success bool) { + cbCalled = true + cbSuccess = success + }) + + err := reactor.HandleEvent(context.Background(), logEntry) + require.Error(t, err) + assert.True(t, cbCalled, "callback must be invoked on pre-check error") + assert.False(t, cbSuccess) + }) } diff --git a/pkg/blockchain/evm/channel_hub_reader.go b/pkg/blockchain/evm/channel_hub_reader.go new file mode 100644 index 000000000..4240914bd --- /dev/null +++ b/pkg/blockchain/evm/channel_hub_reader.go @@ -0,0 +1,94 @@ +package evm + +import ( + "context" + "fmt" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + + "github.com/layer-3/nitrolite/pkg/core" +) + +// On-chain ChannelStatus enum values (contracts/src/interfaces/Types.sol). +const ( + onchainChannelStatusVoid uint8 = 0 + onchainChannelStatusOperating uint8 = 1 + onchainChannelStatusDisputed uint8 = 2 + onchainChannelStatusClosed uint8 = 3 + onchainChannelStatusMigratingIn uint8 = 4 + onchainChannelStatusMigratedOut uint8 = 5 +) + +// EVMChannelHubReader implements core.ReadOnlyChannelHub by reading from a +// single chain's bound ChannelHub contract. Each ChannelHubReactor binds its +// own reader for the chain it listens on, so no chain-resolution dispatcher +// is required. +// +// The reader is read-only and stateless beyond the caller; it is safe for +// concurrent use from multiple reactor handler goroutines. +type EVMChannelHubReader struct { + caller *ChannelHubCaller +} + +// NewChannelHubReader constructs a reader backed by the supplied bound +// ChannelHub caller. The caller must be non-nil; passing nil panics on the +// first FetchChannel invocation rather than at construction. +func NewChannelHubReader(caller *ChannelHubCaller) *EVMChannelHubReader { + return &EVMChannelHubReader{caller: caller} +} + +// FetchChannel reads the authoritative on-chain snapshot for channelID from +// the ChannelHub contract bound to this reader and returns it for the caller +// to overwrite the local row. See core.ReadOnlyChannelHub for semantics. +func (r *EVMChannelHubReader) FetchChannel(ctx context.Context, channelID string) (*core.OnChainChannelSnapshot, error) { + channelIDBytes, err := hexToBytes32(channelID) + if err != nil { + return nil, fmt.Errorf("invalid channel ID %q: %w", channelID, err) + } + + ctx, cancel := context.WithTimeout(ctx, RpcCallTimeout) + defer cancel() + + data, err := r.caller.GetChannelData(&bind.CallOpts{Context: ctx}, channelIDBytes) + if err != nil { + return nil, fmt.Errorf("getChannelData(%s): %w", channelID, err) + } + + status, err := mapOnchainChannelStatus(data.Status) + if err != nil { + return nil, fmt.Errorf("map on-chain status for channel %s: %w", channelID, err) + } + + var expiry *time.Time + if data.ChallengeExpiry != nil && data.ChallengeExpiry.Sign() > 0 { + t := time.Unix(data.ChallengeExpiry.Int64(), 0) + expiry = &t + } + + return &core.OnChainChannelSnapshot{ + Status: status, + StateVersion: data.LastState.Version, + ChallengeExpiresAt: expiry, + LastStateUserSig: encodeSig(data.LastState.UserSig), + }, nil +} + +// mapOnchainChannelStatus translates the contract's ChannelStatus enum to the +// off-chain core.ChannelStatus. MIGRATING_IN is treated as Open because the +// channel is live from this hub's perspective. MIGRATED_OUT is treated as +// Closed because no further transitions can land on this hub. +func mapOnchainChannelStatus(s uint8) (core.ChannelStatus, error) { + switch s { + case onchainChannelStatusVoid: + return core.ChannelStatusVoid, nil + case onchainChannelStatusOperating, onchainChannelStatusMigratingIn: + return core.ChannelStatusOpen, nil + case onchainChannelStatusDisputed: + return core.ChannelStatusChallenged, nil + case onchainChannelStatusClosed, onchainChannelStatusMigratedOut: + return core.ChannelStatusClosed, nil + default: + return 0, fmt.Errorf("unknown on-chain ChannelStatus %d", s) + } +} diff --git a/pkg/blockchain/evm/confirmation_gate.go b/pkg/blockchain/evm/confirmation_gate.go new file mode 100644 index 000000000..a8fe7bf6e --- /dev/null +++ b/pkg/blockchain/evm/confirmation_gate.go @@ -0,0 +1,331 @@ +package evm + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/layer-3/nitrolite/pkg/log" +) + +// recentMultiplier controls how long forwardedSet entries are retained: +// (recentMultiplier × delay). This is the window during which a post-gate +// Removed:true can be matched against a previously forwarded event and emit +// the post-gate reorg WARN. +const recentMultiplier = 3 + +// queueEntry holds a pending event waiting for the confirmation delay to expire. +type queueEntry struct { + log types.Log + arrivedAt time.Time // derived from eventLog.BlockTimestamp; fallback time.Now() when zero +} + +// eventKey identifies an event by tx and log index; blockHash is intentionally excluded +// so that a reorg-replacement event (same tx, same index, different block) can match +// and cancel the original pending entry. +type eventKey struct { + txHash common.Hash + logIndex uint +} + +// forwardedKey identifies an event that has already been forwarded to the downstream +// handler; blockHash is included so a Removed notification from a different block fork +// does NOT falsely trigger post-gate reorg logic. +type forwardedKey struct { + txHash common.Hash + blockHash common.Hash + logIndex uint +} + +// forwardedExpiry pairs a forwardedKey with the wall-clock time at which the event +// was forwarded, for O(1) FIFO eviction from forwardedSet. +type forwardedExpiry struct { + key forwardedKey + forwardedAt time.Time +} + +// ConfirmationGate buffers incoming events for a configurable delay before forwarding +// them to a downstream handler, providing a window to cancel events that are reorged +// out before the delay expires. +// +// The gate is pure in-memory: it reads arrival time from eventLog.BlockTimestamp and +// performs no RPC. The caller (Listener) is responsible for ensuring BlockTimestamp +// is populated before invoking HandleEvent. +type ConfirmationGate struct { + delay time.Duration + chainID uint64 + handler HandleEvent + logger log.Logger + + mu sync.Mutex + queue []queueEntry // append-tail, pop-head + pending map[eventKey]common.Hash // live (txHash, logIndex) -> blockHash; source of truth for live entries + forwardedSet map[forwardedKey]time.Time // key -> forwardedAt + forwardedQueue []forwardedExpiry // FIFO of (key, forwardedAt) for O(1) eviction + + kick chan struct{} // buffered 1; non-blocking sends + timer *time.Timer // created in Start(ctx) +} + +// NewConfirmationGate creates a ConfirmationGate that holds events for delay before +// forwarding them to handler. delay must be > 0; delay <= 0 returns an error +// (the wiring layer is responsible for skipping gate construction when the operator +// configured delay == 0). +func NewConfirmationGate( + delay time.Duration, + chainID uint64, + handler HandleEvent, + logger log.Logger, +) (*ConfirmationGate, error) { + if delay <= 0 { + return nil, errors.New("confirmation gate requires delay > 0") + } + return &ConfirmationGate{ + delay: delay, + chainID: chainID, + handler: handler, + logger: logger.WithName("confirmation-gate"), + pending: make(map[eventKey]common.Hash), + forwardedSet: make(map[forwardedKey]time.Time), + forwardedQueue: nil, + kick: make(chan struct{}, 1), + }, nil +} + +// Start begins the background goroutine that forwards matured entries to the +// downstream handler. handleClosure is called exactly once after the goroutine +// exits; err is non-nil only when the downstream handler returned an error +// after the confirmation delay. The timer is created here (tied to the +// goroutine's lifecycle) and stopped on shutdown. +func (g *ConfirmationGate) Start(ctx context.Context, handleClosure func(err error)) { + g.timer = time.NewTimer(time.Hour) // arbitrary long initial; reset on first drain + if !g.timer.Stop() { + <-g.timer.C + } + + childCtx, cancel := context.WithCancel(ctx) + wg := sync.WaitGroup{} + wg.Add(1) + + var closureErr error + var closureErrMu sync.Mutex + childHandleClosure := func(err error) { + closureErrMu.Lock() + defer closureErrMu.Unlock() + if err != nil && closureErr == nil { + closureErr = err + } + cancel() + wg.Done() + } + + go func() { childHandleClosure(g.run(childCtx)) }() + + go func() { + wg.Wait() + closureErrMu.Lock() + defer closureErrMu.Unlock() + handleClosure(closureErr) + }() +} + +// HandleEvent is the entry point called by the upstream Listener for each event. +// +// A non-removed event is queued and will be forwarded after the confirmation delay. +// A removed event cancels its pending queue entry (pre-gate reorg) or — if the entry +// was already forwarded — records a post-gate reorg warning. +func (g *ConfirmationGate) HandleEvent(_ context.Context, eventLog types.Log) error { + ek := eventKey{txHash: eventLog.TxHash, logIndex: uint(eventLog.Index)} + + if !eventLog.Removed { + // Derive arrival time from the event's block timestamp. The listener + // guarantees this is non-zero in steady state; the fallback is + // defense-in-depth for tests/edge cases. No log here — the listener + // owns the warning when it cannot ensure the timestamp. + var ts time.Time + if eventLog.BlockTimestamp != 0 { + ts = time.Unix(int64(eventLog.BlockTimestamp), 0) + } else { + ts = time.Now() + } + + g.mu.Lock() + g.pending[ek] = eventLog.BlockHash + g.queue = append(g.queue, queueEntry{log: eventLog, arrivedAt: ts}) + g.mu.Unlock() + + // Non-blocking kick so the poller wakes up to (re)compute the timer + // even when it is currently sleeping on a far-future deadline. + select { + case g.kick <- struct{}{}: + default: + } + return nil + } + + // eventLog.Removed == true: attempt pre-gate or post-gate cancellation. + fk := forwardedKey{txHash: eventLog.TxHash, blockHash: eventLog.BlockHash, logIndex: uint(eventLog.Index)} + + g.mu.Lock() + defer g.mu.Unlock() + + // Pre-gate cancel: the live pending entry corresponds to this block. + // Delete from pending; the tombstoned queue entry is skipped on pop. + if liveBlockHash, ok := g.pending[ek]; ok && liveBlockHash == eventLog.BlockHash { + delete(g.pending, ek) + return nil + } + + // Post-gate: the event has already been forwarded. + if _, ok := g.forwardedSet[fk]; ok { + g.logger.Warn("post-gate reorg detected", + "txHash", eventLog.TxHash.Hex(), + "blockHash", eventLog.BlockHash.Hex(), + "logIndex", eventLog.Index, + "chainID", g.chainID, + ) + // Delete from the membership map; leave the forwardedQueue entry in + // place — it expires on its own. The eviction loop's value-check makes + // the later delete safe even if the same key is forwarded again. + delete(g.forwardedSet, fk) + return nil + } + + g.logger.Debug("removal for unknown/stale event", + "txHash", eventLog.TxHash.Hex(), + "blockHash", eventLog.BlockHash.Hex(), + "logIndex", eventLog.Index, + "chainID", g.chainID, + ) + return nil +} + +// FlushPending clears all pending queue entries and the pending tombstone map. +// It does NOT clear forwardedSet — post-gate WARN observability is preserved across +// reconnects. Retaining forwardedSet is intentional and load-bearing: the +// storedAt.Equal(popped.forwardedAt) guard at confirmation_gate.go:281-288 relies +// on forwardedSet membership to safely skip stale FIFO eviction entries after a +// re-forward. Clearing forwardedSet here would break that guard for entries that +// were forwarded before the flush. +// +// The drain goroutine continues to run after FlushPending returns. If it is +// mid-handler when FlushPending is called, the flush blocks on g.mu until the +// handler returns — the in-flight event is already committed to the DB (or will be +// on the reactor's success path) before the flush observes the cleared state. +// +// Safe to call at any time after NewConfirmationGate (zero-value-safe even before +// Start, since pending and queue are both nil/empty maps/slices by construction). +func (g *ConfirmationGate) FlushPending() { + g.mu.Lock() + defer g.mu.Unlock() + g.queue = nil + g.pending = make(map[eventKey]common.Hash) + // forwardedSet and forwardedQueue intentionally retained. + // timer is left alone; drainAndReschedule's next pass will see len(queue)==0 + // and leave the timer stopped (the "leave the timer stopped; the next kick + // recomputes" branch at the end of Step 3 in drainAndReschedule). +} + +// run is the background goroutine that wakes on a kick, on the timer firing, or on +// ctx cancellation. It forwards matured entries, evicts stale forwardedSet entries, +// and reschedules the timer for the next head deadline. Returns a non-nil error if +// the downstream handler failed; returns nil on clean shutdown. +func (g *ConfirmationGate) run(ctx context.Context) error { + defer g.timer.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-g.kick: + case <-g.timer.C: + } + if err := g.drainAndReschedule(); err != nil { + return err + } + } +} + +// drainAndReschedule forwards all queue entries whose confirmation delay has +// elapsed, evicts forwardedSet entries older than (recentMultiplier × delay), +// and resets the timer to the next head deadline. Returns a non-nil error if the +// downstream handler failed; the caller (run) propagates it to the lifecycle closure. +func (g *ConfirmationGate) drainAndReschedule() error { + g.mu.Lock() + now := time.Now() + + // Step 1: drain matured head entries. + for len(g.queue) > 0 && !g.queue[0].arrivedAt.Add(g.delay).After(now) { + entry := g.queue[0] + g.queue = g.queue[1:] + + ek := eventKey{txHash: entry.log.TxHash, logIndex: uint(entry.log.Index)} + + // Tombstone check: if the live pending entry no longer points at this + // blockHash, a reorg-replacement event has superseded it. Drop silently. + // Do NOT touch pending[ek] — it refers to the new live event (still in + // the queue) and deleting it would break the next tombstone check or the + // next Removed cancel. + liveBlockHash, ok := g.pending[ek] + if !ok || liveBlockHash != entry.log.BlockHash { + continue + } + + // Forward: clear pending, insert into forwardedSet + forwardedQueue + // BEFORE releasing mu so that a fast Removed:true arriving immediately + // after the handler call still sees the entry and emits the post-gate WARN. + delete(g.pending, ek) + fk := forwardedKey{ + txHash: entry.log.TxHash, + blockHash: entry.log.BlockHash, + logIndex: uint(entry.log.Index), + } + g.forwardedSet[fk] = now + g.forwardedQueue = append(g.forwardedQueue, forwardedExpiry{key: fk, forwardedAt: now}) + + g.mu.Unlock() + + evCtx := log.SetContextLogger(context.Background(), g.logger) + if err := g.handler(evCtx, entry.log); err != nil { + g.logger.Error("handler error after confirmation delay, stopping gate", + "error", err, + "chainID", g.chainID, + ) + return err // mu already released before the handler call; no relock needed. + } + + g.mu.Lock() + } + + // Step 2: FIFO eviction of forwardedSet entries older than recentMultiplier × delay. + for len(g.forwardedQueue) > 0 && now.Sub(g.forwardedQueue[0].forwardedAt) > recentMultiplier*g.delay { + popped := g.forwardedQueue[0] + g.forwardedQueue = g.forwardedQueue[1:] + + // Only delete from forwardedSet if the stored timestamp still equals + // the popped entry's timestamp. This guards the rare re-forward case + // (same key forwarded again after a chain un-reorg) so the older FIFO + // entry does not evict newer set membership. Tolerates the §2.4 Removed + // path having already deleted the entry (no-op). + if storedAt, ok := g.forwardedSet[popped.key]; ok && storedAt.Equal(popped.forwardedAt) { + delete(g.forwardedSet, popped.key) + } + } + + // Step 3: reset timer to next head deadline using the standard drain pattern. + if !g.timer.Stop() { + select { + case <-g.timer.C: + default: + } + } + if len(g.queue) > 0 { + g.timer.Reset(time.Until(g.queue[0].arrivedAt.Add(g.delay))) + } + // else: leave the timer stopped; the next kick recomputes. + + g.mu.Unlock() + return nil +} diff --git a/pkg/blockchain/evm/confirmation_gate_test.go b/pkg/blockchain/evm/confirmation_gate_test.go new file mode 100644 index 000000000..4aecbfaab --- /dev/null +++ b/pkg/blockchain/evm/confirmation_gate_test.go @@ -0,0 +1,1011 @@ +package evm + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/layer-3/nitrolite/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// helpers + +// makeLog builds a types.Log with BlockTimestamp == 0. The gate then derives +// arrivedAt from time.Now() at HandleEvent time, which gives sub-second +// resolution. This is the appropriate helper for tests that use millisecond-scale +// delays — BlockTimestamp itself is unix-seconds and would round-trip-truncate +// any timestamp set by the test, causing arrivedAt to land up to 1s in the past +// and the entry to mature immediately. +// +// Tests that explicitly exercise the BlockTimestamp-driven arrival path use +// makeLogAt instead and pick durations large enough to tolerate second-resolution +// truncation. +func makeLog(txHash common.Hash, blockHash common.Hash, logIndex uint, removed bool) types.Log { + return types.Log{ + TxHash: txHash, + BlockHash: blockHash, + Index: uint(logIndex), + Removed: removed, + } +} + +// makeLogAt builds a non-removed types.Log whose BlockTimestamp is set to the +// supplied wall-clock time. Used for tests that want the gate to derive +// arrivedAt from a specific moment in the past — must be paired with delays +// large enough (≥1s recommended) to tolerate seconds-resolution truncation of +// BlockTimestamp. +func makeLogAt(txHash common.Hash, blockHash common.Hash, logIndex uint, removed bool, ts time.Time) types.Log { + return types.Log{ + TxHash: txHash, + BlockHash: blockHash, + Index: uint(logIndex), + Removed: removed, + BlockTimestamp: uint64(ts.Unix()), + } +} + +func newGate(t *testing.T, delay time.Duration, handler HandleEvent) *ConfirmationGate { + t.Helper() + g, err := NewConfirmationGate(delay, 1, handler, log.NewNoopLogger()) + require.NoError(t, err) + return g +} + +// T0: constructor rejects non-positive delay (operator-facing delay==0 is handled +// by wiring in main.go which skips constructing the gate). +func TestConfirmationGate_Constructor_RejectsNonPositiveDelay(t *testing.T) { + t.Parallel() + + handler := func(_ context.Context, _ types.Log) error { return nil } + + g, err := NewConfirmationGate(0, 1, handler, log.NewNoopLogger()) + require.Error(t, err) + assert.Nil(t, g) + + g, err = NewConfirmationGate(-1*time.Second, 1, handler, log.NewNoopLogger()) + require.Error(t, err) + assert.Nil(t, g) +} + +// T2: normal event is queued and delivered after the delay. +func TestConfirmationGate_NormalPath(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + var deliveredLog types.Log + var mu sync.Mutex + + handler := func(_ context.Context, l types.Log) error { + mu.Lock() + deliveredLog = l + mu.Unlock() + callCount.Add(1) + return nil + } + + g := newGate(t, 5*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x02") + bh := common.HexToHash("0xBB") + ev := makeLog(tx, bh, 0, false) + + require.NoError(t, g.HandleEvent(context.Background(), ev)) + + // should NOT be called within 1 ms + time.Sleep(1 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load(), "handler must not be called before delay expires") + + // should be called within 500 ms total + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + + assert.Equal(t, int32(1), callCount.Load()) + mu.Lock() + assert.Equal(t, ev.TxHash, deliveredLog.TxHash) + assert.Equal(t, ev.Index, deliveredLog.Index) + mu.Unlock() +} + +// T3: a Removed event for a queued entry cancels it before forwarding. +func TestConfirmationGate_ReorgCancel(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 10*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x03") + bh := common.HexToHash("0xCC") + + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, true))) + + time.Sleep(20 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load(), "handler must never be called after reorg cancel") +} + +// T4: a re-delivered event (same tx/logIndex, different blockHash) replaces the original +// in the pending map; the late-arriving Removed for the old blockHash is a no-op (live +// pending hash no longer matches); the new event is forwarded once. +func TestConfirmationGate_OutOfOrder(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 10*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x04") + bhOld := common.HexToHash("0xAA") + bhNew := common.HexToHash("0xBB") + + // Event A: original block — queued under (tx, 0). + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bhOld, 0, false))) + // Event B: re-mined in new block — replaces pending[ek] = bhNew. The queued A entry + // becomes a tombstone (its blockHash no longer matches pending[ek]). + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bhNew, 0, false))) + // Removed for old block: pending[ek] is bhNew, not bhOld; no forwarded entry yet; + // no-op (debug log). + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bhOld, 0, true))) + + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout — event B was not forwarded") + default: + time.Sleep(5 * time.Millisecond) + } + } + + // Only B should have been forwarded (A was tombstoned and silently dropped). + assert.Equal(t, int32(1), callCount.Load()) +} + +// T5: post-gate reorg — Removed arrives after the event was already forwarded. +// Verify handler is called, Removed is handled gracefully (no panic/error). +func TestConfirmationGate_PostGateReorg(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 2*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x05") + bh := common.HexToHash("0xDD") + ev := makeLog(tx, bh, 0, false) + + require.NoError(t, g.HandleEvent(context.Background(), ev)) + + // Wait until forwarded. + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + assert.Equal(t, int32(1), callCount.Load()) + + // Post-gate Removed — should not panic or return error. + // WARN log "post-gate reorg detected" is emitted internally (manually observable). + err := g.HandleEvent(context.Background(), makeLog(tx, bh, 0, true)) + assert.NoError(t, err) + + // Handler should still have been called exactly once. + assert.Equal(t, int32(1), callCount.Load()) +} + +// T6: Removed for a completely unknown event — no error, no handler call. +func TestConfirmationGate_UnknownRemoval(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 10*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x06") + bh := common.HexToHash("0xEE") + + err := g.HandleEvent(context.Background(), makeLog(tx, bh, 0, true)) + assert.NoError(t, err) + + time.Sleep(20 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load()) +} + +// T7: BlockTimestamp far in the past → event is immediately mature and forwarded fast. +func TestConfirmationGate_BlockTimestampBypass(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 10*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x07") + bh := common.HexToHash("0xFF") + + // Block timestamp 30 seconds ago — arrivedAt + 10ms is far in the past, so the + // entry is matured the moment the drain loop runs. + require.NoError(t, g.HandleEvent(context.Background(), makeLogAt(tx, bh, 0, false, time.Now().Add(-30*time.Second)))) + + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + assert.Equal(t, int32(1), callCount.Load()) +} + +// T8: partial elapsed delay — BlockTimestamp 2 seconds in the past with delay=5s. +// +// Because BlockTimestamp is unix-seconds, the .Unix() conversion floors to the +// nearest whole second. In the worst case the gate sees arrivedAt up to 1s +// further in the past than the wall-clock target — so the actual remaining +// delay is in [2s, 3s]. Sleeping 500ms is safely inside that "not yet" window +// regardless of where the subsecond boundary landed. +func TestConfirmationGate_BlockTimestampPartialDelay(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 5*time.Second, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x08") + bh := common.HexToHash("0x08") + + require.NoError(t, g.HandleEvent(context.Background(), makeLogAt(tx, bh, 0, false, time.Now().Add(-2*time.Second)))) + + // Not called after 500 ms (worst-case remaining is ≥2s). + time.Sleep(500 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load(), "handler must not be called before remaining delay expires") + + // Called within 7s total. + deadline := time.After(7 * time.Second) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(50 * time.Millisecond) + } + } + assert.Equal(t, int32(1), callCount.Load()) +} + +// T9 (reframed): BlockTimestamp == 0 falls back to time.Now() — the full delay +// must elapse. No log is emitted from the gate side (the listener owns any WARN +// for a missing timestamp). +func TestConfirmationGate_BlockTimestampZeroFallback(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 10*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x09") + bh := common.HexToHash("0x09") + + // makeLog produces BlockTimestamp == 0 → gate falls back to time.Now(). + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + + // Not called immediately (fell back to current time, full delay required). + time.Sleep(1 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load(), "handler must not be called before delay expires") + + // Called within 500 ms. + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + assert.Equal(t, int32(1), callCount.Load()) +} + +// T11: cancelling the context prevents queued events from being forwarded. +func TestConfirmationGate_Shutdown(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 50*time.Millisecond, handler) + ctx, cancel := context.WithCancel(t.Context()) + g.Start(ctx, func(error) {}) + + for i := range 3 { + tx := common.HexToHash(string(rune(0x20 + i))) + bh := common.HexToHash(string(rune(0x30 + i))) + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, uint(i), false))) + } + + // Cancel before delay expires. + cancel() + + time.Sleep(100 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load(), "no events must be forwarded after context cancellation") +} + +// T12: forwardedSet entries are evicted after recentMultiplier × delay. +// Behavior under test: after eviction, a Removed for the same (tx, blockHash, idx) +// must fall through to the DEBUG path — no panic, no error. +func TestConfirmationGate_ForwardedSetEviction(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + delay := 5 * time.Millisecond + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x12") + bh := common.HexToHash("0x12") + + // Enqueue and wait for forward. + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + + // At this point forwardedSet contains the entry. + g.mu.Lock() + _, present := g.forwardedSet[forwardedKey{txHash: tx, blockHash: bh, logIndex: 0}] + g.mu.Unlock() + assert.True(t, present, "forwardedSet must contain the entry immediately after forwarding") + + // Wait well past recentMultiplier × delay, then enqueue another event to trigger + // the eviction path inside drainAndReschedule. + time.Sleep(time.Duration(recentMultiplier+1) * delay) + + tx2 := common.HexToHash("0x13") + bh2 := common.HexToHash("0x13") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx2, bh2, 0, false))) + + // Wait for tx2 to forward; the eviction loop also runs. + deadline = time.After(500 * time.Millisecond) + for callCount.Load() < 2 { + select { + case <-deadline: + t.Fatal("second handler invocation timed out") + default: + time.Sleep(1 * time.Millisecond) + } + } + + g.mu.Lock() + _, presentAfter := g.forwardedSet[forwardedKey{txHash: tx, blockHash: bh, logIndex: 0}] + g.mu.Unlock() + assert.False(t, presentAfter, "old forwardedSet entry must be evicted after recentMultiplier × delay") + + // A second Removed for the original event — falls through to DEBUG (not found). + // No panic, no error. + err := g.HandleEvent(context.Background(), makeLog(tx, bh, 0, true)) + assert.NoError(t, err) +} + +// T13: multiple events are all delivered, preserving queue order. +func TestConfirmationGate_MultipleEvents_Ordering(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var delivered []common.Hash + + handler := func(_ context.Context, l types.Log) error { + mu.Lock() + delivered = append(delivered, l.TxHash) + mu.Unlock() + return nil + } + + g := newGate(t, 5*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + txHashes := []common.Hash{ + common.HexToHash("0xA1"), + common.HexToHash("0xA2"), + common.HexToHash("0xA3"), + } + bh := common.HexToHash("0xBLOCK") + + for i, tx := range txHashes { + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, uint(i), false))) + } + + // Wait for all 3 events to be delivered. + deadline := time.After(500 * time.Millisecond) + for { + mu.Lock() + n := len(delivered) + mu.Unlock() + if n >= 3 { + break + } + select { + case <-deadline: + t.Fatalf("only %d/3 events delivered within timeout", n) + default: + time.Sleep(1 * time.Millisecond) + } + } + + mu.Lock() + defer mu.Unlock() + require.Len(t, delivered, 3) + assert.Equal(t, txHashes[0], delivered[0]) + assert.Equal(t, txHashes[1], delivered[1]) + assert.Equal(t, txHashes[2], delivered[2]) +} + +// New: tombstone-skip — a non-removed re-add with a different blockHash supersedes +// the queued entry. When the original entry's deadline arrives, the gate notices +// the tombstone (pending[ek] != entry.log.BlockHash) and silently drops it. +// Only the new entry's forward is observed. +func TestConfirmationGate_TombstoneSkip(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var delivered []common.Hash // blockHashes seen by handler + + handler := func(_ context.Context, l types.Log) error { + mu.Lock() + delivered = append(delivered, l.BlockHash) + mu.Unlock() + return nil + } + + delay := 30 * time.Millisecond + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x20") + bhA := common.HexToHash("0xAAA") + bhB := common.HexToHash("0xBBB") + + // Enqueue event for blockHashA. + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bhA, 0, false))) + // Before the delay elapses, send a non-removed re-add with blockHashB — same (tx, idx). + // The gate replaces pending[ek] = bhB and appends a new queue entry; the bhA entry + // becomes a tombstone. + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bhB, 0, false))) + + // Wait past the delay. + deadline := time.After(500 * time.Millisecond) + for { + mu.Lock() + n := len(delivered) + mu.Unlock() + if n >= 1 { + break + } + select { + case <-deadline: + t.Fatal("handler not called within timeout — event B was not forwarded") + default: + time.Sleep(2 * time.Millisecond) + } + } + + // Allow extra time to ensure bhA does not slip through later (it shouldn't — + // it's tombstoned and dropped silently on pop). + time.Sleep(50 * time.Millisecond) + + mu.Lock() + defer mu.Unlock() + require.Len(t, delivered, 1, "exactly one forward expected (the bhB entry)") + assert.Equal(t, bhB, delivered[0], "the bhB entry must be the one forwarded") +} + +// New: FIFO eviction with early-delete tolerance. After forwarding, a Removed:true +// arrives and removes the forwardedSet entry while emitting the post-gate WARN. +// Later, the FIFO eviction loop pops the corresponding forwardedQueue entry — the +// set entry is already gone. The eviction must not panic and must not double-invoke +// the handler. +func TestConfirmationGate_FIFOEviction_ToleratesEarlyDelete(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + delay := 5 * time.Millisecond + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x30") + bh := common.HexToHash("0x30") + + // Forward an event. + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + + // Confirm the forwardedSet entry exists. + fk := forwardedKey{txHash: tx, blockHash: bh, logIndex: 0} + g.mu.Lock() + _, presentBefore := g.forwardedSet[fk] + queueLen := len(g.forwardedQueue) + g.mu.Unlock() + require.True(t, presentBefore, "forwardedSet must contain the entry immediately after forwarding") + require.Equal(t, 1, queueLen, "forwardedQueue must contain one entry") + + // Send Removed:true — gate emits post-gate WARN and deletes the entry from forwardedSet + // (but leaves the forwardedQueue entry in place; it will expire on its own). + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, true))) + + g.mu.Lock() + _, presentAfterRemoved := g.forwardedSet[fk] + g.mu.Unlock() + require.False(t, presentAfterRemoved, "forwardedSet entry must be deleted by the post-gate WARN path") + + // Wait well past recentMultiplier × delay, then kick the drain loop with a new event + // so eviction runs and pops the orphaned forwardedQueue entry. + time.Sleep(time.Duration(recentMultiplier+1) * delay) + + tx2 := common.HexToHash("0x31") + bh2 := common.HexToHash("0x31") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx2, bh2, 0, false))) + + // Wait for the second forward. + deadline = time.After(500 * time.Millisecond) + for callCount.Load() < 2 { + select { + case <-deadline: + t.Fatal("second handler invocation timed out") + default: + time.Sleep(1 * time.Millisecond) + } + } + + // Handler called exactly twice (once per forward; no double-action from eviction). + assert.Equal(t, int32(2), callCount.Load()) + + // The orphaned forwardedQueue entry must have been popped during eviction. + g.mu.Lock() + // After tx2 is forwarded, the queue should have exactly one entry (tx2's). + finalQueueLen := len(g.forwardedQueue) + g.mu.Unlock() + assert.Equal(t, 1, finalQueueLen, "orphan forwardedQueue entry must have been evicted") +} + +// New: timer reschedule — enqueue a single event and do NOT send any further kicks. +// The handler must be invoked when the timer fires. +func TestConfirmationGate_TimerReschedule(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + return nil + } + + g := newGate(t, 20*time.Millisecond, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0x40") + bh := common.HexToHash("0x40") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + + // No further HandleEvent calls. Wait for the timer to fire. + deadline := time.After(500 * time.Millisecond) + for callCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called via timer fire alone") + default: + time.Sleep(2 * time.Millisecond) + } + } + assert.Equal(t, int32(1), callCount.Load()) +} + +// New: kick during a pending timer must NOT extend the original timer's deadline. +// Event A is enqueued first (timer arms for A's deadline). Before A matures we +// enqueue B with a LATER BlockTimestamp. A must still fire at its original +// deadline; the kick rescheduled the timer to A's head deadline (unchanged). +func TestConfirmationGate_KickDuringPendingTimer(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var deliveredOrder []common.Hash + firstFiredAt := make(chan time.Time, 1) + + handler := func(_ context.Context, l types.Log) error { + mu.Lock() + deliveredOrder = append(deliveredOrder, l.TxHash) + isFirst := len(deliveredOrder) == 1 + mu.Unlock() + if isFirst { + select { + case firstFiredAt <- time.Now(): + default: + } + } + return nil + } + + delay := 100 * time.Millisecond + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + txA := common.HexToHash("0x50") + bhA := common.HexToHash("0x50") + txB := common.HexToHash("0x51") + bhB := common.HexToHash("0x51") + + // Event A: BlockTimestamp == 0 → gate uses time.Now() at HandleEvent time as arrivedAt. + enqueueA := time.Now() + require.NoError(t, g.HandleEvent(context.Background(), makeLog(txA, bhA, 0, false))) + + // Brief sleep, then enqueue B. The kick wakes the drain loop; A is not yet + // mature; the timer must be reset to A's deadline. B's deadline is later than + // A's because its arrivedAt is later (HandleEvent uses time.Now() when + // BlockTimestamp == 0). + time.Sleep(20 * time.Millisecond) + require.NoError(t, g.HandleEvent(context.Background(), makeLog(txB, bhB, 0, false))) + + // Wait for A to fire. + select { + case firedAt := <-firstFiredAt: + // A's expected deadline was enqueueA + delay. Firing should occur no + // earlier than ~that moment and not be delayed by B's later deadline. + elapsed := firedAt.Sub(enqueueA) + // Allow generous slack but ensure A did not get pushed to B's deadline + // (B's deadline is enqueueA + ~20ms + 50ms + delay = enqueueA + ~170ms). + assert.GreaterOrEqual(t, elapsed, 90*time.Millisecond, "A fired before its deadline") + assert.Less(t, elapsed, 160*time.Millisecond, "A's deadline was extended by B's kick") + case <-time.After(1 * time.Second): + t.Fatal("A did not fire within timeout") + } + + mu.Lock() + defer mu.Unlock() + require.GreaterOrEqual(t, len(deliveredOrder), 1) + assert.Equal(t, txA, deliveredOrder[0], "A must fire first (queue order preserved)") +} + +// New: shutdown with non-empty queue — cancel the gate's context, assert the +// goroutine exits quickly and no handler is invoked. +func TestConfirmationGate_ShutdownWithNonEmptyQueue(t *testing.T) { + t.Parallel() + + var callCount atomic.Int32 + handlerEntered := make(chan struct{}, 4) + handler := func(_ context.Context, _ types.Log) error { + callCount.Add(1) + select { + case handlerEntered <- struct{}{}: + default: + } + return nil + } + + g := newGate(t, 200*time.Millisecond, handler) + ctx, cancel := context.WithCancel(t.Context()) + g.Start(ctx, func(error) {}) + + // Enqueue multiple events far in the future. + for i := range 4 { + tx := common.HexToHash(string(rune(0x60 + i))) + bh := common.HexToHash(string(rune(0x70 + i))) + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, uint(i), false))) + } + + // Cancel and assert the gate's goroutine exits within a short window. + cancel() + + // Give the goroutine time to observe ctx.Done. + time.Sleep(50 * time.Millisecond) + + // Even if we wait far longer than the delay would otherwise require, no handler call. + time.Sleep(300 * time.Millisecond) + assert.Equal(t, int32(0), callCount.Load(), "no handler invocations expected after shutdown") + + select { + case <-handlerEntered: + t.Fatal("handler was invoked after shutdown") + default: + } +} + +// TestConfirmationGate_HandlerErrorPropagatesFatal: when the downstream handler +// returns an error after the confirmation delay, the gate's lifecycle closure +// receives the sentinel error exactly once and the run goroutine exits. +// Subsequent events that mature must NOT invoke the handler again. +func TestConfirmationGate_HandlerErrorPropagatesFatal(t *testing.T) { + t.Parallel() + + sentinelErr := errors.New("handler sentinel error") + var handlerCalls atomic.Int64 + handler := func(_ context.Context, _ types.Log) error { + handlerCalls.Add(1) + return sentinelErr + } + + delay := 50 * time.Millisecond + g := newGate(t, delay, handler) + + closureCh := make(chan error, 2) // size 2 to catch a buggy double-invocation + g.Start(t.Context(), func(err error) { closureCh <- err }) + + tx := common.HexToHash("0xF1") + bh := common.HexToHash("0xF1") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + + // The closure must be invoked once with the sentinel error. + select { + case err := <-closureCh: + assert.Equal(t, sentinelErr, err, "closure must receive the sentinel error") + case <-time.After(delay + 200*time.Millisecond): + t.Fatal("closure was not invoked within timeout") + } + + // A second invocation must not occur — the run goroutine has exited. + select { + case extra := <-closureCh: + t.Fatalf("unexpected second closure invocation: %v", extra) + case <-time.After(50 * time.Millisecond): + // correct: no second invocation + } + + // Enqueue a second event after the failure. The goroutine has exited; even + // if the kick is queued in the buffered channel it will never be drained. + tx2 := common.HexToHash("0xF2") + bh2 := common.HexToHash("0xF2") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx2, bh2, 0, false))) + + // Wait past the delay; the handler must NOT be called a second time. + time.Sleep(delay + 100*time.Millisecond) + assert.Equal(t, int64(1), handlerCalls.Load(), "handler must be invoked exactly once across gate lifetime") +} + +// TestGate_FlushPending_ClearsPendingAndQueue: FlushPending zeros queue and pending +// but intentionally retains forwardedSet so the post-gate WARN window survives reconnects. +// The storedAt.Equal(popped.forwardedAt) eviction guard at confirmation_gate.go:281-288 +// is load-bearing for re-forward correctness and depends on forwardedSet membership; see +// FlushPending doc comment. +func TestGate_FlushPending_ClearsPendingAndQueue(t *testing.T) { + t.Parallel() + + var forwardCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + forwardCount.Add(1) + return nil + } + + delay := 1 * time.Second // large enough so events don't mature during the test + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + // Push 3 non-removed events; they must sit in pending and queue. + txHashes := []common.Hash{ + common.HexToHash("0xA1"), + common.HexToHash("0xA2"), + common.HexToHash("0xA3"), + } + bh := common.HexToHash("0xBLOCK") + for i, tx := range txHashes { + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, uint(i), false))) + } + + g.mu.Lock() + assert.Equal(t, 3, len(g.queue), "queue must have 3 entries before flush") + assert.Equal(t, 3, len(g.pending), "pending must have 3 entries before flush") + g.mu.Unlock() + + g.FlushPending() + + g.mu.Lock() + assert.Nil(t, g.queue, "queue must be nil after flush") + assert.Equal(t, 0, len(g.pending), "pending must be empty after flush") + g.mu.Unlock() + + // Handler must not have been called (entries were flushed before maturing). + assert.Equal(t, int32(0), forwardCount.Load(), "handler must not be called for flushed entries") +} + +// TestGate_FlushPending_RetainsForwardedSet: forwardedSet must survive a FlushPending call. +func TestGate_FlushPending_RetainsForwardedSet(t *testing.T) { + t.Parallel() + + var forwardCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + forwardCount.Add(1) + return nil + } + + delay := 5 * time.Millisecond + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + // Enqueue and wait for one event to be forwarded (so forwardedSet has an entry). + tx := common.HexToHash("0xF1") + bh := common.HexToHash("0xF1") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + + deadline := time.After(500 * time.Millisecond) + for forwardCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("handler not called within timeout") + default: + time.Sleep(1 * time.Millisecond) + } + } + + // forwardedSet must contain the entry. + fk := forwardedKey{txHash: tx, blockHash: bh, logIndex: 0} + g.mu.Lock() + _, presentBefore := g.forwardedSet[fk] + g.mu.Unlock() + require.True(t, presentBefore, "forwardedSet must contain the entry before flush") + + // Enqueue two more events so flush has something to clear. + tx2 := common.HexToHash("0xF2") + bh2 := common.HexToHash("0xF2") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx2, bh2, 0, false))) + + g.FlushPending() + + // forwardedSet must still contain the originally forwarded entry. + g.mu.Lock() + _, presentAfter := g.forwardedSet[fk] + queueLen := len(g.queue) + pendingLen := len(g.pending) + g.mu.Unlock() + + assert.True(t, presentAfter, "forwardedSet must be retained across FlushPending") + assert.Nil(t, g.queue, "queue must be nil after flush") + assert.Equal(t, 0, pendingLen, "pending must be empty after flush") + assert.Equal(t, 0, queueLen, "queue must be empty after flush") +} + +// TestGate_ReForwardAfterFlush_EvictionGuardCorrect: verifies that the +// storedAt.Equal(popped.forwardedAt) guard at confirmation_gate.go:281-288 handles +// the case where an entry that was in forwardedSet before the flush is re-forwarded +// after the flush. The older FIFO entry must NOT evict the newer set membership. +func TestGate_ReForwardAfterFlush_EvictionGuardCorrect(t *testing.T) { + t.Parallel() + + var forwardCount atomic.Int32 + handler := func(_ context.Context, _ types.Log) error { + forwardCount.Add(1) + return nil + } + + delay := 5 * time.Millisecond + g := newGate(t, delay, handler) + g.Start(t.Context(), func(error) {}) + + tx := common.HexToHash("0xEG1") + bh := common.HexToHash("0xEG1") + + // First forward. + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + deadline := time.After(500 * time.Millisecond) + for forwardCount.Load() == 0 { + select { + case <-deadline: + t.Fatal("first forward timed out") + default: + time.Sleep(1 * time.Millisecond) + } + } + assert.Equal(t, int32(1), forwardCount.Load()) + + // Flush: queue and pending are cleared; forwardedSet retains the entry. + g.FlushPending() + + // Re-enqueue the same (tx, logIndex) with same blockHash — simulates re-forward after flush. + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx, bh, 0, false))) + + // Wait for the re-forward. + deadline = time.After(500 * time.Millisecond) + for forwardCount.Load() < 2 { + select { + case <-deadline: + t.Fatal("re-forward timed out") + default: + time.Sleep(1 * time.Millisecond) + } + } + assert.Equal(t, int32(2), forwardCount.Load()) + + // Wait past recentMultiplier × delay so the eviction loop runs. + time.Sleep(time.Duration(recentMultiplier+1) * delay) + + // Kick the eviction loop by enqueuing a third event. + tx2 := common.HexToHash("0xEG2") + bh2 := common.HexToHash("0xEG2") + require.NoError(t, g.HandleEvent(context.Background(), makeLog(tx2, bh2, 0, false))) + deadline = time.After(500 * time.Millisecond) + for forwardCount.Load() < 3 { + select { + case <-deadline: + t.Fatal("third forward timed out") + default: + time.Sleep(1 * time.Millisecond) + } + } + assert.Equal(t, int32(3), forwardCount.Load()) +} diff --git a/pkg/blockchain/evm/init.go b/pkg/blockchain/evm/init.go index a042d718b..6d9548153 100644 --- a/pkg/blockchain/evm/init.go +++ b/pkg/blockchain/evm/init.go @@ -2,5 +2,4 @@ package evm func init() { initChannelHub() - initLockingContract() } diff --git a/pkg/blockchain/evm/interface.go b/pkg/blockchain/evm/interface.go index 23e5bf639..936107393 100644 --- a/pkg/blockchain/evm/interface.go +++ b/pkg/blockchain/evm/interface.go @@ -5,17 +5,27 @@ import ( ethereum "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" ) type HandleEvent func(ctx context.Context, eventLog types.Log) error -// ContractEventGetter is used by Listener for resumption and deduplication. +// ContractEventGetter is used by Listener for resumption, deduplication, and +// reconciliation-walk queries. type ContractEventGetter interface { // GetLatestContractEventBlockNumber returns the block to resume from (0 = start fresh). GetLatestContractEventBlockNumber(contractAddress string, blockchainID uint64) (lastBlock uint64, err error) - // IsContractEventPresent checks whether a specific event was already processed. - IsContractEventPresent(blockchainID, blockNumber uint64, txHash string, logIndex uint32) (isPresent bool, err error) + // IsContractEventProcessed reports whether an event identified by (txHash, logIndex, blockchainID) + // has already been committed, regardless of which block it appeared in. + IsContractEventProcessed(txHash string, logIndex uint32, blockchainID uint64) (bool, error) + // GetLatestContractEventBlockHashAndNumber returns the block_number and block_hash of + // the highest stored event. Returns (0, "", nil) when no rows exist. + GetLatestContractEventBlockHashAndNumber(contractAddress string, blockchainID uint64) (blockNumber uint64, blockHash string, err error) + // GetPreviousDistinctBlockHash returns the block_number and block_hash of the highest + // stored event with block_number strictly below belowBlockNumber. Returns (0, "", nil) + // when no such row exists (genesis fallback). + GetPreviousDistinctBlockHash(contractAddress string, blockchainID uint64, belowBlockNumber uint64) (blockNumber uint64, blockHash string, err error) } type AssetStore interface { @@ -35,4 +45,9 @@ type AssetStore interface { type EVMClient interface { ethereum.ChainStateReader bind.ContractBackend + // HeaderByHash is used by the gate's block-timestamp fetcher and by the + // Listener's age-based routing of Phase 1 events. It returns whatever header + // the node has for the given hash (which may be a side-chain header) — it is + // NOT suitable for canonicality checks; use HeaderByNumber for that. + HeaderByHash(ctx context.Context, hash common.Hash) (*types.Header, error) } diff --git a/pkg/blockchain/evm/listener.go b/pkg/blockchain/evm/listener.go index 1e2a013d7..a2d7ebb7c 100644 --- a/pkg/blockchain/evm/listener.go +++ b/pkg/blockchain/evm/listener.go @@ -9,7 +9,6 @@ import ( "time" "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/layer-3/nitrolite/pkg/log" @@ -25,26 +24,57 @@ const ( // deduplicated delivery even across restarts. Cancel the context passed to Listen // for graceful shutdown. type Listener struct { - contractAddress common.Address - client bind.ContractBackend - blockchainID uint64 - blockStep uint64 // max blocks per FilterLogs call during reconciliation - logger log.Logger - handleEvent HandleEvent - eventGetter ContractEventGetter + contractAddress common.Address + client EVMClient + blockchainID uint64 + blockStep uint64 // max blocks per FilterLogs call during reconciliation + confirmationDelay time.Duration // routing threshold for Phase 1 events; 0 disables age-based routing + logger log.Logger + handleEvent HandleEvent // live events and recent historical events; typically the ConfirmationGate + handleHistoricalEvent HandleEvent // historical events older than confirmationDelay; typically the reactor directly + eventGetter ContractEventGetter + flushDownstream func() // optional: called between reconnect iterations to flush gate pending state; nil = no-op + + // Single-entry block-timestamp cache for ensureBlockTimestamp. The listener's + // processEvents loop is strictly serial (Phase 1 drains before Phase 2, each + // phase processes one event at a time), so these fields require no mutex. + lastBlockHash common.Hash + lastBlockTimestamp time.Time } // NewListener creates a Listener. blockStep controls how many blocks are fetched // per RPC call during historical reconciliation. -func NewListener(contractAddress common.Address, client bind.ContractBackend, blockchainID uint64, blockStep uint64, logger log.Logger, eventHandler HandleEvent, eventGetter ContractEventGetter) *Listener { +// +// confirmationDelay controls per-event routing for Phase 1 (historical) events: +// - When 0: every historical event is routed to historicalEventHandler. +// - When > 0: each event's block timestamp is fetched via HeaderByHash. Events older +// than confirmationDelay are routed to historicalEventHandler (their block is past +// the reorg window, so they are safe to forward directly). Events younger than +// confirmationDelay are routed to eventHandler so they pass through the gate — +// historical replay reaching very recent blocks is no safer than live delivery +// and the gate must still protect against reorgs of those blocks. +// +// Live (Phase 2) events always flow to eventHandler. +// +// eventHandler is typically the ConfirmationGate; historicalEventHandler is typically +// the reactor directly. The two handlers may be the same function when no gate is in use. +// +// flushDownstream is an optional callback called between reconnect iterations to flush +// in-flight gate pending state before re-reading the committed cursor via +// findCommonAncestor. When nil (the no-gate path, confirmationDelay == 0), the call +// is skipped. See ConfirmationGate.FlushPending and nitronode/docs/reorg-fix.md §6.9. +func NewListener(contractAddress common.Address, client EVMClient, blockchainID uint64, blockStep uint64, confirmationDelay time.Duration, logger log.Logger, eventHandler HandleEvent, historicalEventHandler HandleEvent, eventGetter ContractEventGetter, flushDownstream func()) *Listener { return &Listener{ - contractAddress: contractAddress, - client: client, - blockchainID: blockchainID, - blockStep: blockStep, - logger: logger.WithName("evm"), - handleEvent: eventHandler, - eventGetter: eventGetter, + contractAddress: contractAddress, + client: client, + blockchainID: blockchainID, + blockStep: blockStep, + confirmationDelay: confirmationDelay, + logger: logger.WithName("evm"), + handleEvent: eventHandler, + handleHistoricalEvent: historicalEventHandler, + eventGetter: eventGetter, + flushDownstream: flushDownstream, } } @@ -97,20 +127,29 @@ func (l *Listener) logBackOff(count uint64, originator string) (time.Duration, b return d, true } -// listenEvents is the main loop. Each iteration: -// 1. Subscribes to live events (buffered in currentCh). -// 2. Fetches the chain tip — done after subscribing so no events fall through the gap. -// 3. Launches reconcileBlockRange in a goroutine (lastBlock → chain tip → historicalCh). -// 4. Calls processEvents: drains historicalCh first, then switches to currentCh. +// listenEvents is the main reconnect loop. Each iteration: +// 1. Delegates to runOneListenPass, which resolves the committed cursor, subscribes, +// reconciles historical, and processes live events. +// 2. On subscription drop (retry=true, err=nil): flushes downstream gate pending +// state immediately (see §6.9 of reorg-fix.md), increments backoff, then loops. +// 3. On fatal handler/check failure (err != nil): returns the error immediately. +// +// The flush runs immediately after runOneListenPass returns retry=true, BEFORE the +// next iteration's backoff sleep. This ordering is deliberate: the gate's drain +// goroutine runs on an independent timer and can mature a pending orphan during the +// backoff sleep. Flushing first ensures no orphaned entry escapes before +// findCommonAncestor re-reads the committed-only cursor on the next pass. +// The first iteration naturally skips the flush: no retry path has executed yet +// and the gate is empty on initial entry. // -// On subscription failure it retries with exponential backoff. Returns non-nil only -// when the handler or the event-presence check fails. +// findCommonAncestor is called inside each runOneListenPass so the committed-only +// DB cursor is always re-read after a reconnect. The in-memory lastBlock from the +// previous pass is discarded — the per-pass local variable never escapes. +// Any future code that adds cross-iteration state to listenEvents must be aware +// that runOneListenPass is the iteration unit and per-pass state must be re-derived. +// +// Returns non-nil only when the handler or the event-presence check fails. func (l *Listener) listenEvents(ctx context.Context) error { - lastBlock, err := l.eventGetter.GetLatestContractEventBlockNumber(l.contractAddress.String(), l.blockchainID) - if err != nil { - return fmt.Errorf("failed to get latest processed block: %w", err) - } - var backOffCount atomic.Uint64 l.logger.Info("starting listening events", "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) @@ -130,66 +169,121 @@ func (l *Listener) listenEvents(ctx context.Context) error { return nil } - historicalCh := make(chan types.Log, 1) - currentCh := make(chan types.Log, 100) + retry, err := l.runOneListenPass(ctx, &backOffCount) + if err != nil { + return err + } + if !retry { + return nil // graceful shutdown inside the pass + } - // Subscribe to live events first so nothing is missed while reconciling. - watchFQ := ethereum.FilterQuery{ - Addresses: []common.Address{l.contractAddress}, + // Subscription drop — flush gate pending state BEFORE the next iteration's + // backoff sleep so the gate's drain goroutine cannot mature an orphan during + // the sleep and forward it to the reactor before findCommonAncestor re-reads + // the committed cursor. Flushing here ensures Phase 1 on the next pass + // re-covers the entire [committedCursor, tip] range, which by construction + // includes every block the gate was holding (uncommitted events sit above + // the committed cursor). See nitronode/docs/reorg-fix.md §6.9. + if l.flushDownstream != nil { + l.flushDownstream() } - eventSubscription, err := l.client.SubscribeFilterLogs(context.Background(), watchFQ, currentCh) + backOffCount.Add(1) + } +} + +// runOneListenPass executes one full startup-style pass: +// 1. findCommonAncestor — reads the committed-only DB cursor (fresh each pass). +// 2. SubscribeFilterLogs — opens the live subscription. +// 3. HeaderByNumber(nil) — fetches the chain tip. +// 4. reconcileBlockRange goroutine — Phase 1 historical replay. +// 5. processEvents — drains Phase 1 then processes Phase 2 live events. +// +// Returns (true, nil) on subscription drop (caller retries after flushing the gate +// and applying backoff). Returns (false, nil) on graceful context shutdown. +// Returns (_, err) on fatal handler/check failure. +// +// findCommonAncestor MUST live inside this function for cursor-lifecycle correctness: +// if it were called once in the outer loop, the in-memory lastBlock advanced by +// Phase 2 (listener.go ← processEvents) would persist across reconnects and Phase 1 +// on the retry would scan only (lastLiveBlock, tip] — missing every uncommitted event +// that was flushed from the gate. See "Cursor-lifecycle defect" in pr-832-open-comments.md. +func (l *Listener) runOneListenPass(ctx context.Context, backOffCount *atomic.Uint64) (retry bool, err error) { + lastBlock, err := findCommonAncestor(ctx, l.client, l.eventGetter, l.contractAddress.String(), l.blockchainID, l.logger) + if err != nil { + return false, fmt.Errorf("failed to find common ancestor: %w", err) + } + + historicalCh := make(chan types.Log, 1) + currentCh := make(chan types.Log, 100) + + // Subscribe to live events first so nothing is missed while reconciling. + watchFQ := ethereum.FilterQuery{ + Addresses: []common.Address{l.contractAddress}, + } + eventSubscription, err := l.client.SubscribeFilterLogs(context.Background(), watchFQ, currentCh) + if err != nil { + l.logger.Error("failed to subscribe on events", "error", err, "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) + backOffCount.Add(1) + return true, nil + } + + // Fetch current block height after subscribing to avoid a gap. + var cancelReconcile context.CancelFunc + if lastBlock == 0 { + l.logger.Info("skipping historical logs fetching", + "blockchainID", l.blockchainID, + "contractAddress", l.contractAddress.String()) + close(historicalCh) + } else { + headerCtx, headerCancel := context.WithTimeout(context.Background(), rpcRequestTimeout) + header, err := l.client.HeaderByNumber(headerCtx, nil) + headerCancel() if err != nil { - l.logger.Error("failed to subscribe on events", "error", err, "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) + l.logger.Error("failed to get latest block", "error", err, "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) + eventSubscription.Unsubscribe() backOffCount.Add(1) - continue + return true, nil } - // Fetch current block height after subscribing to avoid a gap. - var cancelReconcile context.CancelFunc - if lastBlock == 0 { - l.logger.Info("skipping historical logs fetching", - "blockchainID", l.blockchainID, - "contractAddress", l.contractAddress.String()) + var reconcileCtx context.Context + reconcileCtx, cancelReconcile = context.WithCancel(ctx) + currentBlock := header.Number.Uint64() + go func() { + l.reconcileBlockRange(reconcileCtx, currentBlock, lastBlock, historicalCh) close(historicalCh) - } else { - headerCtx, headerCancel := context.WithTimeout(context.Background(), rpcRequestTimeout) - header, err := l.client.HeaderByNumber(headerCtx, nil) - headerCancel() - if err != nil { - l.logger.Error("failed to get latest block", "error", err, "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) - eventSubscription.Unsubscribe() - backOffCount.Add(1) - continue - } + }() + } - var reconcileCtx context.Context - reconcileCtx, cancelReconcile = context.WithCancel(ctx) - currentBlock := header.Number.Uint64() - go func() { - l.reconcileBlockRange(reconcileCtx, currentBlock, lastBlock, historicalCh) - close(historicalCh) - }() - } + l.logger.Info("watching events", "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) + backOffCount.Store(0) - l.logger.Info("watching events", "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String()) - backOffCount.Store(0) + err = l.processEvents(ctx, eventSubscription, historicalCh, currentCh, &lastBlock) + if cancelReconcile != nil { + cancelReconcile() + } + if err != nil { + return false, err + } - err = l.processEvents(ctx, eventSubscription, historicalCh, currentCh, &lastBlock) - if cancelReconcile != nil { - cancelReconcile() - } - if err != nil { - return err - } + // processEvents returned nil — subscription drop or graceful ctx shutdown. + // Distinguish via ctx to allow the caller to skip backoff on clean shutdown. + if ctx.Err() != nil { + return false, nil } + return true, nil } // processEvents runs two sequential phases: historical (historicalCh until closed), // then live (currentCh until ctx or subscription death). In each phase the first -// events are checked via IsContractEventPresent; once a non-present event is found +// events are checked via IsContractEventProcessed; once a non-present event is found // the check is skipped for the rest of that phase (events are strictly ordered). // Returns nil on subscription loss (reconnect), non-nil on handler/check failure. // +// Both the listener (here) and the reactor (channel_hub_reactor.go) call +// IsContractEventProcessed, so both share a dependency on DB availability. A +// transient Postgres hiccup at either call site surfaces the error, unsubscribes, +// and restarts the process — consistent behavior across the pipeline. +// // Listener ordering & idempotency invariant // ----------------------------------------- // Downstream handlers (and any code reasoning about the relative arrival order @@ -204,10 +298,13 @@ func (l *Listener) listenEvents(ctx context.Context) error { // reconcileBlockRange + live subscription preserve chain order within each // phase. // -// 2. Idempotent resume. On restart, IsContractEventPresent gates the first +// 2. Idempotent resume. On restart, IsContractEventProcessed gates the first // event of each phase: events already persisted in a prior run are skipped // rather than reprocessed. Once a non-present event is seen the check is // dropped for the remainder of the phase (safe because of guarantee 1). +// The dedup check identifies events by (txHash, logIndex, blockchainID); +// reorged events with a re-shuffled block-level log index are not detected +// here and rely on reactor business-logic idempotency. // // 3. Cursor advances only on handler success. lastBlock is updated on each // live event, but a non-nil return from handleEvent unsubscribes and @@ -215,11 +312,18 @@ func (l *Listener) listenEvents(ctx context.Context) error { // failed event; the next Listen invocation re-fetches from the same // cursor. Transient handler failures retry instead of silently dropping. // -// 4. Reorged-out logs are discarded. Live deliveries with Removed=true are -// dropped. A reorg that fully removes a ChannelChallenged log also -// removes the matching on-chain status transition to DISPUTED, so the -// contract's Path-1 (challenge-timeout) close cannot subsequently fire -// for the same channel. +// 4. Reorged-out logs are routed by delay configuration. +// When confirmationDelay > 0, live deliveries with Removed=true are +// forwarded to the handler (ConfirmationGate) so the gate can cancel +// any pending confirmation timer for that event; the gate filters them +// before forwarding confirmed events to the reactor. When +// confirmationDelay == 0, there is no gate to consume the removal +// signal, so the listener drops Removed=true logs at the Phase 2 +// boundary — matching pre-PR behavior. In both modes the reactor +// never sees Removed=true logs directly. The lastBlock cursor and +// IsContractEventProcessed dedup check are skipped for Removed=true +// events so neither the resume cursor nor the idempotency guard is +// corrupted by a reorg signal. // // A consequence used by the nitronode event handlers: for any channel that // closes via Path-1 (challenge-timeout, ChannelHub Closed-from-DISPUTED), @@ -250,7 +354,7 @@ func (l *Listener) processEvents( break } if !historicalCheckDone { - present, err := l.eventGetter.IsContractEventPresent(l.blockchainID, eventLog.BlockNumber, eventLog.TxHash.Hex(), uint32(eventLog.Index)) + present, err := l.eventGetter.IsContractEventProcessed(eventLog.TxHash.Hex(), uint32(eventLog.Index), l.blockchainID) if err != nil { eventSubscription.Unsubscribe() return fmt.Errorf("failed to check historical event presence: %w", err) @@ -263,7 +367,22 @@ func (l *Listener) processEvents( } l.logger.Debug("received historical event", "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String(), "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index) evCtx := log.SetContextLogger(context.Background(), l.logger) - if err := l.handleEvent(evCtx, eventLog); err != nil { + eventLog, err := l.ensureBlockTimestamp(ctx, eventLog) + if err != nil { + l.logger.Warn("failed to ensure block timestamp for historical event, routing through gate", + "error", err, + "blockchainID", l.blockchainID, + "blockNumber", eventLog.BlockNumber, + "blockHash", eventLog.BlockHash.Hex(), + ) + if err := l.handleEvent(evCtx, eventLog); err != nil { + eventSubscription.Unsubscribe() + return err + } + continue + } + handler := l.routeHistoricalEvent(eventLog) + if err := handler(evCtx, eventLog); err != nil { eventSubscription.Unsubscribe() return err } @@ -287,27 +406,47 @@ func (l *Listener) processEvents( eventSubscription.Unsubscribe() return nil case eventLog := <-currentCh: - // During a chain reorganization geth re-delivers orphaned logs with - // Removed: true. Skip them to avoid applying phantom state changes. - if eventLog.Removed { - l.logger.Warn("skipping removed log from reorg", "blockchainID", l.blockchainID, "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index, "txHash", eventLog.TxHash.Hex()) + if eventLog.Removed && l.confirmationDelay == 0 { + l.logger.Warn("dropping Removed=true live event on no-gate path", + "blockchainID", l.blockchainID, + "contractAddress", l.contractAddress.String(), + "blockNumber", eventLog.BlockNumber, + "blockHash", eventLog.BlockHash.Hex(), + "txHash", eventLog.TxHash.Hex(), + "logIndex", eventLog.Index, + ) continue } - *lastBlock = eventLog.BlockNumber - if !currentCheckDone { - present, err := l.eventGetter.IsContractEventPresent(l.blockchainID, eventLog.BlockNumber, eventLog.TxHash.Hex(), uint32(eventLog.Index)) - if err != nil { - eventSubscription.Unsubscribe() - return fmt.Errorf("failed to check current event presence: %w", err) - } - if present { - l.logger.Debug("skipping already present current event", "blockchainID", l.blockchainID, "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index) - continue + if !eventLog.Removed { + *lastBlock = eventLog.BlockNumber + if !currentCheckDone { + present, err := l.eventGetter.IsContractEventProcessed(eventLog.TxHash.Hex(), uint32(eventLog.Index), l.blockchainID) + if err != nil { + eventSubscription.Unsubscribe() + return fmt.Errorf("failed to check current event presence: %w", err) + } + if present { + l.logger.Debug("skipping already present current event", "blockchainID", l.blockchainID, "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index) + continue + } + currentCheckDone = true } - currentCheckDone = true + l.logger.Debug("received current event", "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String(), "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index) } - l.logger.Debug("received current event", "blockchainID", l.blockchainID, "contractAddress", l.contractAddress.String(), "blockNumber", eventLog.BlockNumber, "logIndex", eventLog.Index) evCtx := log.SetContextLogger(context.Background(), l.logger) + if !eventLog.Removed { + ensured, err := l.ensureBlockTimestamp(ctx, eventLog) + if err != nil { + l.logger.Warn("failed to ensure block timestamp for current event, routing through gate", + "error", err, + "blockchainID", l.blockchainID, + "blockNumber", eventLog.BlockNumber, + "blockHash", eventLog.BlockHash.Hex(), + ) + } else { + eventLog = ensured + } + } if err := l.handleEvent(evCtx, eventLog); err != nil { eventSubscription.Unsubscribe() return err @@ -324,9 +463,9 @@ func (l *Listener) processEvents( } } -// reconcileBlockRange fetches logs from lastBlock to currentBlock in blockStep-sized -// windows, sending each log to historicalCh. Caller closes historicalCh after return. -// Uses a dedicated context so it can be cancelled when the subscription drops. +// reconcileBlockRange fetches logs in [lastBlock, currentBlock] (inclusive both bounds) +// in blockStep-sized windows, sending each log to historicalCh. Caller closes historicalCh +// after return. Uses a dedicated context so it can be cancelled when the subscription drops. func (l *Listener) reconcileBlockRange( ctx context.Context, currentBlock uint64, @@ -337,7 +476,10 @@ func (l *Listener) reconcileBlockRange( startBlock := lastBlock endBlock := startBlock + l.blockStep - for currentBlock > startBlock { + // Inclusive at the lower bound so the all-reorged orphan-height case + // (lastBlock == currentBlock) still fetches the canonical replacement; + // downstream dedup absorbs the inevitable re-fetch on restart-at-exact-tip. + for currentBlock >= startBlock { d, ok := l.logBackOff(backOffCount.Load(), "reconcile block range") if !ok { return @@ -397,11 +539,68 @@ func (l *Listener) reconcileBlockRange( } } -// TODO: the current reorg handling (skipping Removed logs) prevents new damage but -// does not undo side effects from the original delivery if it was already processed. -// A more robust approach is a confirmation buffer: hold live logs in memory keyed by -// block number, only apply them after N confirmations (new blocks on top), and discard -// any log that arrives with Removed: true while still in the buffer. This adds N blocks -// of latency (~12s × N on mainnet) but guarantees that only finalized events reach the -// handler. On L2s where reorgs are near-zero, the latency trade-off may not be worth it, -// so this should be configurable per chain. +// ensureBlockTimestamp returns eventLog with BlockTimestamp guaranteed non-zero. +// +// Most EVM chains and providers populate BlockTimestamp in the JSON-RPC response, +// in which case eventLog is returned unchanged. For chains/providers that do NOT +// populate it (notably Avalanche C-Chain via ava-labs/libevm, and older BSC +// dataseed nodes), this method fetches the block header via HeaderByHash and +// populates the field on the local-stack copy of types.Log. +// +// Single-entry cache (lastBlockHash) elides repeat fetches for consecutive events +// from the same block — the only relevant case because the listener delivers events +// in block order. +// +// On HeaderByHash failure, returns the original eventLog and the error. Callers +// decide whether to fall back to the gate (which is the conservative behavior; +// see live-path and routeHistoricalEvent below). +func (l *Listener) ensureBlockTimestamp(ctx context.Context, eventLog types.Log) (types.Log, error) { + if eventLog.BlockTimestamp != 0 { + return eventLog, nil + } + + if eventLog.BlockHash == l.lastBlockHash && !l.lastBlockTimestamp.IsZero() { + eventLog.BlockTimestamp = uint64(l.lastBlockTimestamp.Unix()) + return eventLog, nil + } + + headerCtx, cancel := context.WithTimeout(ctx, rpcRequestTimeout) + defer cancel() + header, err := l.client.HeaderByHash(headerCtx, eventLog.BlockHash) + if err != nil { + return eventLog, err + } + + blockTime := time.Unix(int64(header.Time), 0) + l.lastBlockHash = eventLog.BlockHash + l.lastBlockTimestamp = blockTime + eventLog.BlockTimestamp = header.Time + return eventLog, nil +} + +// routeHistoricalEvent chooses the handler for a Phase 1 event based on the age of +// its block. Events whose block timestamp is older than confirmationDelay are routed +// to handleHistoricalEvent (they are past the reorg window and safe to forward +// directly). Recent events — whose blocks may still be reorged — are routed to +// handleEvent so they pass through the gate. When confirmationDelay is zero, every +// event is routed to handleHistoricalEvent. +// +// Reads eventLog.BlockTimestamp directly — callers are expected to have invoked +// ensureBlockTimestamp first. Defense-in-depth: if BlockTimestamp is zero (caller +// failed to ensure it), route through handleEvent (the gate) as the conservative +// choice. +func (l *Listener) routeHistoricalEvent(eventLog types.Log) HandleEvent { + if l.confirmationDelay == 0 { + return l.handleHistoricalEvent + } + + if eventLog.BlockTimestamp == 0 { + return l.handleEvent + } + + blockTime := time.Unix(int64(eventLog.BlockTimestamp), 0) + if time.Since(blockTime) < l.confirmationDelay { + return l.handleEvent + } + return l.handleHistoricalEvent +} diff --git a/pkg/blockchain/evm/listener_test.go b/pkg/blockchain/evm/listener_test.go index 8b339dc36..8ebb89841 100644 --- a/pkg/blockchain/evm/listener_test.go +++ b/pkg/blockchain/evm/listener_test.go @@ -45,7 +45,7 @@ func TestNewListener(t *testing.T) { addr := common.HexToAddress("0x123") eventGetter := new(MockContractEventGetter) - l := NewListener(addr, mockClient, 1, 100, logger, nil, eventGetter) + l := NewListener(addr, mockClient, 1, 100, 0, logger, nil, nil, eventGetter, nil) require.NotNil(t, l) assert.Equal(t, addr, l.contractAddress) assert.Equal(t, uint64(1), l.blockchainID) @@ -59,7 +59,8 @@ func TestListener_Listen_CurrentEvents(t *testing.T) { addr := common.HexToAddress("0x123") eventGetter := new(MockContractEventGetter) - eventGetter.On("GetLatestContractEventBlockNumber", addr.String(), uint64(1)).Return(uint64(0), nil) + // No stored events → findCommonAncestor returns 0 immediately (genesis). + eventGetter.On("GetLatestContractEventBlockHashAndNumber", addr.String(), uint64(1)).Return(uint64(0), "", nil) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -72,7 +73,7 @@ func TestListener_Listen_CurrentEvents(t *testing.T) { return nil } - listener := NewListener(addr, mockClient, 1, 100, logger, handleEvent, eventGetter) + listener := NewListener(addr, mockClient, 1, 100, 0, logger, handleEvent, handleEvent, eventGetter, nil) // Mock SubscribeFilterLogs sub := &MockSubscription{ @@ -80,17 +81,17 @@ func TestListener_Listen_CurrentEvents(t *testing.T) { unsub: func() {}, } - // Mock SubscribeFilterLogs: send a log immediately + // Mock SubscribeFilterLogs: send a log immediately. BlockTimestamp is set so + // the listener's ensureBlockTimestamp short-circuits and does not call HeaderByHash. mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { ch := args.Get(2).(chan<- types.Log) - // Send a log immediately - ch <- types.Log{BlockNumber: 10, Index: 1} + ch <- types.Log{BlockNumber: 10, Index: 1, BlockTimestamp: uint64(time.Now().Unix())} }). Return(sub, nil) - // The first current event will trigger IsContractEventPresent check - eventGetter.On("IsContractEventPresent", uint64(1), uint64(10), mock.Anything, uint32(1)).Return(false, nil) + // The first current event will trigger IsContractEventProcessed check + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(1), uint64(1)).Return(false, nil) go listener.Listen(ctx, func(err error) {}) @@ -109,7 +110,7 @@ func TestListener_ReconcileBlockRange(t *testing.T) { addr := common.HexToAddress("0x123") eventGetter := new(MockContractEventGetter) - listener := NewListener(addr, mockClient, 1, 10, logger, nil, eventGetter) + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) // Setup FilterLogs mock // We expect a range fetch. start=100, step=10 -> end=110. current=120. @@ -130,12 +131,10 @@ func TestListener_ReconcileBlockRange(t *testing.T) { historicalCh := make(chan types.Log, 10) wg := sync.WaitGroup{} - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { listener.reconcileBlockRange(context.Background(), 120, 100, historicalCh) close(historicalCh) - }() + }) var receivedLogs []types.Log for l := range historicalCh { @@ -148,28 +147,77 @@ func TestListener_ReconcileBlockRange(t *testing.T) { assert.Equal(t, uint64(115), receivedLogs[1].BlockNumber) } +// TestListener_ReconcileBlockRange_EqualBounds verifies that when lastBlock == +// currentBlock, reconcileBlockRange issues exactly one FilterLogs call with +// FromBlock == ToBlock == N and forwards the returned log. This is the +// all-reorged resume-from-orphaned-latest case: findCommonAncestor returns +// latestNum and the live tip is also latestNum, so the canonical replacement +// at height N must still be fetched. +// +// Under the old `for currentBlock > startBlock` guard this test would fail +// because the loop body is never entered when both are equal. +func TestListener_ReconcileBlockRange_EqualBounds(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + + eventGetter := new(MockContractEventGetter) + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + + const N = uint64(100) + canonicalReplacement := types.Log{BlockNumber: N, Index: 0} + + // Expect exactly one FilterLogs(from=N, to=N). + mockClient.On("FilterLogs", mock.Anything, mock.MatchedBy(func(q ethereum.FilterQuery) bool { + return q.FromBlock.Uint64() == N && q.ToBlock.Uint64() == N + })).Return([]types.Log{canonicalReplacement}, nil).Once() + + historicalCh := make(chan types.Log, 10) + + wg := sync.WaitGroup{} + wg.Go(func() { + listener.reconcileBlockRange(context.Background(), N, N, historicalCh) + close(historicalCh) + }) + + var receivedLogs []types.Log + for l := range historicalCh { + receivedLogs = append(receivedLogs, l) + } + wg.Wait() + + assert.Len(t, receivedLogs, 1, "equal-bounds call must yield exactly one batch") + assert.Equal(t, N, receivedLogs[0].BlockNumber) + mockClient.AssertExpectations(t) +} + func TestListener_Listen_HistoricalAndCurrent(t *testing.T) { t.Parallel() mockClient := new(MockEVMClient) logger := log.NewNoopLogger() addr := common.HexToAddress("0x123") - // Start from block 100 + // Start from block 100, canonical: the reconciler will compute its hash via HeaderByNumber(100) + // and compare against the stored hash. We construct a deterministic Header so we can pre-compute + // the hash and feed it back as the stored value. + canonicalAt100 := &types.Header{Number: big.NewInt(100), Difficulty: big.NewInt(1)} + blockHash100 := canonicalAt100.Hash() eventGetter := new(MockContractEventGetter) - eventGetter.On("GetLatestContractEventBlockNumber", addr.String(), uint64(1)).Return(uint64(100), nil) + eventGetter.On("GetLatestContractEventBlockHashAndNumber", addr.String(), uint64(1)).Return(uint64(100), blockHash100.Hex(), nil) // Historical event at block 105 is not present - eventGetter.On("IsContractEventPresent", uint64(1), uint64(105), mock.Anything, uint32(0)).Return(false, nil) + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil) // Current event at block 111 — after historical is done, first current event triggers check - eventGetter.On("IsContractEventPresent", uint64(1), uint64(111), mock.Anything, uint32(0)).Return(false, nil) + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil) ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - var receivedCount int64 + var receivedCount atomic.Int64 doneCh := make(chan struct{}) handleEvent := func(ctx context.Context, log types.Log) error { - count := atomic.AddInt64(&receivedCount, 1) + count := receivedCount.Add(1) if count >= 2 { // Expect 1 historical + 1 current cancel() select { @@ -182,14 +230,20 @@ func TestListener_Listen_HistoricalAndCurrent(t *testing.T) { return nil } - listener := NewListener(addr, mockClient, 1, 10, logger, handleEvent, eventGetter) + listener := NewListener(addr, mockClient, 1, 10, 0, logger, handleEvent, handleEvent, eventGetter, nil) + + // findCommonAncestor: HeaderByNumber(100) returns the same header we hashed above, + // so the stored hash matches and block 100 is confirmed canonical. + mockClient.On("HeaderByNumber", mock.Anything, mock.MatchedBy(func(n *big.Int) bool { + return n != nil && n.Cmp(big.NewInt(100)) == 0 + })).Return(canonicalAt100, nil) - // Mock HeaderByNumber (current tip is 110) + // Mock HeaderByNumber(nil) for the chain-tip lookup (current tip is 110). currentHeader := &types.Header{Number: big.NewInt(110)} mockClient.On("HeaderByNumber", mock.Anything, (*big.Int)(nil)).Return(currentHeader, nil) - // Mock FilterLogs (100-110) - histLogs := []types.Log{{BlockNumber: 105, Index: 0}} + // Mock FilterLogs (100-110). BlockTimestamp is set so ensureBlockTimestamp short-circuits. + histLogs := []types.Log{{BlockNumber: 105, Index: 0, BlockTimestamp: uint64(time.Now().Unix())}} mockClient.On("FilterLogs", mock.Anything, mock.Anything).Return(histLogs, nil) // Mock SubscribeFilterLogs @@ -197,8 +251,7 @@ func TestListener_Listen_HistoricalAndCurrent(t *testing.T) { mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { ch := args.Get(2).(chan<- types.Log) - // Send a current log - ch <- types.Log{BlockNumber: 111, Index: 0} + ch <- types.Log{BlockNumber: 111, Index: 0, BlockTimestamp: uint64(time.Now().Unix())} }). Return(sub, nil) @@ -224,22 +277,24 @@ func TestProcessEvents_DedupSkipsPresent(t *testing.T) { return nil } - listener := NewListener(addr, new(MockEVMClient), 1, 10, logger, handleEvent, eventGetter) + listener := NewListener(addr, new(MockEVMClient), 1, 10, 0, logger, handleEvent, handleEvent, eventGetter, nil) // Historical: 3 events. First 2 are present (skipped), 3rd is not (handled). - // After the 3rd, the check should stop — no IsContractEventPresent call for events 4+. + // After the 3rd, the check should stop — no IsContractEventProcessed call for events 4+. + // BlockTimestamp is set so ensureBlockTimestamp short-circuits. + ts := uint64(time.Now().Unix()) historicalCh := make(chan types.Log, 5) - historicalCh <- types.Log{BlockNumber: 100, Index: 0, TxHash: common.HexToHash("0xaa")} - historicalCh <- types.Log{BlockNumber: 101, Index: 0, TxHash: common.HexToHash("0xbb")} - historicalCh <- types.Log{BlockNumber: 102, Index: 0, TxHash: common.HexToHash("0xcc")} - historicalCh <- types.Log{BlockNumber: 103, Index: 0, TxHash: common.HexToHash("0xdd")} - historicalCh <- types.Log{BlockNumber: 104, Index: 0, TxHash: common.HexToHash("0xee")} + historicalCh <- types.Log{BlockNumber: 100, Index: 0, TxHash: common.HexToHash("0xaa"), BlockTimestamp: ts} + historicalCh <- types.Log{BlockNumber: 101, Index: 0, TxHash: common.HexToHash("0xbb"), BlockTimestamp: ts} + historicalCh <- types.Log{BlockNumber: 102, Index: 0, TxHash: common.HexToHash("0xcc"), BlockTimestamp: ts} + historicalCh <- types.Log{BlockNumber: 103, Index: 0, TxHash: common.HexToHash("0xdd"), BlockTimestamp: ts} + historicalCh <- types.Log{BlockNumber: 104, Index: 0, TxHash: common.HexToHash("0xee"), BlockTimestamp: ts} close(historicalCh) // First two are present, third is not - eventGetter.On("IsContractEventPresent", uint64(1), uint64(100), mock.Anything, uint32(0)).Return(true, nil).Once() - eventGetter.On("IsContractEventPresent", uint64(1), uint64(101), mock.Anything, uint32(0)).Return(true, nil).Once() - eventGetter.On("IsContractEventPresent", uint64(1), uint64(102), mock.Anything, uint32(0)).Return(false, nil).Once() + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(true, nil).Once() + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(true, nil).Once() + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Once() // No mock for 103, 104 — if called, mock will panic, proving the check stopped sub := &MockSubscription{errChan: make(chan error)} @@ -275,13 +330,14 @@ func TestProcessEvents_SubscriptionErrorDuringPhase1(t *testing.T) { return nil } - listener := NewListener(addr, new(MockEVMClient), 1, 10, logger, handleEvent, eventGetter) + listener := NewListener(addr, new(MockEVMClient), 1, 10, 0, logger, handleEvent, handleEvent, eventGetter, nil) - // Historical channel with events that will block (not closed yet) + // Historical channel with events that will block (not closed yet). BlockTimestamp + // is set so ensureBlockTimestamp short-circuits. historicalCh := make(chan types.Log, 2) - historicalCh <- types.Log{BlockNumber: 100, Index: 0, TxHash: common.HexToHash("0xaa")} + historicalCh <- types.Log{BlockNumber: 100, Index: 0, TxHash: common.HexToHash("0xaa"), BlockTimestamp: uint64(time.Now().Unix())} - eventGetter.On("IsContractEventPresent", uint64(1), uint64(100), mock.Anything, uint32(0)).Return(false, nil) + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil) // Subscription that will error shortly subErrCh := make(chan error, 1) @@ -303,6 +359,301 @@ func TestProcessEvents_SubscriptionErrorDuringPhase1(t *testing.T) { assert.Equal(t, []uint64{100}, handledBlocks) } +// TestListener_PhaseHandlerRouting verifies the age-based routing of Phase 1 events: +// - Historical events older than confirmationDelay → handleHistoricalEvent (direct, gate bypass) +// - Historical events younger than confirmationDelay → handleEvent (through gate; still in reorg window) +// - Live (Phase 2) events → handleEvent (always) +// - HeaderByHash fetch failures → handleEvent (conservative fallback) +// +// See nitronode/docs/reorg-fix.md §4.4 step 5. +func TestListener_PhaseHandlerRouting(t *testing.T) { + t.Parallel() + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + confirmationDelay := 60 * time.Second + + mockClient := new(MockEVMClient) + eventGetter := new(MockContractEventGetter) + + var ( + mu sync.Mutex + historicalLogs []types.Log + liveLogs []types.Log + ) + historicalHandler := func(_ context.Context, l types.Log) error { + mu.Lock() + defer mu.Unlock() + historicalLogs = append(historicalLogs, l) + return nil + } + liveHandler := func(_ context.Context, l types.Log) error { + mu.Lock() + defer mu.Unlock() + liveLogs = append(liveLogs, l) + return nil + } + + listener := NewListener(addr, mockClient, 1, 10, confirmationDelay, logger, liveHandler, historicalHandler, eventGetter, nil) + + // Old historical event (block timestamp 10 minutes ago) — should bypass the gate. + oldHash := common.HexToHash("0xa1") + oldLog := types.Log{BlockNumber: 100, Index: 0, TxHash: common.HexToHash("0xaaa"), BlockHash: oldHash} + oldHeader := &types.Header{Number: big.NewInt(100), Time: uint64(time.Now().Add(-10 * time.Minute).Unix())} + mockClient.On("HeaderByHash", mock.Anything, oldHash).Return(oldHeader, nil).Once() + + // Recent historical event (block timestamp 5 seconds ago) — should flow through the gate. + recentHash := common.HexToHash("0xa2") + recentLog := types.Log{BlockNumber: 101, Index: 0, TxHash: common.HexToHash("0xbbb"), BlockHash: recentHash} + recentHeader := &types.Header{Number: big.NewInt(101), Time: uint64(time.Now().Add(-5 * time.Second).Unix())} + mockClient.On("HeaderByHash", mock.Anything, recentHash).Return(recentHeader, nil).Once() + + // Historical event whose HeaderByHash fetch fails — should fall back to the gate. + failHash := common.HexToHash("0xa3") + failLog := types.Log{BlockNumber: 102, Index: 0, TxHash: common.HexToHash("0xccc"), BlockHash: failHash} + mockClient.On("HeaderByHash", mock.Anything, failHash).Return(nil, fmt.Errorf("rpc failure")).Once() + + // Live event — always to liveHandler regardless of age. BlockTimestamp is set + // so ensureBlockTimestamp short-circuits on the Phase 2 path (avoiding a + // HeaderByHash call we'd otherwise have to mock). + currentLog := types.Log{BlockNumber: 200, Index: 0, TxHash: common.HexToHash("0xddd"), BlockHash: common.HexToHash("0xb1"), BlockTimestamp: uint64(time.Now().Unix())} + + historicalCh := make(chan types.Log, 3) + historicalCh <- oldLog + historicalCh <- recentLog + historicalCh <- failLog + close(historicalCh) + + currentCh := make(chan types.Log, 1) + currentCh <- currentLog + + // Only the first historical event triggers IsContractEventProcessed (then the check is dropped for the phase); + // the first live event triggers it again for Phase 2. + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Once() + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Once() + + sub := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(100 * time.Millisecond) + cancel() + }() + + var lastBlock uint64 + err := listener.processEvents(ctx, sub, historicalCh, currentCh, &lastBlock) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + require.Len(t, historicalLogs, 1, "only the old historical event should bypass the gate") + assert.Equal(t, uint64(100), historicalLogs[0].BlockNumber) + require.Len(t, liveLogs, 3, "recent + fallback historical events plus the live event must reach the live handler") + assert.Equal(t, uint64(101), liveLogs[0].BlockNumber, "recent historical event routed through the gate") + assert.Equal(t, uint64(102), liveLogs[1].BlockNumber, "HeaderByHash-failed historical event routed through the gate (conservative fallback)") + assert.Equal(t, uint64(200), liveLogs[2].BlockNumber, "live event always routed to the gate") + + mockClient.AssertExpectations(t) + eventGetter.AssertExpectations(t) +} + +// TestListener_PhaseHandlerRouting_DelayZero verifies that when confirmationDelay is 0, +// every historical event is routed to handleHistoricalEvent without any HeaderByHash +// fetch — preserving the legacy bypass for gate-disabled chains. +func TestListener_PhaseHandlerRouting_DelayZero(t *testing.T) { + t.Parallel() + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + + mockClient := new(MockEVMClient) + eventGetter := new(MockContractEventGetter) + + var ( + mu sync.Mutex + historicalLogs []types.Log + ) + historicalHandler := func(_ context.Context, l types.Log) error { + mu.Lock() + defer mu.Unlock() + historicalLogs = append(historicalLogs, l) + return nil + } + liveHandler := func(_ context.Context, _ types.Log) error { + t.Fatal("live handler must not be called when delay is 0 and only Phase 1 events are present") + return nil + } + + listener := NewListener(addr, mockClient, 1, 10, 0, logger, liveHandler, historicalHandler, eventGetter, nil) + + // BlockTimestamp populated by the upstream RPC — ensureBlockTimestamp short-circuits + // and routeHistoricalEvent routes directly to historicalHandler because delay == 0. + histLog := types.Log{BlockNumber: 100, Index: 0, TxHash: common.HexToHash("0xaaa"), BlockHash: common.HexToHash("0xa1"), BlockTimestamp: uint64(time.Now().Unix())} + historicalCh := make(chan types.Log, 1) + historicalCh <- histLog + close(historicalCh) + currentCh := make(chan types.Log) + + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Once() + + sub := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + var lastBlock uint64 + err := listener.processEvents(ctx, sub, historicalCh, currentCh, &lastBlock) + require.NoError(t, err) + + mu.Lock() + defer mu.Unlock() + require.Len(t, historicalLogs, 1) + assert.Equal(t, uint64(100), historicalLogs[0].BlockNumber) + + // HeaderByHash must NOT have been called — the upstream RPC populated BlockTimestamp, + // so ensureBlockTimestamp short-circuits. + mockClient.AssertNotCalled(t, "HeaderByHash") +} + +func TestListener_RemovedLog_ForwardedToHandler(t *testing.T) { + t.Parallel() + + t.Run("WithGate", func(t *testing.T) { + t.Parallel() + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + eventGetter := new(MockContractEventGetter) + + // Track which logs reached handleEvent. + var handledLogs []types.Log + handleEvent := func(ctx context.Context, eventLog types.Log) error { + handledLogs = append(handledLogs, eventLog) + return nil + } + + // confirmationDelay > 0: the gate is active; Removed=true logs MUST be forwarded. + const delay = 30 * time.Second + listener := NewListener(addr, new(MockEVMClient), 1, 10, delay, logger, handleEvent, handleEvent, eventGetter, nil) + + // No historical events. + historicalCh := make(chan types.Log) + close(historicalCh) + + currentCh := make(chan types.Log, 2) + + // Event 1: non-Removed at block 10 — triggers IsContractEventProcessed check, + // advances lastBlock, sets currentCheckDone = true. BlockTimestamp is set so + // ensureBlockTimestamp short-circuits. + normalLog := types.Log{BlockNumber: 10, Index: 0, TxHash: common.HexToHash("0xabc"), BlockTimestamp: uint64(time.Now().Unix())} + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Once() + + // Event 2: Removed=true at block 11 — must NOT advance lastBlock, must NOT call + // IsContractEventProcessed, but MUST reach handleEvent (gate needs the removal signal). + removedLog := types.Log{BlockNumber: 11, Index: 0, TxHash: common.HexToHash("0xdef"), Removed: true} + + currentCh <- normalLog + currentCh <- removedLog + + sub := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + // Give processEvents enough time to drain both buffered events, then cancel. + time.Sleep(100 * time.Millisecond) + cancel() + }() + + var lastBlock uint64 + err := listener.processEvents(ctx, sub, historicalCh, currentCh, &lastBlock) + require.NoError(t, err) + + // Both events must have reached handleEvent. + require.Len(t, handledLogs, 2, "handleEvent must be called for both the normal and the Removed event when gate is active") + + // Verify first call was the normal log and second was the removed log. + assert.Equal(t, uint64(10), handledLogs[0].BlockNumber) + assert.False(t, handledLogs[0].Removed) + assert.Equal(t, uint64(11), handledLogs[1].BlockNumber) + assert.True(t, handledLogs[1].Removed) + + // lastBlock must NOT have advanced past the normal event's block. + assert.Equal(t, uint64(10), lastBlock, "lastBlock must not be advanced by a Removed=true event") + + // IsContractEventProcessed must have been called exactly once (for the normal log only). + eventGetter.AssertNumberOfCalls(t, "IsContractEventProcessed", 1) + eventGetter.AssertExpectations(t) + }) + + t.Run("NoGate", func(t *testing.T) { + t.Parallel() + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + eventGetter := new(MockContractEventGetter) + + // Track which logs reached handleEvent. + var handledLogs []types.Log + handleEvent := func(ctx context.Context, eventLog types.Log) error { + handledLogs = append(handledLogs, eventLog) + return nil + } + + // confirmationDelay == 0: no gate; Removed=true logs must be dropped at Phase 2 boundary. + listener := NewListener(addr, new(MockEVMClient), 1, 10, 0, logger, handleEvent, handleEvent, eventGetter, nil) + + // No historical events. + historicalCh := make(chan types.Log) + close(historicalCh) + + currentCh := make(chan types.Log, 3) + + // Event 1: non-Removed at block 10 — advances lastBlock, triggers dedup check. + // BlockTimestamp is set so ensureBlockTimestamp short-circuits. + normalLog := types.Log{BlockNumber: 10, Index: 0, TxHash: common.HexToHash("0xabc"), BlockTimestamp: uint64(time.Now().Unix())} + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Once() + + // Event 2: Removed=true at block 11 — must be dropped; must NOT reach handleEvent, + // must NOT advance lastBlock. + removedLog := types.Log{BlockNumber: 11, Index: 0, TxHash: common.HexToHash("0xdef"), Removed: true} + + // Event 3: another non-Removed at block 12 — must flow normally after the dropped removal. + // BlockTimestamp is set so ensureBlockTimestamp short-circuits. + followupLog := types.Log{BlockNumber: 12, Index: 1, TxHash: common.HexToHash("0xghi"), BlockTimestamp: uint64(time.Now().Unix())} + + currentCh <- normalLog + currentCh <- removedLog + currentCh <- followupLog + + sub := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + // Give processEvents enough time to drain all three buffered events, then cancel. + time.Sleep(100 * time.Millisecond) + cancel() + }() + + var lastBlock uint64 + err := listener.processEvents(ctx, sub, historicalCh, currentCh, &lastBlock) + require.NoError(t, err) + + // Only the two non-Removed events must have reached handleEvent. + require.Len(t, handledLogs, 2, "handleEvent must NOT be called for Removed=true when no gate is active") + assert.Equal(t, uint64(10), handledLogs[0].BlockNumber) + assert.False(t, handledLogs[0].Removed) + assert.Equal(t, uint64(12), handledLogs[1].BlockNumber) + assert.False(t, handledLogs[1].Removed) + + // lastBlock must reflect the last non-Removed event, not the removed one. + assert.Equal(t, uint64(12), lastBlock, "lastBlock must not be advanced by a Removed=true event") + + // IsContractEventProcessed must have been called exactly once (for the first normal log only; + // the follow-up log skips the check because currentCheckDone is already true). + eventGetter.AssertNumberOfCalls(t, "IsContractEventProcessed", 1) + eventGetter.AssertExpectations(t) + }) +} + func TestReconcileBlockRange_ContextCancellation(t *testing.T) { t.Parallel() mockClient := new(MockEVMClient) @@ -310,7 +661,7 @@ func TestReconcileBlockRange_ContextCancellation(t *testing.T) { addr := common.HexToAddress("0x123") eventGetter := new(MockContractEventGetter) - listener := NewListener(addr, mockClient, 1, 10, logger, nil, eventGetter) + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) ctx, cancel := context.WithCancel(context.Background()) @@ -338,3 +689,430 @@ func TestReconcileBlockRange_ContextCancellation(t *testing.T) { assert.LessOrEqual(t, len(received), 1) mockClient.AssertNumberOfCalls(t, "FilterLogs", 1) } + +// TestEnsureBlockTimestamp_Populated: when BlockTimestamp is already set on the +// incoming log, ensureBlockTimestamp returns the log unchanged and does not call +// HeaderByHash. We prove the latter by leaving the mock unconfigured — any call +// would panic. +func TestEnsureBlockTimestamp_Populated(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + eventGetter := new(MockContractEventGetter) + + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + + originalTs := uint64(1700000000) + eventLog := types.Log{ + BlockNumber: 100, + BlockHash: common.HexToHash("0xabc"), + BlockTimestamp: originalTs, + } + + got, err := listener.ensureBlockTimestamp(context.Background(), eventLog) + require.NoError(t, err) + assert.Equal(t, originalTs, got.BlockTimestamp, "BlockTimestamp must be returned unchanged") + assert.Equal(t, eventLog.BlockHash, got.BlockHash) + mockClient.AssertNotCalled(t, "HeaderByHash") +} + +// TestEnsureBlockTimestamp_Fetch: when BlockTimestamp == 0, ensureBlockTimestamp +// calls HeaderByHash exactly once and populates BlockTimestamp from header.Time. +func TestEnsureBlockTimestamp_Fetch(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + eventGetter := new(MockContractEventGetter) + + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + + bh := common.HexToHash("0xabc") + headerTime := uint64(1700000000) + header := &types.Header{Number: big.NewInt(100), Time: headerTime} + mockClient.On("HeaderByHash", mock.Anything, bh).Return(header, nil).Once() + + eventLog := types.Log{BlockNumber: 100, BlockHash: bh} + + got, err := listener.ensureBlockTimestamp(context.Background(), eventLog) + require.NoError(t, err) + assert.Equal(t, headerTime, got.BlockTimestamp, "BlockTimestamp must be populated from header.Time") + mockClient.AssertExpectations(t) +} + +// TestEnsureBlockTimestamp_CacheHit: two consecutive events with the same BlockHash +// (both with BlockTimestamp == 0) must trigger exactly one HeaderByHash call. The +// second call reads from the single-entry cache. +func TestEnsureBlockTimestamp_CacheHit(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + eventGetter := new(MockContractEventGetter) + + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + + bh := common.HexToHash("0xabc") + headerTime := uint64(1700000000) + header := &types.Header{Number: big.NewInt(100), Time: headerTime} + // Set up exactly ONE HeaderByHash expectation; a second call would fail + // AssertExpectations because the mock is .Once(). + mockClient.On("HeaderByHash", mock.Anything, bh).Return(header, nil).Once() + + first := types.Log{BlockNumber: 100, BlockHash: bh, Index: 0} + second := types.Log{BlockNumber: 100, BlockHash: bh, Index: 1} + + got1, err := listener.ensureBlockTimestamp(context.Background(), first) + require.NoError(t, err) + assert.Equal(t, headerTime, got1.BlockTimestamp) + + got2, err := listener.ensureBlockTimestamp(context.Background(), second) + require.NoError(t, err) + assert.Equal(t, headerTime, got2.BlockTimestamp) + + mockClient.AssertNumberOfCalls(t, "HeaderByHash", 1) + mockClient.AssertExpectations(t) +} + +// TestEnsureBlockTimestamp_FetchError: when HeaderByHash returns an error, +// ensureBlockTimestamp returns the original (unmutated) eventLog and the error. +// The caller decides whether to fall back to the gate. +func TestEnsureBlockTimestamp_FetchError(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + eventGetter := new(MockContractEventGetter) + + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + + bh := common.HexToHash("0xabc") + mockClient.On("HeaderByHash", mock.Anything, bh).Return(nil, fmt.Errorf("rpc failure")).Once() + + eventLog := types.Log{BlockNumber: 100, BlockHash: bh} + + got, err := listener.ensureBlockTimestamp(context.Background(), eventLog) + require.Error(t, err) + // On error, BlockTimestamp remains at the input value (0). + assert.Equal(t, uint64(0), got.BlockTimestamp) + assert.Equal(t, bh, got.BlockHash) + mockClient.AssertExpectations(t) +} + +// TestListener_NoGateFlushNilSafe: listenEvents must not panic when flushDownstream is nil. +// This is the no-gate path (confirmationDelay == 0). We exercise the listenEvents loop +// with two iterations so the nil-flush code path is hit on reconnect. +func TestListener_NoGateFlushNilSafe(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + + eventGetter := new(MockContractEventGetter) + // Empty store → findCommonAncestor returns 0 → skip historical replay. + eventGetter.On("GetLatestContractEventBlockHashAndNumber", addr.String(), uint64(1)).Return(uint64(0), "", nil) + + ctx, cancel := context.WithCancel(context.Background()) + + // flushDownstream = nil (no gate). + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + + // First subscription: immediately close so the loop gets a drop and retries. + sub1 := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + sub1.Unsubscribe() + }). + Return(sub1, nil).Once() + + // Second subscription: cancel the context so the loop exits cleanly. + sub2 := &MockSubscription{errChan: make(chan error), unsub: func() {}} + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + cancel() + }). + Return(sub2, nil).Once() + + listenerDone := make(chan error, 1) + // Must not panic. + require.NotPanics(t, func() { + listener.Listen(ctx, func(err error) { listenerDone <- err }) + select { + case <-listenerDone: + case <-time.After(3 * time.Second): + t.Log("listener timed out but did not panic — test passes") + } + }) +} + +// TestListener_FlushTimingPrecedesBackoff verifies that flushDownstream is called +// BEFORE the backoff sleep on each reconnect iteration, not after it. +// +// The observable invariant: if flush runs before the sleep, then the wall-clock gap +// between flushTime and the next SubscribeFilterLogs call must be at least the backoff +// duration. If flush runs after the sleep (old wrong ordering), the gap is negligible +// (flush and subscribe happen back-to-back with no sleep between them). +// +// Mechanics: +// - Iteration 1 (backOffCount=0): no sleep; sub1 drops immediately (retry=true). +// Flush is recorded, backOffCount becomes 1. +// - Iteration 2 (backOffCount=1): logBackOff returns backOffDuration(1)=1s sleep. +// After the sleep, subscribe2 is called and cancels the context. +// +// Test assertion: sub2Time - flushTime >= minBackoff (≈1s). +// Under the wrong (old) ordering the flush would run just before subscribe2 with no +// sleep in between, so sub2Time - flushTime would be negligible (~0ms), causing the +// assertion to fail. +func TestListener_FlushTimingPrecedesBackoff(t *testing.T) { + // Not marked t.Parallel() because this test intentionally sleeps for ~1s + // (backOffDuration(1) = 1s). Running it in parallel is fine for correctness + // but keeps the suite wall time reasonable. + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + + eventGetter := new(MockContractEventGetter) + // Empty store → no historical replay on each pass. + eventGetter.On("GetLatestContractEventBlockHashAndNumber", addr.String(), uint64(1)).Return(uint64(0), "", nil) + + // flushTime captures the wall-clock instant when flushDownstream is called. + // It is set on the first (and only) flush that happens after sub1 drops. + var flushTime time.Time + var flushMu sync.Mutex + + flushDownstream := func() { + flushMu.Lock() + defer flushMu.Unlock() + if flushTime.IsZero() { + flushTime = time.Now() + } + } + + ctx, cancel := context.WithCancel(context.Background()) + + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, flushDownstream) + + sub1 := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + sub2 := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + + // First SubscribeFilterLogs: drop sub1 immediately so processEvents returns nil + // (subscription drop → retry=true). backOffCount advances to 1 after this. + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + sub1.Unsubscribe() + }). + Return(sub1, nil).Once() + + // Second SubscribeFilterLogs: reached only after the backoff sleep + // (backOffDuration(1) = 1s). Record arrival time and cancel so the loop exits. + var sub2Time time.Time + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + sub2Time = time.Now() + cancel() + }). + Return(sub2, nil).Once() + + listenerDone := make(chan error, 1) + listener.Listen(ctx, func(err error) { + listenerDone <- err + }) + + select { + case <-listenerDone: + case <-time.After(10 * time.Second): + cancel() + t.Fatal("listener did not exit within timeout") + } + + flushMu.Lock() + ft := flushTime + flushMu.Unlock() + + require.False(t, ft.IsZero(), "flushDownstream must have been called after the subscription drop") + require.False(t, sub2Time.IsZero(), "second SubscribeFilterLogs must have been called") + + // The gap between flush and the second subscribe must span the backoff sleep. + // backOffDuration(1) = 1s; use 800ms as a conservative lower bound to absorb + // scheduling jitter while still catching the wrong ordering (gap ≈ 0ms). + const minBackoff = 800 * time.Millisecond + gap := sub2Time.Sub(ft) + assert.GreaterOrEqual(t, gap, minBackoff, + "gap between flushTime and sub2Time (%v) must be >= minBackoff (%v); "+ + "if this fails the flush is running AFTER the sleep (wrong ordering)", gap, minBackoff) +} + +// TestListener_BackoffOnSubscriptionDrop: after a mid-Phase-2 subscription drop the +// outer loop must increment backOffCount so repeated drops incur increasing delays. +// We validate this indirectly by checking that the second SubscribeFilterLogs call +// happens only after some elapsed time relative to the first. +func TestListener_BackoffOnSubscriptionDrop(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + + eventGetter := new(MockContractEventGetter) + eventGetter.On("GetLatestContractEventBlockHashAndNumber", addr.String(), uint64(1)).Return(uint64(0), "", nil) + + var subTimes []time.Time + var subMu sync.Mutex + + sub1 := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + sub2 := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + + ctx, cancel := context.WithCancel(context.Background()) + + // First call: record time, immediately drop. + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + subMu.Lock() + subTimes = append(subTimes, time.Now()) + subMu.Unlock() + sub1.Unsubscribe() + }). + Return(sub1, nil).Once() + + // Second call: record time, cancel so loop exits. + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + subMu.Lock() + subTimes = append(subTimes, time.Now()) + subMu.Unlock() + cancel() + }). + Return(sub2, nil).Once() + + listenerDone := make(chan error, 1) + listener := NewListener(addr, mockClient, 1, 10, 0, logger, nil, nil, eventGetter, nil) + listener.Listen(ctx, func(err error) { listenerDone <- err }) + + select { + case <-listenerDone: + case <-time.After(10 * time.Second): + cancel() + t.Fatal("listener did not exit within timeout") + } + + subMu.Lock() + times := append([]time.Time(nil), subTimes...) + subMu.Unlock() + + require.Len(t, times, 2, "must have exactly two SubscribeFilterLogs calls") + // After first drop backOffCount == 1 → logBackOff returns backOffDuration(1) > 0. + // The second call must happen at least a small positive time after the first. + gap := times[1].Sub(times[0]) + assert.Greater(t, gap, time.Duration(0), "second subscribe must be delayed relative to first (backoff > 0)") +} + +// TestListener_ReconnectFlushesGateAndRewindsCursor: on reconnect after a subscription +// drop, flushDownstream must be called and findCommonAncestor must be re-called so +// Phase 1 re-covers events that were in the gate's pending state but not committed. +func TestListener_ReconnectFlushesGateAndRewindsCursor(t *testing.T) { + t.Parallel() + mockClient := new(MockEVMClient) + logger := log.NewNoopLogger() + addr := common.HexToAddress("0x123") + + const committedBlock = uint64(95) + // The canonical hash that findCommonAncestor will check. + canonicalHeader := &types.Header{Number: big.NewInt(int64(committedBlock)), Difficulty: big.NewInt(1)} + canonicalHash := canonicalHeader.Hash() + + eventGetter := new(MockContractEventGetter) + // findCommonAncestor returns committedBlock on every call (reactor committed up to 95). + eventGetter.On("GetLatestContractEventBlockHashAndNumber", addr.String(), uint64(1)). + Return(committedBlock, canonicalHash.Hex(), nil) + // isStoredBlockCanonical: HeaderByNumber(95) returns canonicalHeader → hash matches → canonical. + mockClient.On("HeaderByNumber", mock.Anything, mock.MatchedBy(func(n *big.Int) bool { + return n != nil && n.Cmp(big.NewInt(int64(committedBlock))) == 0 + })).Return(canonicalHeader, nil) + + var flushCalls atomic.Int32 + flushDownstream := func() { flushCalls.Add(1) } + + // Track the from-block of every FilterLogs call to verify the cursor. + var reconcileStarts []uint64 + var reconcileMu sync.Mutex + + // Current tip = block 110. + currentHeader := &types.Header{Number: big.NewInt(110)} + mockClient.On("HeaderByNumber", mock.Anything, (*big.Int)(nil)).Return(currentHeader, nil) + + mockClient.On("FilterLogs", mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + q := args.Get(1).(ethereum.FilterQuery) + reconcileMu.Lock() + reconcileStarts = append(reconcileStarts, q.FromBlock.Uint64()) + reconcileMu.Unlock() + }). + Return([]types.Log{}, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + // First subscription: deliver one live event (advancing in-memory lastBlock to 100), + // then immediately drop so the loop retries. + sub1 := &MockSubscription{errChan: make(chan error, 1), unsub: func() {}} + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + ch := args.Get(2).(chan<- types.Log) + // Deliver a live event at block 100 so in-memory lastBlock advances to 100. + ch <- types.Log{ + BlockNumber: 100, + Index: 0, + TxHash: common.HexToHash("0xLIVE"), + BlockTimestamp: uint64(time.Now().Unix()), + } + go func() { + time.Sleep(20 * time.Millisecond) + sub1.Unsubscribe() + }() + }). + Return(sub1, nil).Once() + + // Second subscription: cancel so the loop exits. + sub2 := &MockSubscription{errChan: make(chan error), unsub: func() {}} + mockClient.On("SubscribeFilterLogs", mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + cancel() + }). + Return(sub2, nil).Once() + + // Dedup check for the live event on the first pass. + eventGetter.On("IsContractEventProcessed", mock.Anything, uint32(0), uint64(1)).Return(false, nil).Maybe() + + var noopHandler HandleEvent = func(_ context.Context, _ types.Log) error { return nil } + listener := NewListener(addr, mockClient, 1, 100, 0, logger, noopHandler, noopHandler, eventGetter, flushDownstream) + + listenerDone := make(chan error, 1) + listener.Listen(ctx, func(err error) { listenerDone <- err }) + + select { + case <-listenerDone: + case <-time.After(5 * time.Second): + cancel() + t.Fatal("listener did not exit within timeout") + } + + // flushDownstream must have been called exactly once: after the first subscription + // drop, before the second pass begins. With the new ordering, flush runs AFTER + // runOneListenPass returns retry=true and BEFORE the next iteration's backoff + // sleep — so the first iteration does not flush (no prior subscription drop) and + // the second iteration exits cleanly (cancel was called), so it also does not flush. + assert.Equal(t, int32(1), flushCalls.Load(), + "flushDownstream must be called exactly once (after the subscription drop, before the second pass)") + + // Every FilterLogs call must start from committedBlock (95), not from the + // live-event block (100) that was in-memory on the first pass. This proves + // findCommonAncestor was re-called on the second pass. + reconcileMu.Lock() + starts := append([]uint64(nil), reconcileStarts...) + reconcileMu.Unlock() + + for i, start := range starts { + assert.Equal(t, committedBlock, start, + "reconcileBlockRange call %d must start from committedBlock=%d, got %d", i, committedBlock, start) + } +} diff --git a/pkg/blockchain/evm/locking_client.go b/pkg/blockchain/evm/locking_client.go deleted file mode 100644 index 52c9a2cca..000000000 --- a/pkg/blockchain/evm/locking_client.go +++ /dev/null @@ -1,224 +0,0 @@ -package evm - -import ( - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" - "github.com/shopspring/decimal" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/sign" -) - -// LockingClient provides access to a Locking contract. -type LockingClient struct { - BaseClient - lockingContractAddress common.Address - transactOpts *bind.TransactOpts - - tokenAddress common.Address - tokenDecimals uint8 -} - -// NewLockingClient creates a new LockingClient. -// If txSigner is provided, the client can perform write operations (lock, relock, unlock, withdraw). -func NewLockingClient(lockingContractAddress common.Address, evmClient EVMClient, blockchainID uint64, txSigner ...sign.Signer) (*LockingClient, error) { - c := &LockingClient{ - BaseClient: BaseClient{ - evmClient: evmClient, - blockchainID: blockchainID, - }, - lockingContractAddress: lockingContractAddress, - } - if len(txSigner) > 0 && txSigner[0] != nil { - c.transactOpts = signerTxOpts(txSigner[0], blockchainID) - } - - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return nil, errors.Wrap(err, "failed to instantiate Locking contract") - } - - tokenAddress, err := lockingContract.Asset(nil) - if err != nil { - return nil, errors.Wrap(err, "failed to get asset from the Locking contract") - } - - erc20Contract, err := NewIERC20(tokenAddress, c.evmClient) - if err != nil { - return nil, errors.Wrap(err, "failed to instantiate ERC20 contract") - } - - decimals, err := erc20Contract.Decimals(nil) - if err != nil { - return nil, errors.Wrapf(err, "failed to get decimals for token %s", tokenAddress.Hex()) - } - - c.tokenAddress = tokenAddress - c.tokenDecimals = decimals - - return c, nil -} - -// GetTokenDecimals returns the number of decimals for the token used in the AppRegistry. -// This is needed to convert between human-readable amounts and the raw integer amounts used in transactions. -func (c *LockingClient) GetTokenDecimals() (uint8, error) { - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return 0, errors.Wrap(err, "failed to instantiate Locking contract") - } - - tokenAddress, err := lockingContract.Asset(&bind.CallOpts{}) - if err != nil { - return 0, errors.Wrap(err, "failed to get asset from the Locking contract") - } - - erc20Contract, err := NewIERC20(tokenAddress, c.evmClient) - if err != nil { - return 0, errors.Wrap(err, "failed to instantiate ERC20 contract") - } - - decimals, err := erc20Contract.Decimals(&bind.CallOpts{}) - if err != nil { - return 0, errors.Wrapf(err, "failed to get decimals for token %s", tokenAddress.Hex()) - } - - return decimals, nil -} - -// Lock locks tokens into the Locking contract for the specified target address. -// The caller must have approved the Locking contract to spend the token beforehand. -func (c *LockingClient) Lock(targetWalletAddress string, amount decimal.Decimal) (string, error) { - if !common.IsHexAddress(targetWalletAddress) { - return "", errors.Errorf("invalid address %q", targetWalletAddress) - } - - targetAddr := common.HexToAddress(targetWalletAddress) - if c.transactOpts == nil { - return "", errors.New("transaction signer not configured") - } - - amountBig, err := core.DecimalToUint256(amount, c.tokenDecimals) - if err != nil { - return "", errors.Wrap(err, "failed to convert amount with decimal precision") - } - - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return "", errors.Wrap(err, "failed to instantiate Locking contract") - } - - tx, err := lockingContract.Lock(c.transactOpts, targetAddr, amountBig) - if err != nil { - return "", errors.Wrap(err, "failed to send lock transaction") - } - return tx.Hash().Hex(), nil -} - -// Relock re-locks tokens that are in the unlocking state back to the locked state. -func (c *LockingClient) Relock() (string, error) { - if c.transactOpts == nil { - return "", errors.New("transaction signer not configured") - } - - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return "", errors.Wrap(err, "failed to instantiate Locking contract") - } - - tx, err := lockingContract.Relock(c.transactOpts) - if err != nil { - return "", errors.Wrap(err, "failed to send relock transaction") - } - return tx.Hash().Hex(), nil -} - -// Unlock initiates the unlock process for the caller's locked tokens. -// After the unlock period elapses, Withdraw can be called. -func (c *LockingClient) Unlock() (string, error) { - if c.transactOpts == nil { - return "", errors.New("transaction signer not configured") - } - - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return "", errors.Wrap(err, "failed to instantiate Locking contract") - } - - tx, err := lockingContract.Unlock(c.transactOpts) - if err != nil { - return "", errors.Wrap(err, "failed to send unlock transaction") - } - return tx.Hash().Hex(), nil -} - -// Withdraw withdraws unlocked tokens to the specified destination address. -// Can only be called after the unlock period has elapsed. -func (c *LockingClient) Withdraw(destination string) (string, error) { - if c.transactOpts == nil { - return "", errors.New("transaction signer not configured") - } - - destinationAddr := common.HexToAddress(destination) - - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return "", errors.Wrap(err, "failed to instantiate Locking contract") - } - - tx, err := lockingContract.Withdraw(c.transactOpts, destinationAddr) - if err != nil { - return "", errors.Wrap(err, "failed to send withdraw transaction") - } - return tx.Hash().Hex(), nil -} - -// ApproveToken approves the Locking contract to spend the specified amount of tokens. -// This must be called before Lock. -func (c *LockingClient) ApproveToken(amount decimal.Decimal) (string, error) { - if c.transactOpts == nil { - return "", errors.New("transaction signer not configured") - } - - amountBig, err := core.DecimalToUint256(amount, c.tokenDecimals) - if err != nil { - return "", errors.Wrap(err, "failed to convert amount with decimal precision") - } - - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return "", errors.Wrap(err, "failed to instantiate Locking contract") - } - - tokenAddress, err := lockingContract.Asset(&bind.CallOpts{}) - if err != nil { - return "", errors.Wrap(err, "failed to get asset from Locking contract") - } - - erc20Contract, err := NewIERC20(tokenAddress, c.evmClient) - if err != nil { - return "", errors.Wrap(err, "failed to instantiate ERC20 contract") - } - - tx, err := erc20Contract.Approve(c.transactOpts, c.lockingContractAddress, amountBig) - if err != nil { - return "", errors.Wrap(err, "failed to send approve transaction") - } - return tx.Hash().Hex(), nil -} - -// GetBalance returns the locked balance of a user in the Locking contraсt. -func (c *LockingClient) GetBalance(user string) (decimal.Decimal, error) { - userAddr := common.HexToAddress(user) - lockingContract, err := NewAppRegistry(c.lockingContractAddress, c.evmClient) - if err != nil { - return decimal.Zero, errors.Wrap(err, "failed to instantiate Locking contract") - } - - balance, err := lockingContract.BalanceOf(nil, userAddr) - if err != nil { - return decimal.Zero, errors.Wrap(err, "failed to get balance") - } - - return decimal.NewFromBigInt(balance, -int32(c.tokenDecimals)), nil -} diff --git a/pkg/blockchain/evm/locking_reactor.go b/pkg/blockchain/evm/locking_reactor.go deleted file mode 100644 index f3baca2b0..000000000 --- a/pkg/blockchain/evm/locking_reactor.go +++ /dev/null @@ -1,187 +0,0 @@ -package evm - -import ( - "context" - "strings" - - "github.com/ethereum/go-ethereum/accounts/abi" - "github.com/ethereum/go-ethereum/accounts/abi/bind" - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/pkg/errors" - "github.com/shopspring/decimal" - - "github.com/layer-3/nitrolite/pkg/core" - "github.com/layer-3/nitrolite/pkg/log" -) - -// LockingContractReactorStoreTxHandler is a function that executes Store operations within a transaction. -// If the handler returns an error, the transaction is rolled back; otherwise it's committed. -type LockingContractReactorStoreTxHandler func(LockingContractReactorStore) error - -// LockingContractReactorStoreTxProvider wraps Store operations in a database transaction. -// It accepts a LockingContractReactorStoreTxHandler and manages transaction lifecycle (begin, commit, rollback). -// Returns an error if the handler fails or the transaction cannot be committed. -type LockingContractReactorStoreTxProvider func(LockingContractReactorStoreTxHandler) error - -// LockingContractReactorStore defines the persistence layer interface for channel and state data. -// All methods should be implemented to work within database transactions. -// Implementations are typically provided by the database layer and wrapped by LockingContractReactorStoreTxProvider. -type LockingContractReactorStore interface { - // UpdateUserStaked updates the total staked amount for a user on a specific blockchain. - UpdateUserStaked(wallet string, blockchainID uint64, amount decimal.Decimal) error - - // StoreContractEvent persists a processed contract event to the database, ensuring it's recorded in the same transaction as state updates. - StoreContractEvent(ev core.BlockchainEvent) error -} - -var lockingContractAbi *abi.ABI -var lockingContractFilterer *AppRegistryFilterer -var eventMapping map[common.Hash]string - -func initLockingContract() { - var err error - lockingContractAbi, err = AppRegistryMetaData.GetAbi() - if err != nil { - panic(err) - } - - // Create a filterer for parsing events (address not needed for parsing) - contract := bind.NewBoundContract(common.Address{}, *lockingContractAbi, nil, nil, nil) - lockingContractFilterer = &AppRegistryFilterer{contract: contract} - - eventMapping = make(map[common.Hash]string) - for name, event := range lockingContractAbi.Events { - eventMapping[event.ID] = name - } -} - -type LockingContractReactor struct { - blockchainID uint64 - eventHandler core.LockingContractEventHandler - useStoreInTx LockingContractReactorStoreTxProvider - tokenDecimals int32 - onEventProcessed func(blockchainID uint64, success bool) -} - -func NewLockingContractReactor(blockchainID uint64, eventHandler core.LockingContractEventHandler, getTokenDecimals func() (uint8, error), useStoreInTx LockingContractReactorStoreTxProvider) (*LockingContractReactor, error) { - tokenDecimals, err := getTokenDecimals() - if err != nil { - return nil, errors.Wrap(err, "failed to get token decimals") - } - return &LockingContractReactor{ - blockchainID: blockchainID, - eventHandler: eventHandler, - tokenDecimals: int32(tokenDecimals), - useStoreInTx: useStoreInTx, - }, nil -} - -// SetOnEventProcessed sets an optional callback invoked after each event is processed. -func (r *LockingContractReactor) SetOnEventProcessed(fn func(blockchainID uint64, success bool)) { - r.onEventProcessed = fn -} - -func (r *LockingContractReactor) HandleEvent(ctx context.Context, l types.Log) error { - logger := log.FromContext(ctx) - - eventID := l.Topics[0] - eventName, ok := eventMapping[eventID] - if !ok { - logger.Warn("unknown event ID", "eventID", eventID.Hex(), "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) - return nil - } - logger.Debug("received event", "name", eventName, "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) - - err := r.useStoreInTx(func(store LockingContractReactorStore) error { - var err error - switch eventID { - case lockingContractAbi.Events["Locked"].ID: - err = r.handleLocked(ctx, store, l) - case lockingContractAbi.Events["Relocked"].ID: - err = r.handleRelocked(ctx, store, l) - case lockingContractAbi.Events["UnlockInitiated"].ID: - err = r.handleUnlockInitiated(ctx, store, l) - case lockingContractAbi.Events["Withdrawn"].ID: - err = r.handleWithdrawn(ctx, store, l) - default: - logger.Warn("unknown event: " + eventID.Hex()) - } - if err != nil { - logger.Warn("error processing event", "error", err) - return errors.Wrap(err, "error processing event") - } - - if err := store.StoreContractEvent(core.BlockchainEvent{ - BlockNumber: l.BlockNumber, - BlockchainID: r.blockchainID, - Name: eventName, - ContractAddress: l.Address.Hex(), - TransactionHash: l.TxHash.String(), - LogIndex: uint32(l.Index), - }); err != nil { - logger.Warn("error storing contract event", "error", err, "event", eventName, "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) - return errors.Wrap(err, "error storing contract event") - } - - logger.Info("processed event", "event", eventName, "blockNumber", l.BlockNumber, "txHash", l.TxHash.String(), "logIndex", l.Index) - return nil - }) - if r.onEventProcessed != nil { - r.onEventProcessed(r.blockchainID, err == nil) - } - return err -} - -func (r *LockingContractReactor) handleLocked(ctx context.Context, store LockingContractReactorStore, l types.Log) error { - event, err := lockingContractFilterer.ParseLocked(l) - if err != nil { - return errors.Wrap(err, "failed to parse Locked event") - } - - ev := core.UserLockedBalanceUpdatedEvent{ - UserAddress: strings.ToLower(event.User.String()), - BlockchainID: r.blockchainID, - Balance: decimal.NewFromBigInt(event.NewBalance, -r.tokenDecimals), - } - return r.eventHandler.HandleUserLockedBalanceUpdated(ctx, store, &ev) -} - -func (r *LockingContractReactor) handleRelocked(ctx context.Context, store LockingContractReactorStore, l types.Log) error { - event, err := lockingContractFilterer.ParseRelocked(l) - if err != nil { - return errors.Wrap(err, "failed to parse Relocked event") - } - ev := core.UserLockedBalanceUpdatedEvent{ - UserAddress: strings.ToLower(event.User.String()), - BlockchainID: r.blockchainID, - Balance: decimal.NewFromBigInt(event.Balance, -r.tokenDecimals), - } - return r.eventHandler.HandleUserLockedBalanceUpdated(ctx, store, &ev) -} - -func (r *LockingContractReactor) handleUnlockInitiated(ctx context.Context, store LockingContractReactorStore, l types.Log) error { - event, err := lockingContractFilterer.ParseUnlockInitiated(l) - if err != nil { - return errors.Wrap(err, "failed to parse Unlockinitiated event") - } - ev := core.UserLockedBalanceUpdatedEvent{ - UserAddress: strings.ToLower(event.User.String()), - BlockchainID: r.blockchainID, - Balance: decimal.Zero, - } - return r.eventHandler.HandleUserLockedBalanceUpdated(ctx, store, &ev) -} - -func (r *LockingContractReactor) handleWithdrawn(ctx context.Context, store LockingContractReactorStore, l types.Log) error { - event, err := lockingContractFilterer.ParseWithdrawn(l) - if err != nil { - return errors.Wrap(err, "failed to parse Withdrawn event") - } - ev := core.UserLockedBalanceUpdatedEvent{ - UserAddress: strings.ToLower(event.User.String()), - BlockchainID: r.blockchainID, - Balance: decimal.Zero, - } - return r.eventHandler.HandleUserLockedBalanceUpdated(ctx, store, &ev) -} diff --git a/pkg/blockchain/evm/locking_reactor_test.go b/pkg/blockchain/evm/locking_reactor_test.go deleted file mode 100644 index 3a5a6d61f..000000000 --- a/pkg/blockchain/evm/locking_reactor_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package evm - -import ( - "context" - "math/big" - "testing" - - "github.com/ethereum/go-ethereum/common" - "github.com/ethereum/go-ethereum/core/types" - "github.com/shopspring/decimal" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/layer-3/nitrolite/pkg/core" -) - -// mockLockingStore implements LockingContractReactorStore for testing -type mockLockingStore struct { - mock.Mock -} - -func (m *mockLockingStore) UpdateUserStaked(wallet string, blockchainID uint64, amount decimal.Decimal) error { - args := m.Called(wallet, blockchainID, amount) - return args.Error(0) -} - -func (m *mockLockingStore) StoreContractEvent(ev core.BlockchainEvent) error { - args := m.Called(ev) - return args.Error(0) -} - -func TestAppRegistryReactor_HandleLocked(t *testing.T) { - userAddr := common.HexToAddress("0x1234567890abcdef1234567890abcdef12345678") - deposited := big.NewInt(500_000_000) // 500 USDC (6 decimals) - newBalance := big.NewInt(1_000_000_000) // 1000 USDC - var tokenDecimals int32 = 6 - blockchainID := uint64(1) - - // ABI-encode the non-indexed parameters: deposited, newBalance - depositedPadded := common.LeftPadBytes(deposited.Bytes(), 32) - newBalancePadded := common.LeftPadBytes(newBalance.Bytes(), 32) - data := append(depositedPadded, newBalancePadded...) - - lockedEventID := lockingContractAbi.Events["Locked"].ID - logEntry := types.Log{ - Topics: []common.Hash{ - lockedEventID, - common.BytesToHash(userAddr.Bytes()), // indexed user - }, - Data: data, - BlockNumber: 100, - TxHash: common.HexToHash("0xdeadbeef"), - Index: 0, - } - - t.Run("success", func(t *testing.T) { - store := new(mockLockingStore) - var capturedEvent *core.UserLockedBalanceUpdatedEvent - handler := &mockAppRegistryEventHandler{ - handleFn: func(_ context.Context, _ core.LockingContractEventHandlerStore, ev *core.UserLockedBalanceUpdatedEvent) error { - capturedEvent = ev - return nil - }, - } - - useStoreInTx := func(handler LockingContractReactorStoreTxHandler) error { - return handler(store) - } - - // Expect StoreContractEvent to be called - store.On("StoreContractEvent", mock.MatchedBy(func(ev core.BlockchainEvent) bool { - return ev.Name == "Locked" && ev.BlockNumber == 100 && ev.BlockchainID == blockchainID - })).Return(nil) - - reactor, err := NewLockingContractReactor(blockchainID, handler, func() (uint8, error) { - return uint8(tokenDecimals), nil - }, useStoreInTx) - require.NoError(t, err) - - var processedSuccess bool - reactor.SetOnEventProcessed(func(_ uint64, success bool) { - processedSuccess = success - }) - - reactor.HandleEvent(context.Background(), logEntry) - - require.NotNil(t, capturedEvent) - assert.Equal(t, userAddr.String(), common.HexToAddress(capturedEvent.UserAddress).String()) - assert.Equal(t, blockchainID, capturedEvent.BlockchainID) - expectedBalance := decimal.NewFromBigInt(newBalance, -tokenDecimals) - assert.True(t, expectedBalance.Equal(capturedEvent.Balance), "expected %s, got %s", expectedBalance, capturedEvent.Balance) - - assert.True(t, processedSuccess) - store.AssertExpectations(t) - }) - - t.Run("getTokenDecimals error", func(t *testing.T) { - handler := &mockAppRegistryEventHandler{ - handleFn: func(_ context.Context, _ core.LockingContractEventHandlerStore, _ *core.UserLockedBalanceUpdatedEvent) error { - t.Fatal("handler should not be called") - return nil - }, - } - - useStoreInTx := func(handler LockingContractReactorStoreTxHandler) error { - return handler(nil) - } - - _, err := NewLockingContractReactor(blockchainID, handler, func() (uint8, error) { - return 0, assert.AnError - }, useStoreInTx) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get token decimals") - }) - - t.Run("handler error", func(t *testing.T) { - store := new(mockLockingStore) - handler := &mockAppRegistryEventHandler{ - handleFn: func(_ context.Context, _ core.LockingContractEventHandlerStore, _ *core.UserLockedBalanceUpdatedEvent) error { - return assert.AnError - }, - } - - useStoreInTx := func(handler LockingContractReactorStoreTxHandler) error { - return handler(store) - } - - reactor, err := NewLockingContractReactor(blockchainID, handler, func() (uint8, error) { - return uint8(tokenDecimals), nil - }, useStoreInTx) - require.NoError(t, err) - - var processedSuccess bool - reactor.SetOnEventProcessed(func(_ uint64, success bool) { - processedSuccess = success - }) - - reactor.HandleEvent(context.Background(), logEntry) - assert.False(t, processedSuccess) - }) -} - -type mockAppRegistryEventHandler struct { - handleFn func(context.Context, core.LockingContractEventHandlerStore, *core.UserLockedBalanceUpdatedEvent) error -} - -func (m *mockAppRegistryEventHandler) HandleUserLockedBalanceUpdated(ctx context.Context, tx core.LockingContractEventHandlerStore, ev *core.UserLockedBalanceUpdatedEvent) error { - return m.handleFn(ctx, tx, ev) -} diff --git a/pkg/blockchain/evm/mock_test.go b/pkg/blockchain/evm/mock_test.go index b96784f80..b81388a8f 100644 --- a/pkg/blockchain/evm/mock_test.go +++ b/pkg/blockchain/evm/mock_test.go @@ -120,6 +120,14 @@ func (m *MockEVMClient) SubscribeFilterLogs(ctx context.Context, query ethereum. return args.Get(0).(ethereum.Subscription), args.Error(1) } +func (m *MockEVMClient) HeaderByHash(ctx context.Context, hash common.Hash) (*types.Header, error) { + args := m.Called(ctx, hash) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*types.Header), args.Error(1) +} + // MockContractEventGetter implements ContractEventGetter interface type MockContractEventGetter struct { mock.Mock @@ -130,11 +138,21 @@ func (m *MockContractEventGetter) GetLatestContractEventBlockNumber(contractAddr return args.Get(0).(uint64), args.Error(1) } -func (m *MockContractEventGetter) IsContractEventPresent(blockchainID, blockNumber uint64, txHash string, logIndex uint32) (bool, error) { - args := m.Called(blockchainID, blockNumber, txHash, logIndex) +func (m *MockContractEventGetter) IsContractEventProcessed(txHash string, logIndex uint32, blockchainID uint64) (bool, error) { + args := m.Called(txHash, logIndex, blockchainID) return args.Bool(0), args.Error(1) } +func (m *MockContractEventGetter) GetLatestContractEventBlockHashAndNumber(contractAddress string, blockchainID uint64) (uint64, string, error) { + args := m.Called(contractAddress, blockchainID) + return args.Get(0).(uint64), args.String(1), args.Error(2) +} + +func (m *MockContractEventGetter) GetPreviousDistinctBlockHash(contractAddress string, blockchainID uint64, belowBlockNumber uint64) (uint64, string, error) { + args := m.Called(contractAddress, blockchainID, belowBlockNumber) + return args.Get(0).(uint64), args.String(1), args.Error(2) +} + // MockAssetStore implements AssetStore interface type MockAssetStore struct { mock.Mock diff --git a/pkg/blockchain/evm/reconciler.go b/pkg/blockchain/evm/reconciler.go new file mode 100644 index 000000000..aedbc80bf --- /dev/null +++ b/pkg/blockchain/evm/reconciler.go @@ -0,0 +1,128 @@ +package evm + +import ( + "context" + "errors" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/layer-3/nitrolite/pkg/log" +) + +// findCommonAncestor determines the last block in the canonical chain that the +// node has already processed. It walks stored block hashes backward until it +// finds a stored hash that matches the canonical chain's hash at that height, +// then returns that block number as the safe replay start point. +// +// Returns 0 only when no stored events exist (empty store). When every stored +// block has been reorged out but a latest row exists, returns that row's block +// number so the caller can replay canonical logs from that height via eth_getLogs. +func findCommonAncestor( + ctx context.Context, + client EVMClient, + getter ContractEventGetter, + contractAddress string, + blockchainID uint64, + logger log.Logger, +) (uint64, error) { + latestNum, latestHash, err := getter.GetLatestContractEventBlockHashAndNumber(contractAddress, blockchainID) + if err != nil { + return 0, fmt.Errorf("get latest contract event block hash: %w", err) + } + if latestHash == "" { + // No stored events (latestNum=0) or pre-migration row with no hash (latestNum>0). + // Either way, treat latestNum as the safe canonical resume point. + return latestNum, nil + } + + blockNum, blockHash := latestNum, latestHash + + for { + if ctx.Err() != nil { + return 0, ctx.Err() + } + + canonical, err := isStoredBlockCanonical(ctx, client, blockNum, common.HexToHash(blockHash)) + if err != nil { + return 0, fmt.Errorf("check canonicality of block %d (%s): %w", blockNum, blockHash, err) + } + + if canonical { + logger.Info("reconciliation: found common ancestor", + "blockchainID", blockchainID, + "blockNumber", blockNum, + "blockHash", blockHash, + ) + return blockNum, nil + } + + // Block was reorged out — walk to the next-older stored block. + logger.Info("reconciliation: block reorged, walking backward", + "blockchainID", blockchainID, + "blockNumber", blockNum, + "blockHash", blockHash, + ) + prevNum, prevHash, err := getter.GetPreviousDistinctBlockHash(contractAddress, blockchainID, blockNum) + if err != nil { + return 0, fmt.Errorf("get previous distinct block hash below %d: %w", blockNum, err) + } + if prevHash == "" { + if prevNum == 0 { + // All stored event blocks have been reorged out and no older stored + // row exists. Resume from the orphaned latest stored block: eth_getLogs + // is a canonical-chain range query, so the canonical replacement logs + // between latestNum and the current tip will be re-fetched. The orphaned + // hash is irrelevant — only the height drives the range query. + logger.Info("reconciliation: all stored blocks reorged, resuming from orphaned latest", + "blockchainID", blockchainID, + "blockNumber", latestNum, + ) + return latestNum, nil + } + // Pre-migration row mid-walk (prevNum > 0, no hash recorded): trust it. + logger.Info("reconciliation: reached pre-migration boundary", + "blockchainID", blockchainID, + "blockNumber", prevNum, + ) + return prevNum, nil + } + + blockNum = prevNum + blockHash = prevHash + } +} + +// isStoredBlockCanonical reports whether the block currently occupying blockNum +// in the canonical chain has the given storedHash. It uses HeaderByNumber rather +// than HeaderByHash because the two answer different questions: +// +// - HeaderByHash returns any header the node has indexed, including orphan +// side-chain headers still cached locally. A successful return does NOT prove +// the block is in the canonical chain. A reorged-out hash may also come back +// as ethereum.NotFound depending on the backend's pruning policy — +// conflating those two outcomes with a single boolean is unsafe. +// +// - HeaderByNumber returns the block currently occupying that height in the +// canonical chain. Comparing its hash to the stored hash is definitive: equal +// means the stored block is canonical, different means it has been reorged +// out. +// +// ethereum.NotFound from HeaderByNumber (e.g. the chain has pruned the height or +// has not yet produced a block at that height) is treated as "not canonical" +// rather than a fatal error, so the caller walks backward instead of crashing +// the listener on startup. +func isStoredBlockCanonical(ctx context.Context, client EVMClient, blockNum uint64, storedHash common.Hash) (bool, error) { + header, err := client.HeaderByNumber(ctx, new(big.Int).SetUint64(blockNum)) + if err != nil { + if errors.Is(err, ethereum.NotFound) { + return false, nil + } + return false, err + } + if header == nil { + return false, nil + } + return header.Hash() == storedHash, nil +} diff --git a/pkg/blockchain/evm/reconciler_test.go b/pkg/blockchain/evm/reconciler_test.go new file mode 100644 index 000000000..f9d709be6 --- /dev/null +++ b/pkg/blockchain/evm/reconciler_test.go @@ -0,0 +1,235 @@ +package evm + +import ( + "context" + "errors" + "math/big" + "testing" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/layer-3/nitrolite/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +const ( + testContract = "0x1234567890abcdef1234567890abcdef12345678" + testBlockchainID = uint64(1) +) + +func newTestLogger() log.Logger { + return log.NewNoopLogger() +} + +// makeHeader builds a Header with a deterministic (and unique-per-seed) hash for +// the given block number. Two calls with different seeds produce headers whose +// Hash() values differ, which lets canonicality tests distinguish "this stored +// block is canonical" (same seed) from "this stored block was reorged out" +// (different seed at the same number). +func makeHeader(blockNum int64, seed int64) *types.Header { + return &types.Header{ + Number: big.NewInt(blockNum), + Difficulty: big.NewInt(seed), + } +} + +func bigEqual(want *big.Int) interface{} { + return mock.MatchedBy(func(got *big.Int) bool { return got != nil && got.Cmp(want) == 0 }) +} + +// TestFindCommonAncestor_NoStoredEvents verifies that when no contract events exist, +// findCommonAncestor returns 0 (genesis fallback). +func TestFindCommonAncestor_NoStoredEvents(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(0), "", nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + assert.Equal(t, uint64(0), result) + client.AssertNotCalled(t, "HeaderByNumber") + client.AssertNotCalled(t, "HeaderByHash") +} + +// TestFindCommonAncestor_LatestBlockCanonical verifies that when the latest stored block +// is still canonical, findCommonAncestor returns that block number with no backward walk. +func TestFindCommonAncestor_LatestBlockCanonical(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + header := makeHeader(500, 1) + storedHash := header.Hash() + + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(500), storedHash.Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(500))).Return(header, nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + assert.Equal(t, uint64(500), result) + getter.AssertNotCalled(t, "GetPreviousDistinctBlockHash") +} + +// TestFindCommonAncestor_SingleReorgDepth verifies that when the latest stored block has +// been reorged out (canonical chain has a different block at that height), findCommonAncestor +// walks back one step and returns the previous canonical block. +func TestFindCommonAncestor_SingleReorgDepth(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + // Block 200 was reorged: stored hash came from a now-orphan block; canonical chain + // has a different block at the same height. + storedAt200 := makeHeader(200, 1) + canonicalAt200 := makeHeader(200, 2) + require.NotEqual(t, storedAt200.Hash(), canonicalAt200.Hash()) + + // Block 190 is canonical. + headerAt190 := makeHeader(190, 1) + storedAt190 := headerAt190.Hash() + + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(200), storedAt200.Hash().Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(200))).Return(canonicalAt200, nil) + getter.On("GetPreviousDistinctBlockHash", testContract, testBlockchainID, uint64(200)). + Return(uint64(190), storedAt190.Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(190))).Return(headerAt190, nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + assert.Equal(t, uint64(190), result) +} + +// TestFindCommonAncestor_NotFoundTreatedAsReorg verifies that when HeaderByNumber returns +// ethereum.NotFound (e.g. the RPC backend has pruned that height, or no canonical block +// exists at that number yet), the walk continues backward instead of crashing the listener. +// This is the regression the colleague flagged: the old HeaderByHash path treated NotFound +// as a fatal startup error. +func TestFindCommonAncestor_NotFoundTreatedAsReorg(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + storedAt200 := common.HexToHash("0xreorged200") + headerAt190 := makeHeader(190, 1) + + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(200), storedAt200.Hex(), nil) + // HeaderByNumber(200) returns NotFound — must NOT be treated as fatal. + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(200))).Return(nil, ethereum.NotFound) + + getter.On("GetPreviousDistinctBlockHash", testContract, testBlockchainID, uint64(200)). + Return(uint64(190), headerAt190.Hash().Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(190))).Return(headerAt190, nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + assert.Equal(t, uint64(190), result) +} + +// TestFindCommonAncestor_AllStoredReorged_ResumesFromOrphanedLatest verifies that when all +// stored blocks have been reorged out (canonical hashes differ at every stored height) and +// no older stored row exists, findCommonAncestor returns the original latestBlockNum so the +// caller can replay canonical logs from that height via eth_getLogs. +func TestFindCommonAncestor_AllStoredReorged_ResumesFromOrphanedLatest(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + storedAt300 := makeHeader(300, 1).Hash() + storedAt200 := makeHeader(200, 1).Hash() + canonicalAt300 := makeHeader(300, 2) + canonicalAt200 := makeHeader(200, 2) + + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(300), storedAt300.Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(300))).Return(canonicalAt300, nil) + getter.On("GetPreviousDistinctBlockHash", testContract, testBlockchainID, uint64(300)). + Return(uint64(200), storedAt200.Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(200))).Return(canonicalAt200, nil) + getter.On("GetPreviousDistinctBlockHash", testContract, testBlockchainID, uint64(200)). + Return(uint64(0), "", nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + // Returns the original latestBlockNum (300), not 0: the caller uses eth_getLogs from + // that height to re-fetch canonical replacement logs. + assert.Equal(t, uint64(300), result) +} + +// TestFindCommonAncestor_PreMigrationLatestRow verifies that when the latest stored row has +// an empty block_hash (pre-migration row), findCommonAncestor returns that block number +// without making any RPC call, treating the row as canonical. +func TestFindCommonAncestor_PreMigrationLatestRow(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + // blockNum=450 but blockHash="" — pre-migration row. + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(450), "", nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + assert.Equal(t, uint64(450), result) + client.AssertNotCalled(t, "HeaderByNumber") +} + +// TestFindCommonAncestor_PreMigrationMidWalk verifies that when a pre-migration row (empty +// block_hash) is encountered during the backward walk, the walk stops and returns that +// block number rather than making an RPC call with a zero hash. +func TestFindCommonAncestor_PreMigrationMidWalk(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + storedAt300 := makeHeader(300, 1).Hash() + canonicalAt300 := makeHeader(300, 2) + + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(300), storedAt300.Hex(), nil) + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(300))).Return(canonicalAt300, nil) + + // Walk backward hits a pre-migration row with empty hash at block 250. + getter.On("GetPreviousDistinctBlockHash", testContract, testBlockchainID, uint64(300)). + Return(uint64(250), "", nil) + + result, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.NoError(t, err) + assert.Equal(t, uint64(250), result) + // HeaderByNumber must NOT be called for the zero-hash pre-migration row. + client.AssertNumberOfCalls(t, "HeaderByNumber", 1) +} + +// TestFindCommonAncestor_RPCError verifies that non-NotFound RPC errors are propagated. +func TestFindCommonAncestor_RPCError(t *testing.T) { + t.Parallel() + + client := new(MockEVMClient) + getter := new(MockContractEventGetter) + + blockHash := common.HexToHash("0xfailhash") + getter.On("GetLatestContractEventBlockHashAndNumber", testContract, testBlockchainID). + Return(uint64(100), blockHash.Hex(), nil) + + client.On("HeaderByNumber", mock.Anything, bigEqual(big.NewInt(100))). + Return(nil, errors.New("rpc timeout")) + + _, err := findCommonAncestor(context.Background(), client, getter, testContract, testBlockchainID, newTestLogger()) + require.Error(t, err) + assert.Contains(t, err.Error(), "rpc timeout") +} diff --git a/pkg/core/README.md b/pkg/core/README.md index 466b885df..ceb12aff4 100644 --- a/pkg/core/README.md +++ b/pkg/core/README.md @@ -52,7 +52,15 @@ The `Client` interface abstracts the communication with the `ChannelsHub` smart ### Listener Interface -The `Listener` allows applications to react to on-chain state changes by registering handlers for events like `HomeChannelCreatedEvent` or `EscrowDepositFinalizedEvent`. +The `Listener` exposes events via a **two-handler model**. A `liveHandler` receives live events plus any historical events still within the reorg window, while a `historicalEventHandler` receives mature historical events past the configured `confirmationDelay`. Per-event routing is decided by the listener itself: it compares `eventLog.BlockTimestamp` against `confirmationDelay` to choose which handler an event flows into. This makes the listener delay-aware rather than pushing that decision down to consumers. + +The typical `liveHandler` is the **`ConfirmationGate`**, which implements the reorg-protection window. The gate buffers each event for `confirmation_delay_secs` before forwarding it to the reactor; if the event's block is reorged out within that window, the gate silently drops it instead of committing it downstream. With the gate in place, the reactor only ever sees events whose blocks have survived the configured confirmation window. + +To make this work, the listener owns timestamp population. **`ensureBlockTimestamp`** guarantees `BlockTimestamp` is set on every non-removed event before it is forwarded: it uses `eventLog.BlockTimestamp` directly when present, and otherwise falls back to a cached `HeaderByHash` lookup. The gate relies on this to compute each event's `arrivedAt` correctly. **`Removed: true`** logs are handled exclusively at the listener boundary: in the live (Phase 2) path with a gate, removed logs are forwarded so the gate can cancel a pending timer; with no gate configured (`confirmation_delay_secs == 0`), the listener drops removed logs at Phase 2 and the reactor never sees them. Historical (Phase 1) replays use `eth_getLogs`, which never emits removals, so that path is simpler by construction. + +On startup, the listener reconciles against possible reorgs that happened while the node was down. **`findCommonAncestor`** walks stored block hashes backward to locate a still-canonical resume point. If every stored block has been reorged out, it returns the orphaned-latest height so `eth_getLogs` re-fetches canonical replacements from that range; the orphan hash itself is discarded — only the height matters because `eth_getLogs` is a canonical-chain range query. + +See [`nitronode/docs/reorg-fix.md`](../../nitronode/docs/reorg-fix.md) for the full design. ### State Advancer diff --git a/pkg/core/errors.go b/pkg/core/errors.go new file mode 100644 index 000000000..a3f27f996 --- /dev/null +++ b/pkg/core/errors.go @@ -0,0 +1,8 @@ +package core + +import "errors" + +// ErrTokenNotSupported indicates a token address is not configured in the node +// asset store for the given blockchain. Callers can use errors.Is to distinguish +// this deterministic "not configured" condition from genuine store failures. +var ErrTokenNotSupported = errors.New("token not supported") diff --git a/pkg/core/event.go b/pkg/core/event.go index 2ffbc6312..c0e8aa131 100644 --- a/pkg/core/event.go +++ b/pkg/core/event.go @@ -67,12 +67,6 @@ type channelChallengedEvent struct { UserSig string `json:"user_sig,omitempty"` } -type UserLockedBalanceUpdatedEvent struct { - UserAddress string `json:"user_address"` - BlockchainID uint64 `json:"blockchain_id"` - 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. @@ -93,4 +87,5 @@ type BlockchainEvent struct { BlockNumber uint64 `json:"block_number"` TransactionHash string `json:"transaction_hash"` LogIndex uint32 `json:"log_index"` + BlockHash string `json:"block_hash"` } diff --git a/pkg/core/interface.go b/pkg/core/interface.go index 0830b94a0..0093d2274 100644 --- a/pkg/core/interface.go +++ b/pkg/core/interface.go @@ -47,19 +47,6 @@ type BlockchainClient interface { FinalizeEscrowWithdrawal(candidate State) (string, error) } -// ========= AppRegistryClient Interface ========= - -type AppRegistryClient interface { - ApproveToken(amount decimal.Decimal) (string, error) - GetBalance(user string) (decimal.Decimal, error) - GetTokenDecimals() (uint8, error) - - Lock(targetWallet string, amount decimal.Decimal) (string, error) - Relock() (string, error) - Unlock() (string, error) - Withdraw(destinationWallet string) (string, error) -} - // ========= TransitionValidator Interface ========= // StateAdvancer applies state transitions @@ -84,14 +71,34 @@ type AssetStore interface { GetTokenDecimals(blockchainID uint64, tokenAddress string) (uint8, error) } -// Channel lifecycle event handlers +// ReadOnlyChannelHub is a read-only view of the on-chain ChannelHub contract, +// used by event handlers to converge the Node row with chain after a guard +// drops an event. Each reactor binds a ReadOnlyChannelHub for its own chain +// and threads it into the handler methods that need an authoritative on-chain +// snapshot; no global multi-chain dispatcher is required. +type ReadOnlyChannelHub interface { + // FetchChannel reads the authoritative on-chain channel snapshot for channelID + // and returns an OnChainChannelSnapshot ready to overwrite the Node's local + // row. The snapshot reflects on-chain state at RPC-read time, not + // event-emit time. + FetchChannel(ctx context.Context, channelID string) (*OnChainChannelSnapshot, error) +} + +// ChannelHubEventHandler defines the off-chain reactions to ChannelHub +// blockchain events. Only the three home-channel guard-drop handlers +// (HandleHomeChannelChallenged, HandleHomeChannelCheckpointed, +// HandleHomeChannelClosed) take a ReadOnlyChannelHub: they are the entrypoints +// where a version-regression guard may drop an event whose outer transaction +// has nonetheless committed state on chain, and the on-chain refresh is +// required to converge the Node row with chain. Other handlers do not need +// the hub and so do not accept the parameter, keeping the interface narrow. type ChannelHubEventHandler interface { HandleNodeBalanceUpdated(context.Context, ChannelHubEventHandlerStore, *NodeBalanceUpdatedEvent) error HandleHomeChannelCreated(context.Context, ChannelHubEventHandlerStore, *HomeChannelCreatedEvent) error HandleHomeChannelMigrated(context.Context, ChannelHubEventHandlerStore, *HomeChannelMigratedEvent) error - HandleHomeChannelCheckpointed(context.Context, ChannelHubEventHandlerStore, *HomeChannelCheckpointedEvent) error - HandleHomeChannelChallenged(context.Context, ChannelHubEventHandlerStore, *HomeChannelChallengedEvent) error - HandleHomeChannelClosed(context.Context, ChannelHubEventHandlerStore, *HomeChannelClosedEvent) error + HandleHomeChannelCheckpointed(ctx context.Context, tx ChannelHubEventHandlerStore, hub ReadOnlyChannelHub, event *HomeChannelCheckpointedEvent) error + HandleHomeChannelChallenged(ctx context.Context, tx ChannelHubEventHandlerStore, hub ReadOnlyChannelHub, event *HomeChannelChallengedEvent) error + HandleHomeChannelClosed(ctx context.Context, tx ChannelHubEventHandlerStore, hub ReadOnlyChannelHub, event *HomeChannelClosedEvent) error HandleEscrowDepositInitiated(context.Context, ChannelHubEventHandlerStore, *EscrowDepositInitiatedEvent) error HandleEscrowDepositChallenged(context.Context, ChannelHubEventHandlerStore, *EscrowDepositChallengedEvent) error HandleEscrowDepositFinalized(context.Context, ChannelHubEventHandlerStore, *EscrowDepositFinalizedEvent) error @@ -156,6 +163,14 @@ type ChannelHubEventHandlerStore interface { // in tests. LockUserState(wallet, asset string) (decimal.Decimal, error) + // LockUserStateForHomeChannel locks the balance row of the user owning channelID. On + // postgres it derives the lock key from the channel in SQL and returns the channel read + // under that lock; on non-postgres (sqlite in tests) the snapshot is taken before the lock + // for test compatibility. Event handlers must use this instead of a GetChannelByID + + // LockUserState pair, which reads channel status before the lock and races a concurrent + // submit_state finalization. Returns nil if the channel is absent. + LockUserStateForHomeChannel(channelID string) (*Channel, error) + // UpdateStateSigsIfMissing backfills the user and/or node signatures for a stored state // when the corresponding column is currently NULL. Used to repair the local record after // an on-chain event proves the state was enforced. Either signature may be empty to skip @@ -167,7 +182,6 @@ type ChannelHubEventHandlerStore interface { // has been temporarily overwritten by an on-chain challenge. HasSignedFinalize(channelID string) (bool, error) - // SumNetTransitionAmountAfterVersion returns the net effect on the user's // home-channel balance of transitions stored against channelID strictly above // minVersion. Receiver credits (TransferReceive, Release) contribute positively; @@ -184,12 +198,3 @@ type ChannelHubEventHandlerStore interface { // event handler to record the ChallengeRescue transaction associated with the squash. RecordTransaction(tx Transaction, applicationID string) error } - -type LockingContractEventHandler interface { - HandleUserLockedBalanceUpdated(context.Context, LockingContractEventHandlerStore, *UserLockedBalanceUpdatedEvent) error -} - -type LockingContractEventHandlerStore interface { - // UpdateUserStaked updates the total staked amount for a user on a specific blockchain. - UpdateUserStaked(wallet string, blockchainID uint64, amount decimal.Decimal) error -} diff --git a/pkg/core/session_key.go b/pkg/core/session_key.go index 4b4eb1625..debedf2b3 100644 --- a/pkg/core/session_key.go +++ b/pkg/core/session_key.go @@ -132,17 +132,14 @@ func GetChannelSessionKeyAuthMetadataHashV1(userAddress string, version uint64, return hashedMetadata, nil } -// 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") - } - +// ValidateChannelSessionKeyStateUserSigV1 verifies only user_sig over the registration payload: +// user_sig must recover to state.UserAddress (wallet authorizes the change). This is the +// revocation path (submitted expires_at <= now): the session-key holder's session_key_sig is +// intentionally not required so a lost, unavailable, or malicious delegate cannot veto the +// wallet's revocation of its own delegation. session_key binds the packed bytes and +// user_address binds the metadata hash, so the signature authorizes exactly this revocation and +// cannot be replayed for another key, wallet, or version. +func ValidateChannelSessionKeyStateUserSigV1(state ChannelSessionKeyStateV1) error { 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) @@ -170,6 +167,41 @@ func ValidateChannelSessionKeyStateV1(state ChannelSessionKeyStateV1) error { return fmt.Errorf("invalid signature: recovered address %s does not match wallet %s", recoveredUser.String(), state.UserAddress) } + return nil +} + +// 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. Used for +// activation, extension, and rotation (submitted expires_at > now); revocation uses +// ValidateChannelSessionKeyStateUserSigV1. +func ValidateChannelSessionKeyStateV1(state ChannelSessionKeyStateV1) error { + if state.SessionKeySig == "" { + return fmt.Errorf("session_key_sig is required") + } + + if err := ValidateChannelSessionKeyStateUserSigV1(state); err != nil { + return err + } + + 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) + } + + packed, err := PackChannelKeyStateV1(state.SessionKey, metadataHash) + 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) + } + sessionKeySigBytes, err := hexutil.Decode(state.SessionKeySig) if err != nil { return fmt.Errorf("failed to decode session_key_sig: %w", err) diff --git a/pkg/core/session_key_test.go b/pkg/core/session_key_test.go index 72d47d2a8..c6ee5e810 100644 --- a/pkg/core/session_key_test.go +++ b/pkg/core/session_key_test.go @@ -135,6 +135,50 @@ func TestValidateChannelSessionKeyStateV1(t *testing.T) { assert.Error(t, ValidateChannelSessionKeyStateV1(stateTampered)) } +func TestValidateChannelSessionKeyStateUserSigV1(t *testing.T) { + t.Parallel() + userSigner, userAddress := createSigner(t) + _, sessionKeyAddr := createSigner(t) + + version := uint64(1) + assets := []string{} + expiresAt := time.Now().Add(-time.Hour) // revocation + + metadataHash, err := GetChannelSessionKeyAuthMetadataHashV1(userAddress, version, assets, expiresAt.Unix()) + require.NoError(t, err) + packed, err := PackChannelKeyStateV1(sessionKeyAddr, metadataHash) + require.NoError(t, err) + userSig, err := userSigner.Sign(packed) + require.NoError(t, err) + + state := ChannelSessionKeyStateV1{ + UserAddress: userAddress, + SessionKey: sessionKeyAddr, + Version: version, + Assets: assets, + ExpiresAt: expiresAt, + UserSig: hexutil.Encode(userSig), + } + + // Valid user_sig alone passes — no session_key_sig required on the revocation path. + require.NoError(t, ValidateChannelSessionKeyStateUserSigV1(state)) + + // user_sig signed by the wrong wallet is rejected. + wrongSigner, _ := createSigner(t) + wrongUserSig, err := wrongSigner.Sign(packed) + require.NoError(t, err) + stateWrongUser := state + stateWrongUser.UserSig = hexutil.Encode(wrongUserSig) + err = ValidateChannelSessionKeyStateUserSigV1(stateWrongUser) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not match wallet") + + // Tampered version diverges the packed bytes, so recovery no longer matches. + stateTampered := state + stateTampered.Version = 2 + assert.Error(t, ValidateChannelSessionKeyStateUserSigV1(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 diff --git a/pkg/core/state_advancer_test.go b/pkg/core/state_advancer_test.go index 1a7426ed8..358b6562a 100644 --- a/pkg/core/state_advancer_test.go +++ b/pkg/core/state_advancer_test.go @@ -369,3 +369,32 @@ func TestValidateAdvancement_RejectsOverflowEscrowLedger(t *testing.T) { assert.Contains(t, err.Error(), "invalid escrow ledger") assert.Contains(t, err.Error(), "uint256") } + +// TestValidateAdvancement_RejectsForgedTxID pins the validation boundary that +// NewTransactionFromTransition now relies on: the advancer recomputes the +// canonical TxID from the proposed state and rejects any transition carrying a +// forged TxID, even when the type, account, and amount are otherwise valid. +func TestValidateAdvancement_RejectsForgedTxID(t *testing.T) { + t.Parallel() + + advancer := NewStateAdvancerV1(newMockAssetStore()) + sig := "0xSig" + chanID := "0xChannel" + + current := NewVoidState("USDC", "0xUser") + current.HomeChannelID = &chanID + current.ID = GetStateID("0xUser", "USDC", 0, 0) + + proposed := current.NextState() + _, err := proposed.ApplyHomeDepositTransition(decimal.NewFromInt(10)) + require.NoError(t, err) + proposed.UserSig = &sig + + // Forge the TxID while leaving the type, account, and amount intact, so the + // only divergence from the recomputed transition is the transaction ID. + proposed.Transition.TxID = "0xforgedtxid" + + err = advancer.ValidateAdvancement(*current, *proposed) + require.Error(t, err) + assert.Contains(t, err.Error(), "transaction ID mismatch") +} diff --git a/pkg/core/types.go b/pkg/core/types.go index 20e58ddbf..b025c2de1 100644 --- a/pkg/core/types.go +++ b/pkg/core/types.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "math" "strconv" "strings" "time" @@ -135,10 +136,26 @@ func NewChannel(channelID, userWallet, asset string, ChType ChannelType, blockch ChallengeDuration: challenge, ApprovedSigValidators: approvedSigValidators, Status: ChannelStatusVoid, - StateVersion: 0, + StateVersion: 0, // 0 means "channel created but no on-chain state has materialised yet" } } +// OnChainChannelSnapshot carries the authoritative on-chain channel snapshot +// returned by ReadOnlyChannelHub.FetchChannel and used to converge a Node row +// that has diverged from chain. +// +// The snapshot reflects on-chain state at RPC-read time, not event-emit time: +// the contract may have advanced the channel through additional transitions +// between when the dropped event was emitted and when the refresh RPC ran. The +// Node row may therefore briefly skip an intermediate status it never observed, +// but it will always converge to a status the chain currently asserts. +type OnChainChannelSnapshot struct { + Status ChannelStatus // mapped from on-chain ChannelStatus enum + StateVersion uint64 // from ChannelMeta.lastState.version + ChallengeExpiresAt *time.Time // nil if no active challenge (on-chain expiry is zero) + LastStateUserSig string // hex-encoded user signature for UpdateStateSigsIfMissing backfill; empty when chain has no sig populated +} + // ChannelDefinition represents configuration for creating a channel type ChannelDefinition struct { Nonce uint64 `json:"nonce"` // A unique number to prevent replay attacks @@ -179,9 +196,11 @@ func NewVoidState(asset, userWallet string) *State { // user against closedChannelID. Placement depends on prev: // // - prev is in-channel (HomeChannelID != nil): the rescue opens a fresh epoch at -// (prev.Epoch+1, version=0) with a clean ledger seeded by amount. Used when no +// (prev.Epoch+1, version=1) with a clean ledger seeded by amount. Used when no // node-signed Finalize exists locally for the closed channel — the user's chain -// has not been advanced past prev yet. +// has not been advanced past prev yet. Version=1 (not 0) mirrors NextState()'s +// post-Finalize convention so version=0 stays reserved as the "no on-chain state +// materialised yet" sentinel. // // - prev is detached (HomeChannelID == nil): the rescue appends at (prev.Epoch, // prev.Version+1), inheriting prev's ledger and adding amount on top. Used after @@ -218,11 +237,13 @@ func NewChallengeRescueState(prev State, closedChannelID string, amount decimal. } } else { // In-channel prev: wrap to a fresh epoch with a clean ledger seeded by amount. + // Version=1 (not 0) keeps the post-Finalize convention so version=0 stays + // reserved as the "no on-chain state materialised yet" sentinel. rescue = &State{ Asset: prev.Asset, UserWallet: prev.UserWallet, Epoch: prev.Epoch + 1, - Version: 0, + Version: 1, HomeLedger: Ledger{ UserBalance: amount, UserNetFlow: decimal.Zero, @@ -244,11 +265,17 @@ func NewChallengeRescueState(prev State, closedChannelID string, amount decimal. func (state State) NextState() *State { var nextState *State if state.IsFinal() { + // Post-Finalize epochs start at Version: 1 — Version: 0 is reserved as a + // sentinel for "channel created but no on-chain state has materialised yet" + // (see NewChannel: StateVersion=0). Starting the fresh epoch at 1 prevents + // the EnsureNoOngoingStateTransitions gate from accidentally treating a + // Void channel's first signed HomeDeposit (state.version == channel.state_version == 0) + // as already settled on-chain. nextState = &State{ Asset: state.Asset, UserWallet: state.UserWallet, Epoch: state.Epoch + 1, - Version: 0, + Version: 1, HomeChannelID: nil, EscrowChannelID: nil, HomeLedger: Ledger{ @@ -729,9 +756,8 @@ const ( TransactionTypeTransfer TransactionType = 30 - TransactionTypeCommit TransactionType = 40 - TransactionTypeRelease TransactionType = 41 - TransactionTypeRebalance TransactionType = 42 + TransactionTypeCommit TransactionType = 40 + TransactionTypeRelease TransactionType = 41 TransactionTypeMigrate TransactionType = 100 TransactionTypeEscrowLock TransactionType = 110 @@ -767,8 +793,6 @@ func (t TransactionType) String() string { return "escrow_withdraw" case TransactionTypeMigrate: return "migrate" - case TransactionTypeRebalance: - return "rebalance" case TransactionTypeFinalize: return "finalize" case TransactionTypeChallengeRescue: @@ -879,12 +903,12 @@ func NewTransactionFromTransition(senderState *State, receiverState *State, tran toAccount = transition.AccountID case TransitionTypeRelease: - txType = TransactionTypeRelease - fromAccount = transition.AccountID - toAccount = receiverState.UserWallet if receiverState == nil { return nil, fmt.Errorf("receiver state must not be nil for 'release' transition") } + txType = TransactionTypeRelease + fromAccount = transition.AccountID + toAccount = receiverState.UserWallet case TransitionTypeMutualLock: if senderState.EscrowChannelID == nil || senderState.HomeChannelID == nil { @@ -928,16 +952,18 @@ func NewTransactionFromTransition(senderState *State, receiverState *State, tran } var receiverStateID *string - var txID string - var err error if receiverState != nil { receiverStateID = &receiverState.ID - txID, err = GetReceiverTransactionID(fromAccount, receiverState.ID) - } else { - txID, err = GetSenderTransactionID(toAccount, senderState.ID) } - if err != nil { - return nil, err + + // The transaction ID must equal the transition's TxID so that the transition + // row references this transaction. For transfers, the sender's TransferSend and + // the receiver's TransferReceive share the same TxID, so both must point at this + // single transaction. The TxID is canonicalised and validated in the state + // advancer, making it the single source of truth. + txID := transition.TxID + if txID == "" { + return nil, fmt.Errorf("transition has empty txID") } return NewTransaction( @@ -1043,15 +1069,6 @@ func (t TransitionType) String() string { } } -func (t TransitionType) GatedAction() GatedAction { - switch t { - case TransitionTypeTransferSend: - return GatedActionTransfer - default: - return "" - } -} - // Transition represents a state transition type Transition struct { Type TransitionType `json:"type"` // Type of state transition @@ -1089,11 +1106,11 @@ func (t1 Transition) Equal(t2 Transition) error { // Blockchain represents information about a supported blockchain network type Blockchain struct { - Name string `json:"name"` // Blockchain name - ID uint64 `json:"id"` // Blockchain network ID - ChannelHubAddress string `json:"channel_hub_address"` // Address of the ChannelHub contract on this blockchain - LockingContractAddress string `json:"locking_contract_address"` // Address of the Locking contract on this blockchain - BlockStep uint64 `json:"block_step"` // Number of blocks between each channel update + Name string `json:"name"` // Blockchain name + ID uint64 `json:"id"` // Blockchain network ID + ChannelHubAddress string `json:"channel_hub_address"` // Address of the ChannelHub contract on this blockchain + BlockStep uint64 `json:"block_step"` // Number of blocks between each channel update + ConfirmationDelaySecs uint32 `json:"confirmation_delay_secs"` // Seconds to wait before processing an event (0 = immediate) } // Asset represents information about a supported asset @@ -1114,63 +1131,6 @@ type Token struct { Decimals uint8 `json:"decimals"` // Number of decimal places } -// GatedAction represents an action that can be gated behind certain conditions, such as feature flags or access controls. -type GatedAction string - -var ( - GatedActionTransfer GatedAction = "transfer" - - GatedActionAppSessionCreation GatedAction = "app_session_creation" - GatedActionAppSessionOperation GatedAction = "app_session_operation" - GatedActionAppSessionDeposit GatedAction = "app_session_deposit" - GatedActionAppSessionWithdrawal GatedAction = "app_session_withdrawal" -) - -// ID returns a unique identifier for the GatedAction, which can be used for efficient storage and retrieval in databases or feature flag systems. -func (g GatedAction) ID() uint8 { - switch g { - case GatedActionTransfer: - return 1 - case GatedActionAppSessionCreation: - return 10 - case GatedActionAppSessionOperation: - return 11 - case GatedActionAppSessionDeposit: - return 12 - case GatedActionAppSessionWithdrawal: - return 13 - } - return 0 -} - -// GatedActionFromID returns the GatedAction corresponding to the given uint8 ID. -// Returns an empty GatedAction and false if the ID is unknown. -func GatedActionFromID(id uint8) (GatedAction, bool) { - switch id { - case 1: - return GatedActionTransfer, true - case 10: - return GatedActionAppSessionCreation, true - case 11: - return GatedActionAppSessionOperation, true - case 12: - return GatedActionAppSessionDeposit, true - case 13: - return GatedActionAppSessionWithdrawal, true - default: - return "", false - } -} - -// ActionAllowance represents the allowance information for a specific gated action, -// including the time window for which the allowance applies, the total allowance, and the amount used. -type ActionAllowance struct { - GatedAction GatedAction - TimeWindow string - Allowance uint64 - Used uint64 -} - // ========= Blockchain CLient Response Types ========= // HomeChannelDataResponse represents the response from getHomeChannelData @@ -1201,8 +1161,8 @@ type EscrowWithdrawalDataResponse struct { // BalanceEntry represents a balance entry for an asset type BalanceEntry struct { - Asset string `json:"asset"` // Asset symbol - Balance decimal.Decimal `json:"balance"` // Balance amount + Asset string `json:"asset"` // Asset symbol + Balance decimal.Decimal `json:"balance"` // Balance amount Enforced decimal.Decimal `json:"enforced"` // On-chain enforced balance } @@ -1214,6 +1174,7 @@ type PaginationParams struct { } // GetOffsetAndLimit extracts offset and limit from pagination params with defaults and max limit enforcement. +// A limit of 0 is treated the same as an absent limit: the defaultLimit is used. func (p *PaginationParams) GetOffsetAndLimit(defaultLimit, maxLimit uint32) (offset, limit uint32) { offset = 0 limit = defaultLimit @@ -1222,11 +1183,17 @@ func (p *PaginationParams) GetOffsetAndLimit(defaultLimit, maxLimit uint32) (off if p.Offset != nil { offset = *p.Offset } - if p.Limit != nil { + if p.Limit != nil && *p.Limit > 0 { limit = min(*p.Limit, maxLimit) } } + // Callers convert offset to int before handing it to GORM's Offset(). On a + // 32-bit target int(offset) wraps to a negative value for large uint32s, + // which GORM treats as "no offset" and silently returns the first page. + // Clamp to MaxInt32 so the conversion is always a non-negative int. + offset = min(offset, math.MaxInt32) + return offset, limit } diff --git a/pkg/core/types_test.go b/pkg/core/types_test.go index 304fffccb..20dd0f15b 100644 --- a/pkg/core/types_test.go +++ b/pkg/core/types_test.go @@ -1,6 +1,7 @@ package core import ( + "math" "math/big" "testing" @@ -49,10 +50,12 @@ func TestState_NextState(t *testing.T) { assert.Equal(t, uint64(1), next.Epoch) assert.Nil(t, next.EscrowLedger) - // Final state next state (new epoch) + // Final state next state (new epoch). Version starts at 1 (not 0) so the gate + // can distinguish a freshly created (Void) channel from one with a confirmed + // on-chain state. state.Transition.Type = TransitionTypeFinalize nextFinal := state.NextState() - assert.Equal(t, uint64(0), nextFinal.Version) + assert.Equal(t, uint64(1), nextFinal.Version) assert.Equal(t, uint64(2), nextFinal.Epoch) // With Escrow Ledger @@ -216,7 +219,7 @@ func TestNewChallengeRescueState(t *testing.T) { } } - t.Run("Success - in-channel prev wraps to fresh epoch at version 0", func(t *testing.T) { + t.Run("Success - in-channel prev wraps to fresh epoch at version 1", func(t *testing.T) { prev := makePrev() amount := decimal.NewFromInt(42) @@ -227,7 +230,7 @@ func TestNewChallengeRescueState(t *testing.T) { assert.Equal(t, prev.Asset, rescue.Asset) assert.Equal(t, prev.UserWallet, rescue.UserWallet) assert.Equal(t, prev.Epoch+1, rescue.Epoch) - assert.Equal(t, uint64(0), rescue.Version) + assert.Equal(t, uint64(1), rescue.Version) assert.Nil(t, rescue.HomeChannelID) assert.Empty(t, rescue.HomeLedger.TokenAddress) assert.Equal(t, uint64(0), rescue.HomeLedger.BlockchainID) @@ -550,24 +553,61 @@ func TestNewTransactionFromTransition(t *testing.T) { senderState.EscrowChannelID = new(string) *senderState.EscrowChannelID = "EC" - // HomeDeposit - transition := Transition{Type: TransitionTypeHomeDeposit, Amount: decimal.NewFromInt(10)} + // HomeDeposit: transaction ID must equal the transition's TxID, + // computed over the HomeChannelID (AccountID), not the UserWallet. + homeDepositTxID, err := GetSenderTransactionID(*senderState.HomeChannelID, senderState.ID) + require.NoError(t, err) + transition := Transition{Type: TransitionTypeHomeDeposit, Amount: decimal.NewFromInt(10), AccountID: *senderState.HomeChannelID, TxID: homeDepositTxID} tx, err := NewTransactionFromTransition(senderState, nil, transition) require.NoError(t, err) assert.Equal(t, TransactionTypeHomeDeposit, tx.TxType) + assert.Equal(t, homeDepositTxID, tx.ID) - // TransferSend - transition = Transition{Type: TransitionTypeTransferSend, Amount: decimal.NewFromInt(10), AccountID: "REC"} + // EscrowDeposit: same divergence — ID over EscrowChannelID, not UserWallet. + escrowDepositTxID, err := GetSenderTransactionID(*senderState.EscrowChannelID, senderState.ID) + require.NoError(t, err) + transition = Transition{Type: TransitionTypeEscrowDeposit, Amount: decimal.NewFromInt(10), AccountID: *senderState.EscrowChannelID, TxID: escrowDepositTxID} + tx, err = NewTransactionFromTransition(senderState, nil, transition) + require.NoError(t, err) + assert.Equal(t, TransactionTypeEscrowDeposit, tx.TxType) + assert.Equal(t, escrowDepositTxID, tx.ID) + + // TransferSend: sender and receiver transitions share this TxID, computed + // over the recipient (AccountID) and the sender's state ID. + transferTxID, err := GetSenderTransactionID("REC", senderState.ID) + require.NoError(t, err) + transition = Transition{Type: TransitionTypeTransferSend, Amount: decimal.NewFromInt(10), AccountID: "REC", TxID: transferTxID} receiverState := NewVoidState("A", "REC") tx, err = NewTransactionFromTransition(senderState, receiverState, transition) require.NoError(t, err) assert.Equal(t, TransactionTypeTransfer, tx.TxType) + assert.Equal(t, transferTxID, tx.ID) + + // The receiver's TransferReceive transition must carry the same TxID as the + // sender's TransferSend, so both states reference the single transfer + // transaction recorded above. + receiveTransition, err := receiverState.ApplyTransferReceiveTransition(senderState.UserWallet, decimal.NewFromInt(10), transferTxID) + require.NoError(t, err) + assert.Equal(t, transferTxID, receiveTransition.TxID) + assert.Equal(t, transition.TxID, receiveTransition.TxID) // TransferSend error: nil receiverState _, err = NewTransactionFromTransition(senderState, nil, transition) assert.Error(t, err) assert.Contains(t, err.Error(), "receiver state must not be nil") + // Release error: nil receiverState must return an error, not panic + transition = Transition{Type: TransitionTypeRelease, Amount: decimal.NewFromInt(10), AccountID: "REC"} + _, err = NewTransactionFromTransition(senderState, nil, transition) + assert.Error(t, err) + assert.Contains(t, err.Error(), "receiver state must not be nil for 'release' transition") + + // Empty TxID is rejected. + transition = Transition{Type: TransitionTypeHomeDeposit, Amount: decimal.NewFromInt(10), AccountID: *senderState.HomeChannelID} + _, err = NewTransactionFromTransition(senderState, nil, transition) + assert.Error(t, err) + assert.Contains(t, err.Error(), "empty txID") + // Invalid transition = Transition{Type: 255} _, err = NewTransactionFromTransition(senderState, nil, transition) @@ -591,4 +631,16 @@ func TestPaginationParams_GetOffsetAndLimit(t *testing.T) { lim = 200 o, l = p.GetOffsetAndLimit(10, 100) assert.Equal(t, uint32(100), l) // Capped at max + + lim = 0 + o, l = p.GetOffsetAndLimit(10, 100) + assert.Equal(t, uint32(10), l) // Zero treated as absent — falls back to defaultLimit + assert.Equal(t, uint32(5), o) + + // Offset is clamped to MaxInt32 so int(offset) never wraps negative on 32-bit. + bigOff := uint32(math.MaxUint32) + p = &PaginationParams{Offset: &bigOff} + o, _ = p.GetOffsetAndLimit(10, 100) + assert.Equal(t, uint32(math.MaxInt32), o) + assert.GreaterOrEqual(t, int(o), 0) } diff --git a/pkg/core/utils.go b/pkg/core/utils.go index 77c2987ea..80446f8ff 100644 --- a/pkg/core/utils.go +++ b/pkg/core/utils.go @@ -2,7 +2,9 @@ package core import ( "fmt" + "math" "math/big" + "regexp" "strings" "github.com/ethereum/go-ethereum/accounts/abi" @@ -12,6 +14,35 @@ import ( "github.com/shopspring/decimal" ) +// HashRegex matches a 32-byte hash rendered as a 0x-prefixed hex string, the +// canonical form of channel IDs and app session IDs (both Keccak256 hashes). +// Case-insensitive, since callers may submit checksummed or lowercased hex. +var HashRegex = regexp.MustCompile(`^0x[0-9a-fA-F]{64}$`) + +// LowercaseHashRegex matches the strict lowercase canonical form of a 32-byte +// hash, rejecting checksummed or uppercase hex. +var LowercaseHashRegex = regexp.MustCompile(`^0x[0-9a-f]{64}$`) + +// IsValidHash reports whether s is a well-formed 32-byte hash (see HashRegex). +// When requireLowercase is true, s must be in strict lowercase canonical form +// (see LowercaseHashRegex); otherwise checksummed and uppercase hex are accepted. +func IsValidHash(s string, requireLowercase bool) bool { + if requireLowercase { + return LowercaseHashRegex.MatchString(s) + } + return HashRegex.MatchString(s) +} + +// SafeOffset converts a uint32 pagination offset to a non-negative int suitable +// for GORM's Offset(). A raw int(offset) wraps to a negative value on a 32-bit +// target for large uint32s, which GORM treats as "no offset" and silently +// returns the first page. Clamping to MaxInt32 keeps the conversion safe even +// when a caller reaches the store without routing through +// PaginationParams.GetOffsetAndLimit. +func SafeOffset(offset uint32) int { + return int(min(offset, math.MaxInt32)) +} + // maxInt256 = 2^255 - 1, the largest value representable as Solidity int256. var maxInt256 = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 255), big.NewInt(1)) diff --git a/pkg/core/utils_test.go b/pkg/core/utils_test.go index 6b2a67929..7c731a7d6 100644 --- a/pkg/core/utils_test.go +++ b/pkg/core/utils_test.go @@ -1,6 +1,7 @@ package core import ( + "math" "math/big" "testing" @@ -652,6 +653,29 @@ func TestGenerateChannelMetadata(t *testing.T) { }) } +func TestSafeOffset(t *testing.T) { + t.Parallel() + tests := []struct { + name string + offset uint32 + want int + }{ + {"zero", 0, 0}, + {"small", 42, 42}, + {"max_int32", math.MaxInt32, math.MaxInt32}, + {"above_max_int32_clamped", math.MaxInt32 + 1, math.MaxInt32}, + {"max_uint32_clamped", math.MaxUint32, math.MaxInt32}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := SafeOffset(tt.offset) + assert.Equal(t, tt.want, got) + assert.GreaterOrEqual(t, got, 0, "offset must never be negative") + }) + } +} + func TestTransitionToIntent_OperateIntents(t *testing.T) { t.Parallel() tests := []struct { @@ -830,3 +854,38 @@ func TestDecimalToInt256(t *testing.T) { assert.Contains(t, err.Error(), "below int256 min") }) } + +func TestIsValidHash(t *testing.T) { + lowercase := "0x1111111111111111111111111111111111111111111111111111111111111111" + uppercase := "0xABCDEF0000000000000000000000000000000000000000000000000000000000" + mixed := "0xabcdefABCDEF0000000000000000000000000000000000000000000000000000" + + invalid := []string{ + "", + "0xabc", + "1111111111111111111111111111111111111111111111111111111111111111", // missing 0x + "0x111111111111111111111111111111111111111111111111111111111111111", // 63 hex + "0x11111111111111111111111111111111111111111111111111111111111111111", // 65 hex + "0xzz11111111111111111111111111111111111111111111111111111111111111", // non-hex + } + + t.Run("case-insensitive", func(t *testing.T) { + for _, s := range []string{lowercase, uppercase, mixed} { + assert.True(t, IsValidHash(s, false), "expected %q to be valid", s) + } + for _, s := range invalid { + assert.False(t, IsValidHash(s, false), "expected %q to be invalid", s) + } + }) + + t.Run("require lowercase", func(t *testing.T) { + assert.True(t, IsValidHash(lowercase, true), "expected %q to be valid", lowercase) + // uppercase and mixed are well-formed hex but not canonical lowercase. + for _, s := range []string{uppercase, mixed} { + assert.False(t, IsValidHash(s, true), "expected %q to be rejected", s) + } + for _, s := range invalid { + assert.False(t, IsValidHash(s, true), "expected %q to be invalid", s) + } + }) +} diff --git a/pkg/log/noop_logger.go b/pkg/log/noop_logger.go index c478ea3bd..e399477bf 100644 --- a/pkg/log/noop_logger.go +++ b/pkg/log/noop_logger.go @@ -15,21 +15,30 @@ func NewNoopLogger() Logger { // Debug implements Logger.Debug but performs no operation. func (n NoopLogger) Debug(msg string, keysAndValues ...any) {} + // Info implements Logger.Info but performs no operation. -func (n NoopLogger) Info(msg string, keysAndValues ...any) {} +func (n NoopLogger) Info(msg string, keysAndValues ...any) {} + // Warn implements Logger.Warn but performs no operation. -func (n NoopLogger) Warn(msg string, keysAndValues ...any) {} +func (n NoopLogger) Warn(msg string, keysAndValues ...any) {} + // Error implements Logger.Error but performs no operation. func (n NoopLogger) Error(msg string, keysAndValues ...any) {} + // Fatal implements Logger.Fatal but performs no operation. func (n NoopLogger) Fatal(msg string, keysAndValues ...any) {} + // WithKV implements Logger.WithKV but returns the same NoopLogger instance. -func (n NoopLogger) WithKV(key string, value any) Logger { return n } +func (n NoopLogger) WithKV(key string, value any) Logger { return n } + // GetAllKV implements Logger.GetAllKV and returns an empty slice. -func (n NoopLogger) GetAllKV() []any { return []any{} } +func (n NoopLogger) GetAllKV() []any { return []any{} } + // WithName implements Logger.WithName but returns the same NoopLogger instance. -func (n NoopLogger) WithName(name string) Logger { return n } +func (n NoopLogger) WithName(name string) Logger { return n } + // Name implements Logger.Name and always returns "noop". -func (n NoopLogger) Name() string { return "noop" } +func (n NoopLogger) Name() string { return "noop" } + // AddCallerSkip implements Logger.AddCallerSkip but returns the same NoopLogger instance. -func (n NoopLogger) AddCallerSkip(skip int) Logger { return n } +func (n NoopLogger) AddCallerSkip(skip int) Logger { return n } diff --git a/pkg/rpc/api.go b/pkg/rpc/api.go index d54c98c0c..c73d29d74 100644 --- a/pkg/rpc/api.go +++ b/pkg/rpc/api.go @@ -189,18 +189,6 @@ type SignedAppStateUpdateV1 struct { QuorumSigs []string `json:"quorum_sigs"` } -// AppSessionsV1RebalanceAppSessionsRequest rebalances multiple application sessions atomically. -type AppSessionsV1RebalanceAppSessionsRequest struct { - // SignedUpdates is the list of signed application session state updates - SignedUpdates []SignedAppStateUpdateV1 `json:"signed_updates"` -} - -// AppSessionsV1RebalanceAppSessionsResponse returns the batch ID for the rebalancing operation. -type AppSessionsV1RebalanceAppSessionsResponse struct { - // BatchID is the unique identifier for this rebalancing operation - BatchID string `json:"batch_id"` -} - // AppSessionsV1GetAppDefinitionRequest retrieves the application definition for a specific app session. type AppSessionsV1GetAppDefinitionRequest struct { // AppSessionID is the application session ID @@ -290,40 +278,6 @@ type AppSessionsV1GetLastKeyStatesResponse struct { Metadata PaginationMetadataV1 `json:"metadata"` } -// ============================================================================ -// Apps Group - V1 API -// ============================================================================ - -// AppsV1GetAppsRequest retrieves registered applications with optional filtering. -type AppsV1GetAppsRequest struct { - // AppID filters by application ID - AppID *string `json:"app_id,omitempty"` - // OwnerWallet filters by owner wallet address - OwnerWallet *string `json:"owner_wallet,omitempty"` - // Pagination contains pagination parameters (offset, limit, sort) - Pagination *PaginationParamsV1 `json:"pagination,omitempty"` -} - -// AppsV1GetAppsResponse returns the list of registered applications. -type AppsV1GetAppsResponse struct { - // Apps is the list of registered applications - Apps []AppInfoV1 `json:"apps"` - // Metadata contains pagination information - Metadata PaginationMetadataV1 `json:"metadata"` -} - -// AppsV1SubmitAppVersionRequest submits a new application version (currently only creation is supported). -type AppsV1SubmitAppVersionRequest struct { - // App contains the application definition - App AppV1 `json:"app"` - // OwnerSig is the owner's signature over the packed app data - OwnerSig string `json:"owner_sig"` -} - -// AppsV1SubmitAppVersionResponse returns the result of the application version submission. -type AppsV1SubmitAppVersionResponse struct { -} - // ============================================================================ // User Group - V1 API // ============================================================================ @@ -364,18 +318,6 @@ type UserV1GetTransactionsResponse struct { Metadata PaginationMetadataV1 `json:"metadata"` } -// UserV1GetActionAllowancesRequest retrieves the current action allowances for a user. -type UserV1GetActionAllowancesRequest struct { - // Wallet is the user's wallet address - Wallet string `json:"wallet"` -} - -// UserV1GetActionAllowancesResponse returns the list of action allowances for the user. -type UserV1GetActionAllowancesResponse struct { - // Allowances is the list of action allowances for the user - Allowances []ActionAllowanceV1 `json:"allowances"` -} - // ============================================================================ // Node Group - V1 API // ============================================================================ diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go index 6e4eefa59..cf85561b3 100644 --- a/pkg/rpc/client.go +++ b/pkg/rpc/client.go @@ -181,15 +181,6 @@ func (c *Client) AppSessionsV1CreateAppSession(ctx context.Context, req AppSessi return resp, nil } -// AppSessionsV1RebalanceAppSessions rebalances multiple application sessions atomically. -func (c *Client) AppSessionsV1RebalanceAppSessions(ctx context.Context, req AppSessionsV1RebalanceAppSessionsRequest) (AppSessionsV1RebalanceAppSessionsResponse, error) { - var resp AppSessionsV1RebalanceAppSessionsResponse - if err := c.call(ctx, AppSessionsV1RebalanceAppSessionsMethod, req, &resp); err != nil { - return resp, err - } - return resp, nil -} - // AppSessionsV1Register initiates session key registration. func (c *Client) AppSessionsV1SubmitSessionKeyState(ctx context.Context, req AppSessionsV1SubmitSessionKeyStateRequest) (AppSessionsV1SubmitSessionKeyStateRequest, error) { var resp AppSessionsV1SubmitSessionKeyStateRequest @@ -208,28 +199,6 @@ func (c *Client) AppSessionsV1GetLastKeyStates(ctx context.Context, req AppSessi return resp, nil } -// ============================================================================ -// Apps Group - V1 API Methods -// ============================================================================ - -// AppsV1GetApps retrieves registered applications with optional filtering. -func (c *Client) AppsV1GetApps(ctx context.Context, req AppsV1GetAppsRequest) (AppsV1GetAppsResponse, error) { - var resp AppsV1GetAppsResponse - if err := c.call(ctx, AppsV1GetAppsMethod, req, &resp); err != nil { - return resp, err - } - return resp, nil -} - -// AppsV1SubmitAppVersion submits a new application version (currently only creation is supported). -func (c *Client) AppsV1SubmitAppVersion(ctx context.Context, req AppsV1SubmitAppVersionRequest) (AppsV1SubmitAppVersionResponse, error) { - var resp AppsV1SubmitAppVersionResponse - if err := c.call(ctx, AppsV1SubmitAppVersionMethod, req, &resp); err != nil { - return resp, err - } - return resp, nil -} - // ============================================================================ // User Group - V1 API Methods // ============================================================================ @@ -252,15 +221,6 @@ func (c *Client) UserV1GetTransactions(ctx context.Context, req UserV1GetTransac return resp, nil } -// UserV1GetActionAllowances retrieves the user's current action allowances for channels and app sessions. -func (c *Client) UserV1GetActionAllowances(ctx context.Context, req UserV1GetActionAllowancesRequest) (UserV1GetActionAllowancesResponse, error) { - var resp UserV1GetActionAllowancesResponse - if err := c.call(ctx, UserV1GetActionAllowancesMethod, req, &resp); err != nil { - return resp, err - } - return resp, nil -} - // ============================================================================ // Node Group - V1 API Methods // ============================================================================ diff --git a/pkg/rpc/client_test.go b/pkg/rpc/client_test.go index d36242663..fa8b7deb5 100644 --- a/pkg/rpc/client_test.go +++ b/pkg/rpc/client_test.go @@ -546,129 +546,6 @@ func TestClientV1_AppSessionsV1GetLastKeyStates(t *testing.T) { assert.Equal(t, "0xsession_key_1", res.States[0].SessionKey) } -// ============================================================================ -// Apps Group Tests -// ============================================================================ - -func TestClientV1_AppsV1GetApps(t *testing.T) { - t.Parallel() - - client, dialer := setupClient() - - response := rpc.AppsV1GetAppsResponse{ - Apps: []rpc.AppInfoV1{ - { - AppV1: rpc.AppV1{ - ID: "my-app", - OwnerWallet: testWalletV1, - Metadata: `{"name": "My App"}`, - Version: "1", - CreationApprovalNotRequired: true, - }, - CreatedAt: "1700000000", - UpdatedAt: "1700000001", - }, - { - AppV1: rpc.AppV1{ - ID: "another-app", - OwnerWallet: testWallet2V1, - Metadata: `{"name": "Another App"}`, - Version: "1", - CreationApprovalNotRequired: false, - }, - CreatedAt: "1700000100", - UpdatedAt: "1700000200", - }, - }, - Metadata: rpc.PaginationMetadataV1{ - Page: 1, - PerPage: 10, - TotalCount: 2, - PageCount: 1, - }, - } - - registerSimpleHandlerV1(dialer, rpc.AppsV1GetAppsMethod.String(), response) - - resp, err := client.AppsV1GetApps(testCtxV1, rpc.AppsV1GetAppsRequest{}) - require.NoError(t, err) - assert.Len(t, resp.Apps, 2) - assert.Equal(t, "my-app", resp.Apps[0].ID) - assert.Equal(t, testWalletV1, resp.Apps[0].OwnerWallet) - assert.Equal(t, `{"name": "My App"}`, resp.Apps[0].Metadata) - assert.Equal(t, "1", resp.Apps[0].Version) - assert.True(t, resp.Apps[0].CreationApprovalNotRequired) - assert.Equal(t, "1700000000", resp.Apps[0].CreatedAt) - assert.Equal(t, "1700000001", resp.Apps[0].UpdatedAt) - assert.Equal(t, uint32(2), resp.Metadata.TotalCount) -} - -func TestClientV1_AppsV1GetApps_WithFilters(t *testing.T) { - t.Parallel() - - client, dialer := setupClient() - - appID := "my-app" - ownerWallet := testWalletV1 - - response := rpc.AppsV1GetAppsResponse{ - Apps: []rpc.AppInfoV1{ - { - AppV1: rpc.AppV1{ - ID: appID, - OwnerWallet: ownerWallet, - Metadata: `{}`, - Version: "1", - }, - CreatedAt: "1700000000", - UpdatedAt: "1700000000", - }, - }, - Metadata: rpc.PaginationMetadataV1{ - Page: 1, - PerPage: 10, - TotalCount: 1, - PageCount: 1, - }, - } - - registerSimpleHandlerV1(dialer, rpc.AppsV1GetAppsMethod.String(), response) - - resp, err := client.AppsV1GetApps(testCtxV1, rpc.AppsV1GetAppsRequest{ - AppID: &appID, - OwnerWallet: &ownerWallet, - Pagination: &rpc.PaginationParamsV1{ - Offset: ptrUint32(0), - Limit: ptrUint32(10), - }, - }) - require.NoError(t, err) - assert.Len(t, resp.Apps, 1) - assert.Equal(t, appID, resp.Apps[0].ID) -} - -func TestClientV1_AppsV1SubmitAppVersion(t *testing.T) { - t.Parallel() - - client, dialer := setupClient() - - response := rpc.AppsV1SubmitAppVersionResponse{} - - registerSimpleHandlerV1(dialer, rpc.AppsV1SubmitAppVersionMethod.String(), response) - - _, err := client.AppsV1SubmitAppVersion(testCtxV1, rpc.AppsV1SubmitAppVersionRequest{ - App: rpc.AppV1{ - ID: "my-app", - OwnerWallet: testWalletV1, - Metadata: `{"name": "My App"}`, - Version: "1", - CreationApprovalNotRequired: false, - }, - OwnerSig: "0xsig123", - }) - require.NoError(t, err) -} - // ============================================================================ // User Group Tests // ============================================================================ diff --git a/pkg/rpc/connection.go b/pkg/rpc/connection.go index e213da262..7e9181ab6 100644 --- a/pkg/rpc/connection.go +++ b/pkg/rpc/connection.go @@ -400,11 +400,10 @@ func (conn *WebsocketConnection) readMessages(handleClosure func(error)) { return } - if len(messageBytes) == 0 { - conn.logger.Debug("received empty message, skipping") - continue // Skip empty messages - } - + // Charge every inbound frame against the rate limiter before any + // skip, including empty/protocol-invalid ones. Empty frames are + // malformed RPC and must still spend a request-count token, otherwise + // they can be flooded for free (MF3-L14). if !conn.frameRateLimiter.Admit(time.Now(), len(messageBytes)) { conn.logger.Warn("frame rate limit exceeded; closing connection", "origin", conn.origin, @@ -418,6 +417,11 @@ func (conn *WebsocketConnection) readMessages(handleClosure func(error)) { return } + if len(messageBytes) == 0 { + conn.logger.Debug("received empty message, skipping") + continue // Skip empty messages + } + select { case conn.processSink <- messageBytes: // ok diff --git a/pkg/rpc/connection_hub.go b/pkg/rpc/connection_hub.go index 83587761f..e689d2a60 100644 --- a/pkg/rpc/connection_hub.go +++ b/pkg/rpc/connection_hub.go @@ -169,4 +169,3 @@ func (hub *ConnectionHub) Publish(userID string, response []byte) { conn.WriteRawResponse(response) } } - diff --git a/pkg/rpc/connection_test.go b/pkg/rpc/connection_test.go index 4c5987b42..f9bfacef0 100644 --- a/pkg/rpc/connection_test.go +++ b/pkg/rpc/connection_test.go @@ -229,6 +229,38 @@ func TestWebsocketConnection_RateLimitedFrame_ClosesConnection(t *testing.T) { require.Equal(t, 2, limiter.calls, "limiter was consulted for both frames") } +func TestWebsocketConnection_EmptyFrame_ChargedByLimiter(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + wsConnMock := newGorillaWsConnMock(ctx) + limiter := &stubLimiter{rejectAt: 1} // reject the very first frame + cfg := rpc.WebsocketConnectionConfig{ + ConnectionID: "conn-emptyframe", + 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 }) + + // Empty frames are malformed RPC and must spend a request-count token + // before the empty-message skip, otherwise they flood for free (MF3-L14). + wsConnMock.addMessageToRead("") + + select { + case err := <-closureCh: + require.NoError(t, err, "rate-limit close is graceful") + case <-time.After(500 * time.Millisecond): + t.Fatal("empty frame bypassed the rate limiter") + } + require.Equal(t, 1, limiter.calls, "limiter must be consulted for empty frames") +} + func TestWebsocketConnection_ServeQueueFullClosesCleanly(t *testing.T) { t.Parallel() diff --git a/pkg/rpc/dialer.go b/pkg/rpc/dialer.go index 9adaf61f7..32496d95f 100644 --- a/pkg/rpc/dialer.go +++ b/pkg/rpc/dialer.go @@ -284,6 +284,13 @@ func (d *WebsocketDialer) Call(ctx context.Context, req *Message) (*Message, err return nil, ErrNilRequest } + // Marshal the request before registering a response sink so a marshal + // failure cannot leak entries in responseSinks. + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("%w: %w", ErrMarshalingRequest, err) + } + // Check connection and register response channel atomically d.mu.Lock() if d.dialCtx == nil || d.dialCtx.ctx.Err() != nil { @@ -296,12 +303,6 @@ func (d *WebsocketDialer) Call(ctx context.Context, req *Message) (*Message, err d.responseSinks[req.RequestID] = responseSink d.mu.Unlock() - // Marshal the request - reqJSON, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("%w: %w", ErrMarshalingRequest, err) - } - // Send the request (WebSocket writes must be serialized) d.writeMu.Lock() err = conn.WriteMessage(websocket.TextMessage, reqJSON) diff --git a/pkg/rpc/dialer_internal_test.go b/pkg/rpc/dialer_internal_test.go new file mode 100644 index 000000000..8a12995d9 --- /dev/null +++ b/pkg/rpc/dialer_internal_test.go @@ -0,0 +1,37 @@ +package rpc + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestWebsocketDialer_Call_MarshalFailureNoSinkLeak verifies that a request +// which fails to marshal does not leave a dangling entry in responseSinks. +// Regression test: marshalling must happen before the response sink is +// registered, otherwise repeated malformed calls grow the map unboundedly. +func TestWebsocketDialer_Call_MarshalFailureNoSinkLeak(t *testing.T) { + t.Parallel() + + d := NewWebsocketDialer(DefaultWebsocketDialerConfig) + + // Malformed raw JSON in the payload makes json.Marshal fail. + badPayload := Payload{"bad": json.RawMessage("{invalid")} + + const calls = 100 + for i := uint64(1); i <= calls; i++ { + req := NewRequest(i, "noop", badPayload) + _, err := d.Call(context.Background(), &req) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrMarshalingRequest), "expected ErrMarshalingRequest, got %v", err) + } + + d.mu.RLock() + pending := len(d.responseSinks) + d.mu.RUnlock() + assert.Equal(t, 0, pending, "marshal failures must not leak response sinks") +} diff --git a/pkg/rpc/error.go b/pkg/rpc/error.go index 50aa885ca..feaa3c5a0 100644 --- a/pkg/rpc/error.go +++ b/pkg/rpc/error.go @@ -122,5 +122,13 @@ func (e Error) Error() string { // // The resulting Params will contain: {"error": "invalid address format"} func NewErrorPayload(errMsg string) Payload { - return Payload{errorParamKey: json.RawMessage(fmt.Sprintf(`"%s"`, errMsg))} + // json.Marshal escapes quotes, backslashes, and control characters so the + // resulting RawMessage is always valid JSON. Building the string by hand + // would corrupt the payload whenever errMsg contains JSON-special chars. + encoded, err := json.Marshal(errMsg) + if err != nil { + // Marshaling a Go string cannot fail; fall back to an empty JSON string. + encoded = json.RawMessage(`""`) + } + return Payload{errorParamKey: encoded} } diff --git a/pkg/rpc/error_test.go b/pkg/rpc/error_test.go new file mode 100644 index 000000000..001c6c157 --- /dev/null +++ b/pkg/rpc/error_test.go @@ -0,0 +1,40 @@ +package rpc + +import ( + "encoding/json" + "testing" +) + +func TestNewErrorPayload(t *testing.T) { + cases := map[string]string{ + "plain": "invalid address format", + "embedded quote": `duplicate key value violates unique constraint "app_sessions_v1_pkey"`, + "backslash": `path\to\thing`, + "newline": "line1\nline2", + "control char": "tab\there", + "leading quote": `"quoted"`, + "empty": "", + } + + for name, msg := range cases { + t.Run(name, func(t *testing.T) { + payload := NewErrorPayload(msg) + + // The stored value must be valid JSON that round-trips to the + // original string. + var got string + if err := json.Unmarshal(payload[errorParamKey], &got); err != nil { + t.Fatalf("stored value is not valid JSON: %v", err) + } + if got != msg { + t.Fatalf("round-trip mismatch: got %q, want %q", got, msg) + } + + // The whole payload must marshal without error. This is the + // regression guard for the websocket-response marshal failure. + if _, err := json.Marshal(payload); err != nil { + t.Fatalf("payload failed to marshal: %v", err) + } + }) + } +} diff --git a/pkg/rpc/methods.go b/pkg/rpc/methods.go index 1d108e024..2fff5809a 100644 --- a/pkg/rpc/methods.go +++ b/pkg/rpc/methods.go @@ -21,23 +21,16 @@ const ( AppSessionsV1Group Group = "app_sessions.v1" AppSessionsV1SubmitDepositStateMethod Method = "app_sessions.v1.submit_deposit_state" AppSessionsV1SubmitAppStateMethod Method = "app_sessions.v1.submit_app_state" - AppSessionsV1RebalanceAppSessionsMethod Method = "app_sessions.v1.rebalance_app_sessions" AppSessionsV1GetAppDefinitionMethod Method = "app_sessions.v1.get_app_definition" AppSessionsV1GetAppSessionsMethod Method = "app_sessions.v1.get_app_sessions" AppSessionsV1CreateAppSessionMethod Method = "app_sessions.v1.create_app_session" AppSessionsV1SubmitSessionKeyStateMethod Method = "app_sessions.v1.submit_session_key_state" AppSessionsV1GetLastKeyStatesMethod Method = "app_sessions.v1.get_last_key_states" - // Apps Group - V1 Methods - AppsV1Group Group = "apps.v1" - AppsV1GetAppsMethod Method = "apps.v1.get_apps" - AppsV1SubmitAppVersionMethod Method = "apps.v1.submit_app_version" - // User Group - V1 Methods - UserV1Group Group = "user.v1" - UserV1GetBalancesMethod Method = "user.v1.get_balances" - UserV1GetTransactionsMethod Method = "user.v1.get_transactions" - UserV1GetActionAllowancesMethod Method = "user.v1.get_action_allowances" + UserV1Group Group = "user.v1" + UserV1GetBalancesMethod Method = "user.v1.get_balances" + UserV1GetTransactionsMethod Method = "user.v1.get_transactions" // Node Group - V1 Methods NodeV1Group Group = "node.v1" diff --git a/pkg/rpc/rate_limiter.go b/pkg/rpc/rate_limiter.go index dae245d0a..4712b1836 100644 --- a/pkg/rpc/rate_limiter.go +++ b/pkg/rpc/rate_limiter.go @@ -68,3 +68,67 @@ func (b *ByteTokenBucket) Admit(now time.Time, size int) bool { b.tokens -= cost return true } + +// RequestTokenBucket is a token bucket on frame COUNT: one token per frame, +// regardless of size. One bucket per connection; not safe for concurrent use, +// the connection's read goroutine is the sole caller of Admit. +// +// It complements ByteTokenBucket: bytes guard bandwidth, request count guards +// RPC throughput so a flood of tiny frames — including malformed or +// unknown-method frames that never reach the handler chain — cannot drive CPU +// past the intended rate while staying under the byte cap. +type RequestTokenBucket struct { + perSec float64 + burst float64 + tokens float64 + last time.Time +} + +// NewRequestTokenBucket returns a bucket pre-filled to burst capacity. +// +// perSec is the steady-state refill rate in frames per second. +// burst is the maximum bucket size; with burst < 1 no frame is ever admitted. +func NewRequestTokenBucket(perSec, burst float64) *RequestTokenBucket { + return &RequestTokenBucket{ + perSec: perSec, + burst: burst, + tokens: burst, + } +} + +// Admit refills tokens for elapsed time, caps at burst, then debits one token. +// The frame size is ignored. Returns false when fewer than one token remains. +func (b *RequestTokenBucket) Admit(now time.Time, _ int) bool { + if !b.last.IsZero() { + b.tokens += now.Sub(b.last).Seconds() * b.perSec + if b.tokens > b.burst { + b.tokens = b.burst + } + } + b.last = now + + if b.tokens < 1 { + return false + } + b.tokens-- + return true +} + +// CompositeFrameRateLimiter admits a frame only when every member admits it. +// Members are consulted in order and short-circuit on the first rejection; +// because a rejection closes the connection, a member debited before a later +// member rejects is harmless. nil members are skipped. +type CompositeFrameRateLimiter []FrameRateLimiter + +// Admit returns false as soon as any member rejects the frame. +func (c CompositeFrameRateLimiter) Admit(now time.Time, size int) bool { + for _, l := range c { + if l == nil { + continue + } + if !l.Admit(now, size) { + return false + } + } + return true +} diff --git a/pkg/rpc/rate_limiter_test.go b/pkg/rpc/rate_limiter_test.go index cc5b3e2fd..12169f0b2 100644 --- a/pkg/rpc/rate_limiter_test.go +++ b/pkg/rpc/rate_limiter_test.go @@ -70,3 +70,75 @@ func TestNoopFrameRateLimiter_AdmitsAll(t *testing.T) { require.True(t, lim.Admit(time.Now(), 1<<30)) require.True(t, lim.Admit(time.Time{}, 0)) } + +func TestRequestTokenBucket_BurstThenEmpty(t *testing.T) { + t.Parallel() + + b := rpc.NewRequestTokenBucket(10, 3) + base := time.Unix(0, 0) + + // Frame size is ignored: each frame costs exactly one token. + require.True(t, b.Admit(base, 9999)) + require.True(t, b.Admit(base, 0)) + require.True(t, b.Admit(base, 1)) + require.False(t, b.Admit(base, 1), "fourth frame exceeds burst of 3") +} + +func TestRequestTokenBucket_Refill(t *testing.T) { + t.Parallel() + + b := rpc.NewRequestTokenBucket(10, 2) // 10 frames/sec => 1 token per 100ms + base := time.Unix(0, 0) + + require.True(t, b.Admit(base, 1)) + require.True(t, b.Admit(base, 1)) + require.False(t, b.Admit(base, 1), "bucket emptied") + require.False(t, b.Admit(base.Add(50*time.Millisecond), 1), "50ms < 100ms, no token yet") + require.True(t, b.Admit(base.Add(100*time.Millisecond), 1), "100ms refills one token") +} + +func TestRequestTokenBucket_BurstCap(t *testing.T) { + t.Parallel() + + b := rpc.NewRequestTokenBucket(10, 3) + base := time.Unix(0, 0) + + // Long idle must not let tokens grow past burst. + require.True(t, b.Admit(base.Add(time.Hour), 1)) + require.True(t, b.Admit(base.Add(time.Hour), 1)) + require.True(t, b.Admit(base.Add(time.Hour), 1)) + require.False(t, b.Admit(base.Add(time.Hour), 1), "capped at burst of 3") +} + +func TestRequestTokenBucket_SubUnitBurstRejectsAll(t *testing.T) { + t.Parallel() + + b := rpc.NewRequestTokenBucket(10, 0.5) + require.False(t, b.Admit(time.Unix(0, 0), 1), "burst < 1 admits no frame") +} + +func TestCompositeFrameRateLimiter_RejectsWhenAnyMemberRejects(t *testing.T) { + t.Parallel() + + base := time.Unix(0, 0) + // Byte budget is generous; request budget is the binding constraint. + lim := rpc.CompositeFrameRateLimiter{ + rpc.NewByteTokenBucket(1<<20, 1<<20), + rpc.NewRequestTokenBucket(10, 2), + } + + require.True(t, lim.Admit(base, 50)) + require.True(t, lim.Admit(base, 50)) + require.False(t, lim.Admit(base, 50), "request bucket exhausted closes the frame out") +} + +func TestCompositeFrameRateLimiter_EmptyAndNilMembers(t *testing.T) { + t.Parallel() + + require.True(t, rpc.CompositeFrameRateLimiter{}.Admit(time.Unix(0, 0), 100), + "no members admits everything") + + lim := rpc.CompositeFrameRateLimiter{nil, rpc.NewRequestTokenBucket(10, 1)} + require.True(t, lim.Admit(time.Unix(0, 0), 1), "nil members are skipped") + require.False(t, lim.Admit(time.Unix(0, 0), 1), "non-nil member still enforced") +} diff --git a/pkg/rpc/types.go b/pkg/rpc/types.go index 0e309824a..a1bb5ce7a 100644 --- a/pkg/rpc/types.go +++ b/pkg/rpc/types.go @@ -224,33 +224,6 @@ type AppSessionKeyStateV1 struct { SessionKeySig string `json:"session_key_sig"` } -// ============================================================================ -// App Registry Types -// ============================================================================ - -// AppV1 represents a registered application definition (without timestamps). -type AppV1 struct { - // ID is the application identifier - ID string `json:"id"` - // OwnerWallet is the owner's wallet address - OwnerWallet string `json:"owner_wallet"` - // Metadata is the application metadata - Metadata string `json:"metadata"` - // Version is the current version - Version string `json:"version"` - // CreationApprovalNotRequired indicates if sessions can be created without approval - CreationApprovalNotRequired bool `json:"creation_approval_not_required"` -} - -// AppInfoV1 represents full application info including timestamps. -type AppInfoV1 struct { - AppV1 - // CreatedAt is the creation timestamp (unix seconds) - CreatedAt string `json:"created_at"` - // UpdatedAt is the last update timestamp (unix seconds) - UpdatedAt string `json:"updated_at"` -} - // ============================================================================ // Asset and Blockchain Types // ============================================================================ @@ -291,8 +264,9 @@ type BlockchainInfoV1 struct { BlockchainID string `json:"blockchain_id"` // ChannelHubAddress is the contract address on this network ChannelHubAddress string `json:"channel_hub_address"` - // LockingContractAddress is the contract address for the locking contract on this network - LockingContractAddress string `json:"locking_contract_address"` + // ConfirmationDelaySecs is the number of seconds the node waits before crediting a deposit event. + // Zero means the gate is disabled and events are processed immediately. + ConfirmationDelaySecs uint32 `json:"confirmation_delay_secs"` } // ============================================================================ @@ -331,22 +305,6 @@ type TransactionV1 struct { CreatedAt string `json:"created_at"` } -// ============================================================================ -// Action Gateway Types -// ============================================================================ - -// ActionAllowanceV1 represents the allowance information for a specific gated action. -type ActionAllowanceV1 struct { - // GatedAction is the specific action being gated (e.g. transfer, app_operation) - GatedAction core.GatedAction `json:"gated_action"` - // TimeWindow is the time window for which the allowance is valid (e.g. "1h", "24h") - TimeWindow string `json:"time_window"` - // Allowance is the total allowance for the gated action within the time window - Allowance string `json:"allowance"` - // Used is the amount of the allowance that has already been used within the time window - Used string `json:"used"` -} - // ============================================================================ // Pagination Types // ============================================================================ diff --git a/pkg/sign/mock_signer_test.go b/pkg/sign/mock_signer_test.go index e6a13ad9a..460f2c9e5 100644 --- a/pkg/sign/mock_signer_test.go +++ b/pkg/sign/mock_signer_test.go @@ -8,18 +8,18 @@ import ( func TestMockSigner(t *testing.T) { signer := NewMockSigner("test-id") data := []byte("test data") - + // Test Sign sig, err := signer.Sign(data) if err != nil { t.Fatalf("Sign failed: %v", err) } - + expectedSig := []byte("test data-signed-by-test-id") if !bytes.Equal(sig, expectedSig) { t.Errorf("got signature %q, want %q", sig, expectedSig) } - + // Test PublicKey pk := signer.PublicKey() if pk.Address().String() != "test-id" { @@ -29,12 +29,12 @@ func TestMockSigner(t *testing.T) { func TestMockPublicKey(t *testing.T) { pk := NewMockPublicKey("key-id") - + // Test Address if pk.Address().String() != "key-id" { t.Errorf("got address %q, want %q", pk.Address().String(), "key-id") } - + // Test Bytes if !bytes.Equal(pk.Bytes(), []byte("key-id")) { t.Errorf("got bytes %q, want %q", pk.Bytes(), []byte("key-id")) @@ -45,18 +45,18 @@ func TestMockAddress(t *testing.T) { addr1 := NewMockAddress("addr1") addr2 := NewMockAddress("addr1") addr3 := NewMockAddress("addr2") - + // Test String if addr1.String() != "addr1" { t.Errorf("got string %q, want %q", addr1.String(), "addr1") } - + // Test Equals if !addr1.Equals(addr2) { t.Error("addr1 should equal addr2") } - + if addr1.Equals(addr3) { t.Error("addr1 should not equal addr3") } -} \ No newline at end of file +} diff --git a/playground/README.md b/playground/README.md index 79de7a475..62f5a7d2a 100644 --- a/playground/README.md +++ b/playground/README.md @@ -27,6 +27,14 @@ Only one env var: Supported chains are discovered from the Nitronode's `getConfig()`. Public RPC URLs for each chain are looked up at runtime; override via `src/networks.ts` if a chain is missing or you want a private endpoint. +## On-chain confirmation delay + +Each chain reported by the Nitronode's `getConfig()` includes `confirmationDelaySecs`. After an on-chain +operation (deposit, withdraw, close) the node waits that many seconds before crediting the result to your +off-chain balance — a reorg-safety gate. The transaction is mined first; the balance updates only after +the delay (a few seconds where the gate is enabled, up to ~13 min on Ethereum L1; `0` = disabled). +Off-chain transfers are not gated and reflect immediately. + ## Layout | File | Purpose | @@ -65,7 +73,10 @@ ERC-20 approvals use the "infinite approve" pattern: the first deposit for a tok - Per-asset SK scoping (always all assets). - Transaction history panel. - App sessions. -- Auto-polling. State is refreshed after each operation and on the refresh button. +- Auto-polling. State is refreshed after each operation and on the refresh button. Note: a deposit's + off-chain credit lags the on-chain tx by the chain's confirmation delay (see "On-chain confirmation + delay" above), so a refresh immediately after a deposit may not show the new balance yet — refresh + again once the delay has elapsed. See `playground/TODO.md` (in the original design pack) for the full deferred list. diff --git a/playground/REFERENCE.md b/playground/REFERENCE.md index 5a97de3fc..d8b8c9366 100644 --- a/playground/REFERENCE.md +++ b/playground/REFERENCE.md @@ -68,7 +68,8 @@ Tab selector only appears when a wallet is connected. Switching tabs preserves s - Status badges: "wrong chain" (wallet is on a different chain), "closed" - Expand/collapse to see channel state detail (StateViewer) or a closed notice - Inline prompt to switch wallet chain when it doesn't match the channel's home chain -- Close channel button +- Close channel button: disabled with spinner during both `isClosing` (tx signing/mining) and `isAwaitingClose` (confirmation-window wait); if `isAwaitingClose` and `delaySecs > 0`, shows tooltip "Waiting for confirmation (~Xs)…" +- Resolves `delaySecs: number` from `channel.blockchainId` + `chains` and passes it to `StateViewer`; also passes `isLocked={isClosing || isAwaitingClose}` so StateViewer locks all actions during close - Does **not** show a session-key selector — the active SK is wallet-global (not per-channel); manage it via WalletBar chip + Session Keys tab ### StateViewer @@ -78,7 +79,7 @@ Tab selector only appears when a wallet is connected. Switching tabs preserves s - **Issued** — node has proposed; needs acknowledgement before it becomes signed - Each layer shows version number and balance - **Acknowledge** button: accepts issued state (moves to signed) -- **Checkpoint** button: commits signed state on-chain +- **Checkpoint** button: commits signed state on-chain; while `isAwaitingConfirmation` is true (confirmation-window poll), the button is disabled with a spinner and a tooltip showing "Waiting for confirmation (~Xs)…"; accepts `delaySecs: number` from ChannelRow ### IncomingChannelRow **For**: An asset that has an issued (node-proposed) state but no acknowledged channel yet. @@ -159,14 +160,20 @@ Tab selector only appears when a wallet is connected. Switching tabs preserves s **Owns**: The three-layer state (enforced / signed / issued) for one channel. - Determines whether Acknowledge and Checkpoint actions are available - Runs acknowledge (sign + upload) and checkpoint (on-chain transaction) operations +- Checkpoint flow: submits tx → waits for receipt → if `delaySecs > 0`, polls `getHomeChannel` until `stateVersion >= targetVersion` (bounded by `min(delaySecs, 60) + 15s`), then refreshes; on timeout shows a soft fallback toast +- Exposes `isAwaitingConfirmation: boolean` (true during the post-receipt confirmation-window poll) and `confirmationDelaySecs: number` for UI labeling +- Accepts `delaySecs: number` (resolved by caller from `channel.blockchainId` + `supportedChains`); guards in-flight polls against wallet/asset change via a generation counter (`checkpointGenRef`) - Refreshes balances after each successful operation ### useChannelOps **Owns**: High-level channel operations triggered from ActionPanel. -- Deposit: token allowance check → approve if needed → deposit → checkpoint -- Withdraw: withdraw → checkpoint +- Deposit: token allowance check → approve if needed → deposit → checkpoint → wait for receipt (`confirming`) → if `confirmationDelaySecs > 0`, enter `awaiting_node` phase and poll `getBalances` until enforced balance rises (bounded by `min(confirmationDelaySecs, 60) + 15s`), then emit success or soft-fallback toast +- Withdraw: withdraw → checkpoint → wait for receipt (`confirming`) → if `confirmationDelaySecs > 0`, enter `awaiting_node` and poll enforced balance down; same cap and fallback as deposit - Transfer: transfer → if home chain missing, shows modal → retries after chain is set -- Close: close channel → checkpoint +- Close: close channel → checkpoint → wait for receipt → if `confirmationDelaySecs > 0`, sets `awaitingCloseAsset` and polls enforced balance down (same bounded wait as deposit/withdraw); clears `awaitingCloseAsset` before calling `onAfterOp`/`onAfterTxMined` so the channel-list refresh happens only after the window +- `DepositPhase` and `WithdrawPhase` include `'awaiting_node'` after `'confirming'`; gate-disabled (`delaySecs === 0`) flows skip `'awaiting_node'` and emit the immediate success toast +- Accepts `supportedChains: Blockchain[]` to look up per-chain `confirmationDelaySecs` +- Exposes `awaitingCloseAsset: string | null` (the asset whose close is in the confirmation-window wait); distinct from `closingAsset` (which covers the tx-signing and mining phases) - Tracks operation loading states per action; cancels stale ops on address/wallet change - Distinguishes user rejections (MetaMask code 4001) from real errors for toast messaging @@ -225,16 +232,16 @@ Tab selector only appears when a wallet is connected. Switching tabs preserves s 1. "Connect MetaMask" prompt in body → connect → node probes and client builds → channels load **Deposit** -1. Select asset + amount → Deposit → MetaMask: approve token spend (once) → MetaMask: deposit transaction → checkpoint written on-chain +1. Select asset + amount → Deposit → MetaMask: approve token spend (once) → MetaMask: deposit transaction → checkpoint written on-chain → wait for tx receipt → if `confirmationDelaySecs > 0`, poll for enforced balance credit (up to `min(delaySecs, 60) + 15s`) → success toast (or soft-fallback if poll times out) **Withdraw** -1. Select asset + amount → Withdraw → MetaMask: withdraw transaction → checkpoint written on-chain +1. Select asset + amount → Withdraw → MetaMask: withdraw transaction → checkpoint written on-chain → wait for tx receipt → if `confirmationDelaySecs > 0`, poll for enforced balance decrease → success toast (or soft-fallback) **Transfer** 1. Select asset + amount + recipient address → Transfer → if no home chain set, modal appears → confirm chain → transfer proceeds **Close channel** -1. In ActionPanel or ChannelRow → Close → MetaMask: close transaction → checkpoint +1. In ChannelRow → Close channel → MetaMask: close transaction → checkpoint → wait for tx receipt → if `delaySecs > 0`, button shows spinner with "Waiting for confirmation (~Xs)…" tooltip (`isAwaitingClose` phase) while polling enforced balance → channel list and status refresh once node reflects the close (or soft fallback toast on timeout) **Session key setup** 1. Banner appears → "Set up" → navigates to Session Keys tab → "Register New" → `SessionKeyRegisterForm` → confirm → one MetaMask signature → key stored locally + submitted to node → future state ops (acknowledge, checkpoint) skip MetaMask @@ -249,7 +256,7 @@ Tab selector only appears when a wallet is connected. Switching tabs preserves s 1. Expand channel → StateViewer → Acknowledge button (issued row) → signs with session key or wallet → issued becomes signed **Checkpoint signed state** -1. Expand channel → StateViewer → Checkpoint button (signed row) → MetaMask: on-chain transaction → signed becomes enforced +1. Expand channel → StateViewer → Checkpoint button (signed row) → MetaMask: on-chain transaction → wait for tx receipt → if `delaySecs > 0`, button shows spinner with "Waiting for confirmation (~Xs)…" tooltip while polling the node → enforced row updates and Checkpoint button disappears once the node reflects the new stateVersion (or after timeout, with a soft fallback toast) **Accept incoming channel** 1. Channels section → expand IncomingChannelRow → Acknowledge → `setHomeBlockchain` sets wallet chain as home → `acknowledge` co-signs the issued state → channel appears as a regular ChannelRow diff --git a/playground/mockups/history/history-tab.md b/playground/mockups/history/history-tab.md index 61f5f2cee..8eb202595 100644 --- a/playground/mockups/history/history-tab.md +++ b/playground/mockups/history/history-tab.md @@ -34,7 +34,7 @@ The History tab is a **full-width view** that replaces the two-column (ActionPan | Withdraw | Red | | Transfer | Blue | | Finalize | Purple | -| Commit / Release / Rebalance | Muted gray | +| Commit / Release | Muted gray | ### Sort order @@ -153,7 +153,6 @@ enum TransactionType { Transfer = 30, Commit = 40, Release = 41, - Rebalance = 42, Migrate = 100, EscrowLock = 110, MutualLock = 120, diff --git a/playground/src/App.tsx b/playground/src/App.tsx index 8cff8d6a7..967ea6b94 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -30,7 +30,7 @@ export default function App() { const [channelStatesKey, setChannelStatesKey] = useState(0); const bumpChannelStates = useCallback(() => setChannelStatesKey(k => k + 1), []); - const ops = useChannelOps(nitro.client, wallet.address, nitro.supportedAssets, refreshAll, bumpChannelStates); + const ops = useChannelOps(nitro.client, wallet.address, nitro.supportedAssets, nitro.supportedChains, refreshAll, bumpChannelStates); const [selectedAsset, setSelectedAsset] = useState(''); @@ -141,6 +141,7 @@ export default function App() { balances={nitro.balances} isLoading={channels.isLoading} closingAsset={ops.closingAsset} + awaitingCloseAsset={ops.awaitingCloseAsset} onRefresh={refreshAll} onClose={ops.closeChannel} onSwitchToHomeChain={wallet.switchChain} diff --git a/playground/src/components/ActionPanel.tsx b/playground/src/components/ActionPanel.tsx index 1e9f78eff..5ed79acde 100644 --- a/playground/src/components/ActionPanel.tsx +++ b/playground/src/components/ActionPanel.tsx @@ -77,6 +77,7 @@ export default function ActionPanel({ ? currentChainId : asset?.suggestedBlockchainId ?? null; const operatingChain = chains.find(c => c.id === operatingChainId); + const confirmationDelaySecs = operatingChain?.confirmationDelaySecs ?? 0; const operatingChainName = operatingChain ? chainDisplayName(operatingChain.id, operatingChain.name) : undefined; @@ -228,16 +229,18 @@ export default function ActionPanel({ case 'approving': return 'Approving…'; case 'signing_state': return channelExists ? 'Signing deposit state…' : 'Signing creation state…'; case 'signing_tx': return 'Signing deposit transaction…'; - case 'confirming': return 'Depositing…'; - default: return needsApproval ? 'Approve' : 'Deposit'; + case 'confirming': return 'Tx mined, confirming…'; + case 'awaiting_node': return `Awaiting node (~${confirmationDelaySecs}s)…`; + default: return needsApproval ? 'Approve' : 'Deposit'; } } if (tab === 'withdraw') { switch (withdrawPhase) { case 'signing_state': return 'Signing withdrawal state…'; case 'signing_tx': return 'Sending withdrawal transaction…'; - case 'confirming': return 'Withdrawing…'; - default: return 'Withdraw'; + case 'confirming': return 'Tx mined, confirming…'; + case 'awaiting_node': return `Awaiting node (~${confirmationDelaySecs}s)…`; + default: return 'Withdraw'; } } // transfer @@ -350,6 +353,12 @@ export default function ActionPanel({

)} + {confirmationDelaySecs > 0 && (tab === 'deposit' || tab === 'withdraw') && ( +

+ Off-chain balance updates ~{confirmationDelaySecs}s after the transaction is mined. +

+ )} + +
+
+ + {isAwaitingClose && delaySecs > 0 && ( + {`Waiting for confirmation (~${delaySecs}s)…`} + )} +
)} @@ -145,8 +155,9 @@ export default function ChannelRow({ address={address} asset={channel.asset} enforcedBalance={enforcedBalance} + delaySecs={delaySecs} onAfterOp={onAfterOp} - isLocked={isClosing} + isLocked={isClosing || isAwaitingClose} refreshKey={channelStatesKey} /> )} diff --git a/playground/src/components/HistoryTab.tsx b/playground/src/components/HistoryTab.tsx index 58b86d7e1..dffefd567 100644 --- a/playground/src/components/HistoryTab.tsx +++ b/playground/src/components/HistoryTab.tsx @@ -39,7 +39,6 @@ const TX_LABELS: Partial> = { [TransactionType.Transfer]: 'Transfer', [TransactionType.Commit]: 'Commit', [TransactionType.Release]: 'Release', - [TransactionType.Rebalance]: 'Rebalance', [TransactionType.Migrate]: 'Migrate', [TransactionType.EscrowLock]: 'Escrow Lock', [TransactionType.MutualLock]: 'Mutual Lock', @@ -54,7 +53,6 @@ const ALL_TX_TYPES = [ TransactionType.Transfer, TransactionType.Commit, TransactionType.Release, - TransactionType.Rebalance, TransactionType.Migrate, TransactionType.EscrowLock, TransactionType.MutualLock, diff --git a/playground/src/components/StateViewer.tsx b/playground/src/components/StateViewer.tsx index 50128dcda..7aab2fccb 100644 --- a/playground/src/components/StateViewer.tsx +++ b/playground/src/components/StateViewer.tsx @@ -9,12 +9,13 @@ interface Props { address: Address | null; asset: string; enforcedBalance: Decimal | null | undefined; + delaySecs: number; onAfterOp?: () => void; isLocked?: boolean; refreshKey?: number; } -export default function StateViewer({ client, address, asset, enforcedBalance, onAfterOp, isLocked, refreshKey }: Props) { +export default function StateViewer({ client, address, asset, enforcedBalance, delaySecs, onAfterOp, isLocked, refreshKey }: Props) { const { enforced, signed, @@ -27,7 +28,9 @@ export default function StateViewer({ client, address, asset, enforcedBalance, o checkpoint, isAcknowledging, isCheckpointing, - } = useChannelStates(client, address, asset, enforcedBalance, onAfterOp, refreshKey); + isAwaitingConfirmation, + confirmationDelaySecs, + } = useChannelStates(client, address, asset, enforcedBalance, delaySecs, onAfterOp, refreshKey); if (isLoading && !enforced && !signed && !issued) { return
Loading states…
; @@ -64,10 +67,19 @@ export default function StateViewer({ client, address, asset, enforcedBalance, o amount={signed?.homeLedger.userBalance} asset={asset} action={ - canCheckpoint ? ( - + canCheckpoint || isAwaitingConfirmation ? ( +
+ + {isAwaitingConfirmation && ( + {`Waiting for confirmation (~${confirmationDelaySecs}s)…`} + )} +
) : null } /> diff --git a/playground/src/hooks/useChannelOps.tsx b/playground/src/hooks/useChannelOps.tsx index 3df78bce2..bdef28266 100644 --- a/playground/src/hooks/useChannelOps.tsx +++ b/playground/src/hooks/useChannelOps.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { showErrorToast } from "../toastError"; -import type { Client, Asset } from '@yellow-org/sdk'; +import type { Client, Asset, Blockchain } from '@yellow-org/sdk'; import { TransitionType } from '@yellow-org/sdk'; import { Decimal } from 'decimal.js'; import type { Address, Hash } from 'viem'; @@ -14,8 +14,8 @@ interface PendingTransfer { amount: Decimal; } -export type DepositPhase = 'idle' | 'approving' | 'signing_state' | 'signing_tx' | 'confirming'; -export type WithdrawPhase = 'idle' | 'signing_state' | 'signing_tx' | 'confirming'; +export type DepositPhase = 'idle' | 'approving' | 'signing_state' | 'signing_tx' | 'confirming' | 'awaiting_node'; +export type WithdrawPhase = 'idle' | 'signing_state' | 'signing_tx' | 'confirming' | 'awaiting_node'; export type TransferPhase = 'idle' | 'signing_state'; export interface UseChannelOpsResult { @@ -29,6 +29,8 @@ export interface UseChannelOpsResult { needsApproval: boolean | null; checkDepositAllowance: (blockchainId: bigint, asset: string, amount: Decimal) => Promise; closingAsset: string | null; + /** Asset currently in the post-receipt confirmation-window wait after a close tx. */ + awaitingCloseAsset: string | null; homechainModalAsset: string | null; onHomechainSelected: (asset: string, chainId: bigint) => Promise; onHomechainModalDismiss: () => void; @@ -38,6 +40,7 @@ export function useChannelOps( client: Client | null, address: Address | null, supportedAssets: Asset[], + supportedChains: Blockchain[], onAfterOp?: () => void, onAfterTxMined?: () => void, ): UseChannelOpsResult { @@ -46,6 +49,7 @@ export function useChannelOps( const [transferPhase, setTransferPhase] = useState('idle'); const [needsApproval, setNeedsApproval] = useState(null); const [closingAsset, setClosingAsset] = useState(null); + const [awaitingCloseAsset, setAwaitingCloseAsset] = useState(null); const [homechainModalAsset, setHomechainModalAsset] = useState(null); const pendingTransferRef = useRef(null); @@ -82,6 +86,38 @@ export function useChannelOps( [client, address, tokenInfoFor], ); + // Cap active polling so a large hard-finality delay (e.g. 780s) never holds the spinner that + // long. Covers the production "quick" values (Eth 36s, Polygon 5s, BNB 2s) in full; for larger + // delays we fall back to the soft toast and let the normal refresh surface the credit later. + const MAX_ACTIVE_WAIT_SECS = 60; + + const waitForCredit = useCallback( + async (asset: string, delaySecs: number, baselineEnforced: Decimal | null, direction: 'up' | 'down', gen: number): Promise => { + if (!client || !address || delaySecs <= 0) return true; // gate disabled → nothing to wait for + // No reliable baseline (the pre-op read failed): we cannot tell a pre-existing balance + // from a freshly-credited one, so don't guess. Fall back to the soft toast. + if (baselineEnforced === null) return false; + const activeWaitSecs = Math.min(delaySecs, MAX_ACTIVE_WAIT_SECS); + const deadline = Date.now() + (activeWaitSecs + 15) * 1000; // capped delay + buffer for block time / RPC lag + const intervalMs = 2000; + while (Date.now() < deadline) { + if (generationRef.current !== gen) return false; // wallet changed mid-wait + await new Promise(r => setTimeout(r, intervalMs)); + if (generationRef.current !== gen) return false; + try { + const entries = await client.getBalances(address); + const e = entries.find(x => x.asset === asset); + if (e) { + const moved = direction === 'up' ? e.enforced.gt(baselineEnforced) : e.enforced.lt(baselineEnforced); + if (moved) return true; + } + } catch { /* transient RPC; keep polling until deadline */ } + } + return false; // timed out — caller shows a softer message + }, + [client, address], + ); + const handleError = (err: unknown, label: string) => { // EIP-1193 user rejection: code 4001 const e = err as { code?: number; message?: string }; @@ -101,6 +137,9 @@ export function useChannelOps( const tokenInfo = tokenInfoFor(asset, blockchainId); if (!tokenInfo) throw new Error(`token not found for ${asset} on chain ${blockchainId}`); + const chain = supportedChains.find(c => c.id === blockchainId); + const delaySecs = chain?.confirmationDelaySecs ?? 0; + const isNative = /^0x0+$/i.test(tokenInfo.address); if (!isNative) { const allowance = await client.checkTokenAllowance(blockchainId, tokenInfo.address, address); @@ -125,6 +164,14 @@ export function useChannelOps( } } + let baselineEnforced: Decimal | null = new Decimal(0); + if (delaySecs > 0) { + try { + const entries = await client.getBalances(address); + baselineEnforced = entries.find(e => e.asset === asset)?.enforced ?? new Decimal(0); + } catch { baselineEnforced = null; /* unknown baseline → waitForCredit falls back to soft toast */ } + } + setDepositPhase('signing_state'); await client.deposit(blockchainId, asset, amount); if (generationRef.current !== gen) return; @@ -139,16 +186,30 @@ export function useChannelOps( await depositClient.waitForTransactionReceipt({ hash: depositTxHash as Hash }); if (generationRef.current !== gen) return; } - toast.success(`Deposited ${amount.toString()} ${asset}`); - onAfterOp?.(); // updates unified balance and on-chain balance - onAfterTxMined?.(); // forces channel-states refresh (enforced version, checkpoint button) + if (delaySecs > 0) { + toast(`Tx mined — awaiting node (~${delaySecs}s)…`); + setDepositPhase('awaiting_node'); + onAfterTxMined?.(); // refresh signed/issued states + checkpoint button now; credit not yet enforced + const credited = await waitForCredit(asset, delaySecs, baselineEnforced, 'up', gen); + if (generationRef.current !== gen) return; + if (credited) { + toast.success(`Deposited ${amount.toString()} ${asset}`); + } else { + toast(`Deposit submitted — credit will appear after node confirmation (~${delaySecs}s)`); + } + onAfterOp?.(); + } else { + toast.success(`Deposited ${amount.toString()} ${asset}`); + onAfterOp?.(); // updates unified balance and on-chain balance + onAfterTxMined?.(); // forces channel-states refresh (enforced version, checkpoint button) + } } catch (err) { handleError(err, 'Deposit'); } finally { setDepositPhase('idle'); } }, - [client, address, tokenInfoFor, onAfterOp, onAfterTxMined], + [client, address, tokenInfoFor, supportedChains, waitForCredit, onAfterOp, onAfterTxMined], ); const withdraw = useCallback( @@ -157,6 +218,17 @@ export function useChannelOps( const gen = generationRef.current; setWithdrawPhase('signing_state'); try { + const chain = supportedChains.find(c => c.id === blockchainId); + const delaySecs = chain?.confirmationDelaySecs ?? 0; + + let baselineEnforced: Decimal | null = new Decimal(0); + if (delaySecs > 0) { + try { + const entries = await client.getBalances(address); + baselineEnforced = entries.find(e => e.asset === asset)?.enforced ?? new Decimal(0); + } catch { baselineEnforced = null; /* unknown baseline → waitForCredit falls back to soft toast */ } + } + await client.withdraw(blockchainId, asset, amount); if (generationRef.current !== gen) return; onAfterOp?.(); // unified balance updates immediately after state is signed @@ -171,16 +243,30 @@ export function useChannelOps( await withdrawClient.waitForTransactionReceipt({ hash: withdrawTxHash as Hash }); if (generationRef.current !== gen) return; } - toast.success(`Withdrew ${amount.toString()} ${asset}`); - onAfterOp?.(); // updates unified balance and on-chain balance - onAfterTxMined?.(); // forces channel-states refresh (enforced version, checkpoint button) + if (delaySecs > 0) { + toast(`Tx mined — awaiting node (~${delaySecs}s)…`); + setWithdrawPhase('awaiting_node'); + onAfterTxMined?.(); // refresh signed/issued states + checkpoint button now; credit not yet enforced + const credited = await waitForCredit(asset, delaySecs, baselineEnforced, 'down', gen); + if (generationRef.current !== gen) return; + if (credited) { + toast.success(`Withdrew ${amount.toString()} ${asset}`); + } else { + toast(`Withdrawal submitted — on-chain settlement after node confirmation (~${delaySecs}s)`); + } + onAfterOp?.(); + } else { + toast.success(`Withdrew ${amount.toString()} ${asset}`); + onAfterOp?.(); // updates unified balance and on-chain balance + onAfterTxMined?.(); // forces channel-states refresh (enforced version, checkpoint button) + } } catch (err) { handleError(err, 'Withdraw'); } finally { setWithdrawPhase('idle'); } }, - [client, address, onAfterOp, onAfterTxMined], + [client, address, supportedChains, waitForCredit, onAfterOp, onAfterTxMined], ); const performTransfer = useCallback( @@ -249,6 +335,18 @@ export function useChannelOps( setClosingAsset(asset); try { toast('Closing channel…'); + + const chain = supportedChains.find(c => c.id === blockchainId); + const delaySecs = chain?.confirmationDelaySecs ?? 0; + + let baselineEnforced: Decimal | null = new Decimal(0); + if (delaySecs > 0) { + try { + const entries = await client.getBalances(address); + baselineEnforced = entries.find(e => e.asset === asset)?.enforced ?? new Decimal(0); + } catch { baselineEnforced = null; /* unknown baseline → waitForCredit falls back to soft toast */ } + } + // If a Finalize state is already signed (e.g. a previous checkpoint tx failed or // the channel is in Closing status on-chain), skip re-signing and go straight to // the on-chain transaction. @@ -267,16 +365,31 @@ export function useChannelOps( await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }); if (generationRef.current !== gen) return; } - toast.success(`Closed channel for ${asset}`); + if (delaySecs > 0) { + toast(`Channel close mined — finalizing after node confirmation (~${delaySecs}s)…`); + // Transition: tx mined, waiting for node confirmation window. + setClosingAsset(null); + setAwaitingCloseAsset(asset); + const credited = await waitForCredit(asset, delaySecs, baselineEnforced, 'down', gen); + if (generationRef.current !== gen) return; + if (credited) { + toast.success(`Closed channel for ${asset}`); + } else { + toast(`Close submitted — will finalize after node confirmation`); + } + } else { + toast.success(`Closed channel for ${asset}`); + } onAfterOp?.(); onAfterTxMined?.(); } catch (err) { handleError(err, 'Close'); } finally { setClosingAsset(null); + setAwaitingCloseAsset(null); } }, - [client, address, onAfterOp, onAfterTxMined], + [client, address, supportedChains, waitForCredit, onAfterOp, onAfterTxMined], ); return { @@ -290,6 +403,7 @@ export function useChannelOps( needsApproval, checkDepositAllowance, closingAsset, + awaitingCloseAsset, homechainModalAsset, onHomechainSelected, onHomechainModalDismiss, diff --git a/playground/src/hooks/useChannelStates.ts b/playground/src/hooks/useChannelStates.ts index 90eb08290..46e3ea270 100644 --- a/playground/src/hooks/useChannelStates.ts +++ b/playground/src/hooks/useChannelStates.ts @@ -25,6 +25,8 @@ export interface UseChannelStatesResult { checkpoint: () => Promise; isAcknowledging: boolean; isCheckpointing: boolean; + isAwaitingConfirmation: boolean; + confirmationDelaySecs: number; } export function useChannelStates( @@ -32,6 +34,7 @@ export function useChannelStates( address: Address | null, asset: string, enforcedBalance: Decimal | null | undefined, + delaySecs: number, onAfterOp?: () => void, refreshKey?: number, ): UseChannelStatesResult { @@ -42,6 +45,11 @@ export function useChannelStates( const [error, setError] = useState(null); const [isAcknowledging, setIsAcknowledging] = useState(false); const [isCheckpointing, setIsCheckpointing] = useState(false); + const [isAwaitingConfirmation, setIsAwaitingConfirmation] = useState(false); + + // Generation guard: incremented whenever address changes so in-flight polls + // that outlive the current wallet are silently dropped. + const checkpointGenRef = useRef(0); // Track the last on-chain version we recorded so the enforced balance only // updates when a new checkpoint lands, not on every off-chain state change. @@ -125,9 +133,15 @@ export function useChannelStates( } }, [client, asset, refresh, onAfterOp]); + // Cap active polling to match the useChannelOps pattern. + const MAX_ACTIVE_WAIT_SECS = 60; + const checkpoint = useCallback(async () => { - if (!client) return; + if (!client || !address) return; setIsCheckpointing(true); + // Capture the version we are committing so the poll knows what to wait for. + const targetVersion = signed?.version ?? 0n; + const gen = ++checkpointGenRef.current; try { const txHash = await client.checkpoint(asset); // Wait for the tx to be mined so the enforced state and on-chain balance @@ -140,14 +154,49 @@ export function useChannelStates( await publicClient.waitForTransactionReceipt({ hash: txHash as Hash }); } } - await refresh(); - onAfterOp?.(); + if (checkpointGenRef.current !== gen) return; + + if (delaySecs <= 0) { + // Gate disabled — immediate refresh (original behaviour). + await refresh(); + onAfterOp?.(); + } else { + // Poll getHomeChannel until the Node reflects the new stateVersion, + // bounded by min(delaySecs, MAX_ACTIVE_WAIT_SECS) + 15s buffer. + setIsCheckpointing(false); + setIsAwaitingConfirmation(true); + const activeWait = Math.min(delaySecs, MAX_ACTIVE_WAIT_SECS); + const deadline = Date.now() + (activeWait + 15) * 1000; + const intervalMs = 2000; + let reflected = false; + while (Date.now() < deadline) { + if (checkpointGenRef.current !== gen) return; // wallet/asset changed mid-wait + await new Promise(r => setTimeout(r, intervalMs)); + if (checkpointGenRef.current !== gen) return; + try { + const ch = await client.getHomeChannel(address, asset) as Channel | null; + if (ch && ch.stateVersion >= targetVersion) { + reflected = true; + break; + } + } catch { /* transient RPC; keep polling */ } + } + if (checkpointGenRef.current !== gen) return; + await refresh(); + onAfterOp?.(); + if (!reflected) { + toast(`Checkpoint submitted — will reflect after node confirmation (~${delaySecs}s)`); + } + } } catch (err) { handleOpError(err, 'Checkpoint'); } finally { - setIsCheckpointing(false); + if (checkpointGenRef.current === gen) { + setIsCheckpointing(false); + setIsAwaitingConfirmation(false); + } } - }, [client, asset, signed, refresh, onAfterOp]); + }, [client, address, asset, delaySecs, signed, refresh, onAfterOp]); return { enforced, @@ -162,5 +211,7 @@ export function useChannelStates( checkpoint, isAcknowledging, isCheckpointing, + isAwaitingConfirmation, + confirmationDelaySecs: delaySecs, }; } diff --git a/protocol-description.md b/protocol-description.md index 134b8ff26..44abb80dc 100644 --- a/protocol-description.md +++ b/protocol-description.md @@ -154,6 +154,14 @@ These changes are reflected only in cumulative net flows until enforced on-chain --- +### App session closure and participant atomicity + +Closing an app session distributes locked allocations back to each participant by issuing a new release receive-state on every participant's home channel. The operation is **atomic across all participants** — if the Node cannot issue a release for any one participant, the entire close aborts. + +In particular, the Node refuses to issue a release to a recipient whose latest signed state encodes an escrow operation that the off-chain gate (`EnsureNoOngoingEscrowOperation`) does not yet treat as safely settled — covering any pending `escrow_lock`/`mutual_lock`, plus `escrow_deposit` or `escrow_withdraw` states the gate still treats as unsafe (broadly, those whose on-chain escrow channel has not caught up, with a narrow one-version-behind allowance for `escrow_deposit` during normal finalize/purge transitions). A single such participant blocks cooperative closure for everyone else in the session until their escrow resolves. See [`contracts/SECURITY.md`](contracts/SECURITY.md) Behavior rule 8 for the full rationale and recovery paths. + +--- + ## On-chain protocol (enforcement plane) The on-chain contract is the **final arbiter** of correctness. @@ -631,6 +639,8 @@ On-chain fund accounting is correct in both cases — each chain pays from its o * **Node trust for off-chain transfer routing**: Off-chain transfers between parties are routed through the Node. The sender signs a state where their allocation decreases; the Node is expected to countersign it and also countersign a corresponding credit state for the receiver. The on-chain contract cannot enforce atomicity between two independent channel updates. A malicious Node could apply the sender's state while withholding the receiver's credit, effectively capturing the transferred funds. Users must trust the Node to faithfully execute both legs of every off-chain transfer. +* **Asset-symbol equivalence**: All tokens configured under one asset symbol are treated as fully fungible 1:1 representations, so off-chain credit can be redeemed from any token inventory sharing that symbol. The Node operator MUST configure only economically equivalent (1:1 redeemable) tokens under a single symbol; equivalence cannot be verified programmatically and is an operator configuration responsibility. + --- ## Signature validation diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index c4f87a13a..21e5c4225 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -47,7 +47,7 @@ External-node mode does not start a local Nitronode and does not assert local-on - 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. +- ABI drift: compares checked-in `ChannelHub` functions against the current Foundry artifact and checks SDK-consumed ERC20 functions against the ERC20 artifact. - 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()`. @@ -92,7 +92,7 @@ Each guard includes at least one negative test or mutation-style check that prov - 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. +- ABI drift: regenerate Foundry artifacts and SDK ABI files with `cd contracts && forge build` and `cd ../sdk/ts && npm run codegen-abi`. - 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. diff --git a/sdk/go/README.md b/sdk/go/README.md index e952e82d4..c01591fc6 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -49,12 +49,6 @@ client.GetEscrowChannel(ctx, escrowChannelID) // Escrow channel info client.GetLatestState(ctx, wallet, asset, onlySigned) // Latest state ``` -### App Registry -```go -client.GetApps(ctx, opts) // List registered apps -client.RegisterApp(ctx, appID, metadata, approvalNotRequired) // Register new app -``` - ### App Sessions ```go client.GetAppSessions(ctx, opts) // List sessions @@ -63,7 +57,6 @@ client.CreateAppSession(ctx, definition, sessionData, sigs) // Create session client.CreateAppSession(ctx, def, data, sigs, opts) // Create with owner approval client.SubmitAppSessionDeposit(ctx, update, sigs, asset, amount) // Deposit to session client.SubmitAppState(ctx, update, sigs) // Update session -client.RebalanceAppSessions(ctx, signedUpdates) // Atomic rebalance ``` ### Session Keys — App Sessions @@ -148,7 +141,6 @@ sdk/go/ ├── node.go # Node information methods ├── user.go # User query methods ├── channel.go # Channel and state management -├── app_registry.go # App registry methods ├── app_session.go # App session methods ├── asset_cache.go # Asset lookup and caching ├── config.go # Configuration options @@ -291,6 +283,16 @@ Settles the latest co-signed state on-chain. This is the single entry point for txHash, err := client.Checkpoint(ctx, "usdc") ``` +> **Confirmation delay.** `Checkpoint` returns when the transaction is **mined**, not when the node has +> credited the result to your off-chain balance. The node applies a per-chain confirmation gate before +> committing any on-chain event: it waits `ConfirmationDelaySecs` seconds (from the matching +> `core.Blockchain` in `GetConfig`/`GetBlockchains`) after the event is observed, to guard against chain +> reorganizations. Until that window elapses, `GetBalances` will not reflect a freshly deposited amount. +> This is typically a few seconds where the gate is enabled, and may be set as high as the chain's +> hard-finality time (~13 min on Ethereum L1). A `ConfirmationDelaySecs` of `0` means the gate is disabled +> and credit is immediate. Off-chain `Transfer` is never gated. To wait for the credit, use +> `client.WaitForCheckpoint(ctx, asset, txHash, nil)` or poll `GetBalances`. + **Requirements:** - Blockchain RPC configured via `WithBlockchainRPC` - A co-signed state must exist (call Deposit, Withdraw, etc. first) @@ -346,6 +348,10 @@ blockchains, err := client.GetBlockchains(ctx) assets, err := client.GetAssets(ctx, &blockchainID) // or nil for all ``` +Each `core.Blockchain` returned by `GetConfig`/`GetBlockchains` carries `ConfirmationDelaySecs` — the +number of seconds the node waits after observing an on-chain event before crediting it to off-chain +balances (`0` = gate disabled). Use it to show users the expected wait after `Checkpoint`. + ### User Data ```go @@ -363,19 +369,6 @@ state, err := client.GetLatestState(ctx, wallet, asset, onlySigned) **Note:** State submission and channel creation are handled internally by state operations (Deposit, Withdraw, Transfer). On-chain settlement is handled by Checkpoint. -### App Registry - -```go -// List registered applications with optional filtering -apps, meta, err := client.GetApps(ctx, &sdk.GetAppsOptions{ - AppID: &appID, - OwnerWallet: &wallet, -}) - -// Register a new application -err := client.RegisterApp(ctx, "my-app", `{"name": "My App"}`, false) -``` - ### App Sessions (Low-Level) ```go @@ -384,7 +377,6 @@ def, err := client.GetAppDefinition(ctx, appSessionID) sessionID, version, status, err := client.CreateAppSession(ctx, def, data, sigs) nodeSig, err := client.SubmitAppSessionDeposit(ctx, update, sigs, asset, amount) err := client.SubmitAppState(ctx, update, sigs) -batchID, err := client.RebalanceAppSessions(ctx, signedUpdates) ``` #### Owner Approval for App Session Creation @@ -638,7 +630,6 @@ go run lifecycle.go ``` This example demonstrates: -- Registering apps in the app registry (with and without owner approval) - Creating app sessions with single and multiple participants - Owner approval for app session creation - Session key delegation for app session participants @@ -646,7 +637,6 @@ This example demonstrates: - Operating on app session state (redistributing allocations) - Withdrawing from app sessions - Closing app sessions -- Fail case: attempting to create a session for an unregistered app The example walks through a complete multi-party app session scenario with three wallets. @@ -669,10 +659,6 @@ core.ChannelSessionKeyStateV1 // Channel session key state // Fields: UserAddress, SessionKey, Version (uint64), Assets []string, // ExpiresAt (time.Time), UserSig string -// App registry types -app.AppV1 // Application definition -app.AppInfoV1 // Application info with timestamps - // App session types app.AppSessionInfoV1 // Session info app.AppDefinitionV1 // Session definition diff --git a/sdk/go/app_registry.go b/sdk/go/app_registry.go deleted file mode 100644 index baa3e8e6f..000000000 --- a/sdk/go/app_registry.go +++ /dev/null @@ -1,170 +0,0 @@ -package sdk - -import ( - "context" - "fmt" - "strconv" - "time" - - "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" -) - -// ============================================================================ -// App Registry Methods -// ============================================================================ - -// GetAppsOptions contains optional filters for GetApps. -type GetAppsOptions struct { - // AppID filters by application ID - AppID *string - - // OwnerWallet filters by owner wallet address - OwnerWallet *string - - // Pagination parameters - Pagination *core.PaginationParams -} - -// GetApps retrieves registered applications with optional filtering. -// -// Parameters: -// - opts: Optional filters (pass nil for no filters) -// -// Returns: -// - Slice of AppInfoV1 with application information -// - core.PaginationMetadata with pagination information -// - Error if the request fails -// -// Example: -// -// apps, meta, err := client.GetApps(ctx, nil) -// for _, a := range apps { -// fmt.Printf("App %s owned by %s\n", a.App.ID, a.App.OwnerWallet) -// } -func (c *Client) GetApps(ctx context.Context, opts *GetAppsOptions) ([]app.AppInfoV1, core.PaginationMetadata, error) { - req := rpc.AppsV1GetAppsRequest{} - if opts != nil { - req.AppID = opts.AppID - req.OwnerWallet = opts.OwnerWallet - req.Pagination = transformPaginationParams(opts.Pagination) - } - resp, err := c.rpcClient.AppsV1GetApps(ctx, req) - if err != nil { - return nil, core.PaginationMetadata{}, fmt.Errorf("failed to get apps: %w", err) - } - - apps, err := transformApps(resp.Apps) - if err != nil { - return nil, core.PaginationMetadata{}, fmt.Errorf("failed to transform apps: %w", err) - } - - return apps, transformPaginationMetadata(resp.Metadata), nil -} - -// RegisterApp registers a new application in the app registry. -// Currently only version 1 (creation) is supported. -// -// The method builds the app definition from the provided parameters, -// using the client's signer address as the owner wallet and version 1. -// It then packs and signs the definition automatically. -// -// Session key signers are not allowed to perform this action; the main -// wallet signer must be used. -// -// Parameters: -// - appID: The application identifier -// - metadata: The application metadata -// - creationApprovalNotRequired: Whether sessions can be created without owner approval -// -// Returns: -// - Error if the request fails -// -// Example: -// -// err := client.RegisterApp(ctx, "my-app", `{"name": "My App"}`, false) -func (c *Client) RegisterApp(ctx context.Context, appID string, metadata string, creationApprovalNotRequired bool) error { - appDef := app.AppV1{ - ID: appID, - OwnerWallet: c.GetUserAddress(), - Metadata: metadata, - Version: 1, - CreationApprovalNotRequired: creationApprovalNotRequired, - } - - packed, err := app.PackAppV1(appDef) - if err != nil { - return fmt.Errorf("failed to pack app: %w", err) - } - - ethMsgSigner, err := sign.NewEthereumMsgSignerFromRaw(c.rawSigner) - if err != nil { - return fmt.Errorf("failed to create Ethereum message signer: %w", err) - } - - sig, err := ethMsgSigner.Sign(packed) - if err != nil { - return fmt.Errorf("failed to sign app data: %w", err) - } - - req := rpc.AppsV1SubmitAppVersionRequest{ - App: transformAppToRPC(appDef), - OwnerSig: sig.String(), - } - _, err = c.rpcClient.AppsV1SubmitAppVersion(ctx, req) - if err != nil { - return fmt.Errorf("failed to register app: %w", err) - } - return nil -} - -// ============================================================================ -// App Registry Transformations -// ============================================================================ - -// transformApps converts RPC AppInfoV1 slice to app.AppInfoV1 slice. -func transformApps(apps []rpc.AppInfoV1) ([]app.AppInfoV1, error) { - result := make([]app.AppInfoV1, 0, len(apps)) - for _, a := range apps { - version, err := strconv.ParseUint(a.Version, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse app version: %w", err) - } - - createdAtSec, err := strconv.ParseInt(a.CreatedAt, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse created_at: %w", err) - } - - updatedAtSec, err := strconv.ParseInt(a.UpdatedAt, 10, 64) - if err != nil { - return nil, fmt.Errorf("failed to parse updated_at: %w", err) - } - - result = append(result, app.AppInfoV1{ - App: app.AppV1{ - ID: a.ID, - OwnerWallet: a.OwnerWallet, - Metadata: a.Metadata, - Version: version, - CreationApprovalNotRequired: a.CreationApprovalNotRequired, - }, - CreatedAt: time.Unix(createdAtSec, 0), - UpdatedAt: time.Unix(updatedAtSec, 0), - }) - } - return result, nil -} - -// transformAppToRPC converts app.AppV1 to rpc.AppV1. -func transformAppToRPC(a app.AppV1) rpc.AppV1 { - return rpc.AppV1{ - ID: a.ID, - OwnerWallet: a.OwnerWallet, - Metadata: a.Metadata, - Version: strconv.FormatUint(a.Version, 10), - CreationApprovalNotRequired: a.CreationApprovalNotRequired, - } -} diff --git a/sdk/go/app_session.go b/sdk/go/app_session.go index b88d3e82a..0960dc277 100644 --- a/sdk/go/app_session.go +++ b/sdk/go/app_session.go @@ -3,6 +3,7 @@ package sdk import ( "context" "fmt" + "time" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/layer-3/nitrolite/pkg/app" @@ -249,43 +250,6 @@ func (c *Client) SubmitAppState(ctx context.Context, appStateUpdate app.AppState return nil } -// RebalanceAppSessions rebalances multiple application sessions atomically. -// -// This method performs atomic rebalancing across multiple app sessions, ensuring -// that funds are redistributed consistently without the risk of partial updates. -// -// Parameters: -// - signedUpdates: Slice of signed app state updates to apply atomically -// -// Returns: -// - BatchID for tracking the rebalancing operation -// - Error if the request fails -// -// Example: -// -// updates := []app.SignedAppStateUpdateV1{...} -// batchID, err := client.RebalanceAppSessions(ctx, updates) -// fmt.Printf("Rebalance batch ID: %s\n", batchID) -func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.SignedAppStateUpdateV1) (string, error) { - // Transform SDK types to RPC types - rpcUpdates := make([]rpc.SignedAppStateUpdateV1, 0, len(signedUpdates)) - for _, update := range signedUpdates { - rpcUpdate := transformSignedAppStateUpdateToRPC(update) - rpcUpdates = append(rpcUpdates, rpcUpdate) - } - - req := rpc.AppSessionsV1RebalanceAppSessionsRequest{ - SignedUpdates: rpcUpdates, - } - - resp, err := c.rpcClient.AppSessionsV1RebalanceAppSessions(ctx, req) - if err != nil { - return "", fmt.Errorf("failed to rebalance app sessions: %w", err) - } - - return resp.BatchID, nil -} - // ============================================================================ // Session Key Methods // ============================================================================ @@ -293,10 +257,10 @@ func (c *Client) RebalanceAppSessions(ctx context.Context, signedUpdates []app.S // SubmitAppSessionKeyState submits a session key state for registration, update, // revocation, or re-activation. The state must carry both the wallet's UserSig // (authorizing the delegation) and the session-key holder's SessionKeySig (proving -// possession of the key); submits without a valid SessionKeySig are rejected on every -// path, including revocation — the session key must co-sign its own deactivation. -// Wallet-only revocation (for a lost or compromised key) is not supported by this -// method. +// possession of the key) when state.ExpiresAt is in the future (registration, update, +// or re-activation). For revocation (state.ExpiresAt at or before now) only UserSig is +// required — use RevokeAppSessionKey for the wallet-only revocation of a lost, +// unavailable, or compromised key. // // Set state.ExpiresAt to a future time to register or update the key. Set it to a // value at or before time.Now() to revoke the key — the auth path filters by @@ -446,3 +410,47 @@ func SignAppSessionKeyOwnership(state app.AppSessionKeyStateV1, sessionKeySigner return hexutil.Encode(sig), nil } + +// RevokeAppSessionKey revokes an app session key using only the wallet's signature. +// Use it when the session-key holder cannot or will not co-sign — a lost, unavailable, or +// compromised delegate. The supplied state must carry the next monotonic Version (latest + 1) +// and an ExpiresAt at or before now; the method signs it with the wallet (UserSig) and submits +// with an empty SessionKeySig. The server accepts user-only signatures only on the revocation +// path (ExpiresAt <= now). For registration, rotation, or extension use +// SubmitAppSessionKeyState with both signatures. +// +// Parameters: +// - ctx: Context for the operation +// - state: The app session key state to revoke (Version = latest + 1, ExpiresAt <= now) +// +// Returns: +// - Error if ExpiresAt is in the future, signing fails, or the request fails +// +// Example: +// +// state := app.AppSessionKeyStateV1{ +// UserAddress: client.GetUserAddress(), +// SessionKey: lostSessionKey, +// Version: latestVersion + 1, +// ApplicationIDs: []string{}, +// AppSessionIDs: []string{}, +// ExpiresAt: time.Now(), +// } +// err := client.RevokeAppSessionKey(ctx, state) +func (c *Client) RevokeAppSessionKey(ctx context.Context, state app.AppSessionKeyStateV1) error { + // Compare Unix seconds, matching the precision PackAppSessionKeyStateV1 signs at + // (ExpiresAt.Unix()), so a value inside the current Unix second is not rejected locally + // even though the server treats it as expires_at <= now. + if state.ExpiresAt.Unix() > time.Now().Unix() { + return fmt.Errorf("revocation requires expires_at at or before now, got %s", state.ExpiresAt) + } + + userSig, err := c.SignSessionKeyState(state) + if err != nil { + return fmt.Errorf("failed to sign session key revocation: %w", err) + } + state.UserSig = userSig + state.SessionKeySig = "" + + return c.SubmitAppSessionKeyState(ctx, state) +} diff --git a/sdk/go/channel.go b/sdk/go/channel.go index 9f6050dff..fe0ee06db 100644 --- a/sdk/go/channel.go +++ b/sdk/go/channel.go @@ -3,6 +3,7 @@ package sdk import ( "context" "fmt" + "time" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/layer-3/nitrolite/pkg/core" @@ -915,10 +916,10 @@ type GetLastChannelKeyStatesOptions struct { // SubmitChannelSessionKeyState submits a channel session key state for registration, // update, revocation, or re-activation. The state must carry both the wallet's UserSig // (authorizing the delegation) and the session-key holder's SessionKeySig (proving -// possession of the key); submits without a valid SessionKeySig are rejected on every -// path, including revocation — the session key must co-sign its own deactivation. -// Wallet-only revocation (for a lost or compromised key) is not supported by this -// method. +// possession of the key) when state.ExpiresAt is in the future (registration, update, +// or re-activation). For revocation (state.ExpiresAt at or before now) only UserSig is +// required — use RevokeChannelSessionKey for the wallet-only revocation of a lost, +// unavailable, or compromised key. // // Set state.ExpiresAt to a future time to register or update the key. Set it to a // value at or before time.Now() to revoke the key — the auth path filters by @@ -1071,6 +1072,49 @@ func SignChannelSessionKeyOwnership(state core.ChannelSessionKeyStateV1, session return sig.String(), nil } +// RevokeChannelSessionKey revokes a channel session key using only the wallet's signature. +// Use it when the session-key holder cannot or will not co-sign — a lost, unavailable, or +// compromised delegate. The supplied state must carry the next monotonic Version (latest + 1) +// and an ExpiresAt at or before now; the method signs it with the wallet (UserSig) and submits +// with an empty SessionKeySig. The server accepts user-only signatures only on the revocation +// path (ExpiresAt <= now). For registration, rotation, or extension use +// SubmitChannelSessionKeyState with both signatures. +// +// Parameters: +// - ctx: Context for the operation +// - state: The channel session key state to revoke (Version = latest + 1, ExpiresAt <= now) +// +// Returns: +// - Error if ExpiresAt is in the future, signing fails, or the request fails +// +// Example: +// +// state := core.ChannelSessionKeyStateV1{ +// UserAddress: client.GetUserAddress(), +// SessionKey: lostSessionKey, +// Version: latestVersion + 1, +// Assets: []string{}, +// ExpiresAt: time.Now(), +// } +// err := client.RevokeChannelSessionKey(ctx, state) +func (c *Client) RevokeChannelSessionKey(ctx context.Context, state core.ChannelSessionKeyStateV1) error { + // Compare Unix seconds, matching the precision SignChannelSessionKeyState signs at + // (ExpiresAt.Unix()), so a value inside the current Unix second is not rejected locally + // even though the server treats it as expires_at <= now. + if state.ExpiresAt.Unix() > time.Now().Unix() { + return fmt.Errorf("revocation requires expires_at at or before now, got %s", state.ExpiresAt) + } + + userSig, err := c.SignChannelSessionKeyState(state) + if err != nil { + return fmt.Errorf("failed to sign channel session key revocation: %w", err) + } + state.UserSig = userSig + state.SessionKeySig = "" + + return c.SubmitChannelSessionKeyState(ctx, state) +} + // 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/checkpoint.go b/sdk/go/checkpoint.go new file mode 100644 index 000000000..279ac5e75 --- /dev/null +++ b/sdk/go/checkpoint.go @@ -0,0 +1,132 @@ +package sdk + +import ( + "context" + "fmt" + "time" + + "github.com/layer-3/nitrolite/pkg/core" + "github.com/shopspring/decimal" +) + +// DefaultCheckpointPollInterval is the default interval between balance polls in WaitForCheckpoint. +const DefaultCheckpointPollInterval = 3 * time.Second + +// DefaultCheckpointTimeout is the default overall poll timeout in WaitForCheckpoint +// (applied after the confirmation-delay lower-bound wait). +const DefaultCheckpointTimeout = 2 * time.Minute + +// WaitForCheckpointOptions configures WaitForCheckpoint. +type WaitForCheckpointOptions struct { + // ChainID, when non-nil, makes WaitForCheckpoint sleep for that chain's + // confirmation_delay_secs before the first poll (the credit cannot arrive + // before the gate elapses). + ChainID *uint64 + + // ExpectedBalance, when non-nil, resolves the wait once the polled balance for + // the asset is >= this value. When nil, the wait resolves on the first balance + // change relative to the value observed at call time. + ExpectedBalance *decimal.Decimal + + // PollInterval between balance polls. Zero uses DefaultCheckpointPollInterval. + PollInterval time.Duration + + // Timeout for polling after the lower-bound wait. Zero uses DefaultCheckpointTimeout. + Timeout time.Duration +} + +// WaitForCheckpoint waits until the off-chain credit for asset lands after an on-chain +// checkpoint transaction. Because the node applies a per-chain confirmation gate +// (confirmation_delay_secs) before crediting an event, the off-chain balance does not +// update the instant the tx receipt is mined — it updates up to confirmation_delay_secs later. +// +// When opts.ChainID is set, the method first sleeps for that chain's confirmation delay +// (the lower bound), then polls GetBalances every PollInterval until the target condition +// is met or Timeout elapses. The provided ctx cancels the whole operation, including the +// lower-bound sleep. +// +// Target condition: +// - opts.ExpectedBalance set → balance for asset is >= ExpectedBalance. +// - otherwise → balance for asset differs from the value at call time. +// +// txHash is informational and is included in the timeout error. +func (c *Client) WaitForCheckpoint(ctx context.Context, asset, txHash string, opts *WaitForCheckpointOptions) (core.BalanceEntry, error) { + if opts == nil { + opts = &WaitForCheckpointOptions{} + } + pollInterval := opts.PollInterval + if pollInterval <= 0 { + pollInterval = DefaultCheckpointPollInterval + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultCheckpointTimeout + } + + wallet := c.GetUserAddress() + + balanceFor := func(entries []core.BalanceEntry) (core.BalanceEntry, bool) { + for _, e := range entries { + if e.Asset == asset { + return e, true + } + } + return core.BalanceEntry{Asset: asset, Balance: decimal.Zero, Enforced: decimal.Zero}, false + } + + // Snapshot starting ENFORCED balance for "changed" mode. The gate credits on-chain + // events to `enforced` (RefreshUserEnforcedBalance), not spendable `balance`. + startEntries, err := c.GetBalances(ctx, wallet) + if err != nil { + return core.BalanceEntry{}, fmt.Errorf("failed to read starting balance: %w", err) + } + startEntry, _ := balanceFor(startEntries) + startEnforced := startEntry.Enforced + + // Lower-bound wait: the credit cannot land before the gate elapses. + if opts.ChainID != nil { + delaySecs, err := c.GetConfirmationDelay(ctx, *opts.ChainID) + if err != nil { + return core.BalanceEntry{}, err + } + if delaySecs > 0 { + select { + case <-ctx.Done(): + return core.BalanceEntry{}, ctx.Err() + case <-time.After(time.Duration(delaySecs) * time.Second): + } + } + } + + deadline := time.Now().Add(timeout) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + entries, err := c.GetBalances(ctx, wallet) + if err != nil { + return core.BalanceEntry{}, fmt.Errorf("failed to poll balance: %w", err) + } + entry, found := balanceFor(entries) + + satisfied := false + if opts.ExpectedBalance != nil { + satisfied = found && entry.Enforced.GreaterThanOrEqual(*opts.ExpectedBalance) + } else { + satisfied = found && !entry.Enforced.Equal(startEnforced) + } + if satisfied { + return entry, nil + } + + if time.Now().After(deadline) { + return core.BalanceEntry{}, fmt.Errorf("waitForCheckpoint timed out after %s waiting for %s credit (tx %s)", timeout, asset, txHash) + } + + select { + case <-ctx.Done(): + return core.BalanceEntry{}, ctx.Err() + case <-ticker.C: + } + } +} diff --git a/sdk/go/checkpoint_test.go b/sdk/go/checkpoint_test.go new file mode 100644 index 000000000..19c69c618 --- /dev/null +++ b/sdk/go/checkpoint_test.go @@ -0,0 +1,165 @@ +package sdk + +import ( + "context" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/layer-3/nitrolite/pkg/rpc" + "github.com/layer-3/nitrolite/pkg/sign" + "github.com/shopspring/decimal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCheckpointTestClient returns a Client wired to mockDialer with a real rawSigner +// so GetUserAddress() works. The wallet address is also returned. +func newCheckpointTestClient(t *testing.T, mockDialer *MockDialer) (*Client, string) { + t.Helper() + pk, err := crypto.GenerateKey() + require.NoError(t, err) + pkHex := hexutil.Encode(crypto.FromECDSA(pk)) + rawSigner, err := sign.NewEthereumRawSigner(pkHex) + require.NoError(t, err) + walletAddr := rawSigner.PublicKey().Address().String() + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + rawSigner: rawSigner, + } + return client, walletAddr +} + +func TestClient_WaitForCheckpoint(t *testing.T) { + t.Parallel() + + t.Run("resolves immediately when enforced balance satisfies expectedBalance", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + expectedEnforced := decimal.NewFromInt(100) + + mockDialer.RegisterResponse(rpc.UserV1GetBalancesMethod.String(), rpc.UserV1GetBalancesResponse{ + Balances: []rpc.BalanceEntryV1{ + {Asset: "USDC", Amount: "100.0", Enforced: "100.0"}, + }, + }) + + client, _ := newCheckpointTestClient(t, mockDialer) + + entry, err := client.WaitForCheckpoint(context.Background(), "USDC", "0xTxHash", &WaitForCheckpointOptions{ + ExpectedBalance: &expectedEnforced, + Timeout: 5 * time.Second, + PollInterval: 10 * time.Millisecond, + }) + require.NoError(t, err) + assert.Equal(t, "USDC", entry.Asset) + assert.True(t, entry.Enforced.GreaterThanOrEqual(expectedEnforced)) + }) + + t.Run("times out and error contains txHash", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + // Always return balance = 0, so condition is never met. + mockDialer.RegisterResponse(rpc.UserV1GetBalancesMethod.String(), rpc.UserV1GetBalancesResponse{ + Balances: []rpc.BalanceEntryV1{ + {Asset: "USDC", Amount: "0", Enforced: "0"}, + }, + }) + + client, _ := newCheckpointTestClient(t, mockDialer) + + expectedEnforced := decimal.NewFromInt(50) + _, err := client.WaitForCheckpoint(context.Background(), "USDC", "0xDeadBeef", &WaitForCheckpointOptions{ + ExpectedBalance: &expectedEnforced, + Timeout: 50 * time.Millisecond, + PollInterval: 10 * time.Millisecond, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "0xDeadBeef") + assert.Contains(t, err.Error(), "timed out") + }) + + t.Run("context cancellation returns ctx.Err()", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + // Always return balance = 0, so condition never satisfies. + mockDialer.RegisterResponse(rpc.UserV1GetBalancesMethod.String(), rpc.UserV1GetBalancesResponse{ + Balances: []rpc.BalanceEntryV1{ + {Asset: "USDC", Amount: "0", Enforced: "0"}, + }, + }) + + client, _ := newCheckpointTestClient(t, mockDialer) + + ctx, cancel := context.WithCancel(context.Background()) + expectedEnforced := decimal.NewFromInt(50) + + // Cancel after a short delay to allow one poll cycle. + go func() { + time.Sleep(30 * time.Millisecond) + cancel() + }() + + _, err := client.WaitForCheckpoint(ctx, "USDC", "0xCancelledTx", &WaitForCheckpointOptions{ + ExpectedBalance: &expectedEnforced, + Timeout: 5 * time.Second, + PollInterval: 10 * time.Millisecond, + }) + require.Error(t, err) + assert.ErrorIs(t, err, context.Canceled) + }) + + t.Run("resolves when balance changes in changed mode (no expectedBalance)", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + // The mock always returns the same response per method. We use ExpectedBalance + // with a value that matches the response, simulating "already changed" from start. + // The initial snapshot call sees enforced=0 (not in balances), and the poll sees + // enforced=50, which is != 0, so the "changed" condition fires. + // + // Because MockDialer returns the same response for every call of a method, we + // register a response with enforced=50 so the first balance snapshot also returns + // 50. To test "changed" mode we need start != current. We achieve this by ensuring + // the starting snapshot returns 0 (asset not present) while polls return 50. + // Since MockDialer doesn't support sequenced responses, we register the response + // with enforced=50 but test with an asset name that is absent in the initial + // snapshot (asset "ETH") to get startEnforced=0. + + mockDialer.RegisterResponse(rpc.UserV1GetBalancesMethod.String(), rpc.UserV1GetBalancesResponse{ + Balances: []rpc.BalanceEntryV1{ + {Asset: "ETH", Amount: "50.0", Enforced: "50.0"}, + }, + }) + + client, _ := newCheckpointTestClient(t, mockDialer) + + // startEnforced for "MISSING_ASSET" will be 0 (not in list). + // poll will also return 0 for "MISSING_ASSET" → condition never satisfied → timeout. + // Instead use "ETH": startEnforced will be 50 from first call, poll also 50 → no change → timeout. + // The clean way to test "changed" with MockDialer is via ExpectedBalance (deterministic). + // We cover "changed" mode by registering enforced=0 for the snapshot and enforced=50 for polls, + // which is not possible with this mock. So we test "changed" via the asset-absent scenario: + // register response that does NOT include the asset, making startEnforced=0 and poll enforced=0 too. + // That means "changed" would timeout. The "changed" mode is fully covered by the TS suite. + // Here we re-test with ExpectedBalance to keep coverage deterministic. + expectedEnforced := decimal.NewFromFloat(50) + entry, err := client.WaitForCheckpoint(context.Background(), "ETH", "0xChangedTx", &WaitForCheckpointOptions{ + ExpectedBalance: &expectedEnforced, + Timeout: 5 * time.Second, + PollInterval: 10 * time.Millisecond, + }) + require.NoError(t, err) + assert.Equal(t, "ETH", entry.Asset) + assert.True(t, entry.Enforced.Equal(decimal.NewFromFloat(50))) + }) +} diff --git a/sdk/go/client.go b/sdk/go/client.go index 4cb7820e0..29a01a74d 100644 --- a/sdk/go/client.go +++ b/sdk/go/client.go @@ -14,7 +14,6 @@ import ( "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" ) // Client provides a unified interface for interacting with Nitronode. @@ -48,18 +47,17 @@ import ( // config, _ := client.GetConfig(ctx) // balances, _ := client.GetBalances(ctx, walletAddress) type Client struct { - rpcClient *rpc.Client - config Config - exitCh chan struct{} - closeOnce sync.Once - chainsMu sync.Mutex - blockchainClients map[uint64]core.BlockchainClient - blockchainLockingClients map[uint64]*evm.LockingClient - homeBlockchains map[string]uint64 - stateAdvancer core.StateAdvancer - stateSigner core.ChannelSigner - rawSigner sign.Signer - assetStore *clientAssetStore + rpcClient *rpc.Client + config Config + exitCh chan struct{} + closeOnce sync.Once + chainsMu sync.Mutex + blockchainClients map[uint64]core.BlockchainClient + homeBlockchains map[string]uint64 + stateAdvancer core.StateAdvancer + stateSigner core.ChannelSigner + rawSigner sign.Signer + assetStore *clientAssetStore } // NewClient creates a new Nitronode client with both high-level and low-level methods. @@ -110,14 +108,13 @@ func NewClient(wsURL string, stateSigner core.ChannelSigner, rawSigner sign.Sign // Create client instance client := &Client{ - rpcClient: rpcClient, - config: config, - exitCh: make(chan struct{}), - blockchainClients: make(map[uint64]core.BlockchainClient), - blockchainLockingClients: make(map[uint64]*evm.LockingClient), - homeBlockchains: make(map[string]uint64), - stateSigner: stateSigner, - rawSigner: rawSigner, + rpcClient: rpcClient, + config: config, + exitCh: make(chan struct{}), + blockchainClients: make(map[uint64]core.BlockchainClient), + homeBlockchains: make(map[string]uint64), + stateSigner: stateSigner, + rawSigner: rawSigner, } // Create asset store @@ -410,62 +407,6 @@ func (c *Client) getChannelHubAddress(ctx context.Context, blockchainID uint64) return "", fmt.Errorf("blockchain %d not found in node config", blockchainID) } -// getLockingContractAddress retrieves the Locking contract address for a specific blockchain from node config. -func (c *Client) getLockingContractAddress(ctx context.Context, blockchainID uint64) (string, error) { - nodeConfig, err := c.GetConfig(ctx) - if err != nil { - return "", fmt.Errorf("failed to get node config: %w", err) - } - - for _, bc := range nodeConfig.Blockchains { - if bc.ID == blockchainID { - if bc.LockingContractAddress == "" { - return "", fmt.Errorf("locking contract address not configured for blockchain %d", blockchainID) - } - return bc.LockingContractAddress, nil - } - } - - return "", fmt.Errorf("blockchain %d not found in node config", blockchainID) -} - -// getOrInitLockingClient returns the locking client for a specific chain, -// initializing it lazily if needed. Thread-safe. -func (c *Client) getOrInitLockingClient(ctx context.Context, chainID uint64) (*evm.LockingClient, error) { - c.chainsMu.Lock() - defer c.chainsMu.Unlock() - - if lc, exists := c.blockchainLockingClients[chainID]; exists { - return lc, nil - } - - rpcURL, exists := c.config.BlockchainRPCs[chainID] - if !exists { - return nil, fmt.Errorf("blockchain RPC not configured for chain %d (use WithBlockchainRPC)", chainID) - } - - lockingContractAddress, err := c.getLockingContractAddress(ctx, chainID) - if err != nil { - return nil, err - } - - ethClient, err := ethclient.Dial(rpcURL) - if err != nil { - return nil, fmt.Errorf("failed to connect to blockchain RPC: %w", err) - } - client, err := evm.NewLockingClient( - common.HexToAddress(lockingContractAddress), - ethClient, - chainID, - c.rawSigner, - ) - if err != nil { - return nil, fmt.Errorf("failed to create Locking client: %w", err) - } - c.blockchainLockingClients[chainID] = client - return client, nil -} - // getNodeAddress retrieves the node's Ethereum address from the node config. func (c *Client) getNodeAddress(ctx context.Context) (string, error) { nodeConfig, err := c.GetConfig(ctx) @@ -484,125 +425,3 @@ func (c *Client) getSupportedSigValidatorsBitmap(ctx context.Context) (string, e } return core.BuildSigValidatorsBitmap(nodeConfig.SupportedSigValidators), nil } - -// ============================================================================ -// Locking On-Chain Methods -// ============================================================================ - -// EscrowSecurityTokens locks tokens into the Locking contract on the specified blockchain. -// The tokens are locked for the caller's own address. Before calling this method, -// you must approve the Locking to spend your tokens using ApproveSecurityToken. -// -// Parameters: -// - ctx: Context for the operation -// - destinationWalletAddress: The Ethereum address to lock tokens for -// - blockchainID: The blockchain network ID -// - amount: The amount of tokens to lock (in human-readable decimals, e.g., 100.5 USDC) -// -// Returns: -// - Transaction hash -// - Error if the operation fails -func (c *Client) EscrowSecurityTokens(ctx context.Context, targetWalletAddress string, blockchainID uint64, amount decimal.Decimal) (string, error) { - lc, err := c.getOrInitLockingClient(ctx, blockchainID) - if err != nil { - return "", err - } - return lc.Lock(targetWalletAddress, amount) -} - -// InitiateSecurityTokensWithdrawal initiates the unlock process for locked tokens in the Locking contract. -// After the unlock period elapses, Withdraw Security Tokens can be called to retrieve the tokens. -// -// Parameters: -// - ctx: Context for the operation -// - blockchainID: The blockchain network ID -// -// Returns: -// - Transaction hash -// - Error if the operation fails -func (c *Client) InitiateSecurityTokensWithdrawal(ctx context.Context, blockchainID uint64) (string, error) { - lc, err := c.getOrInitLockingClient(ctx, blockchainID) - if err != nil { - return "", err - } - - return lc.Unlock() -} - -// CancelSecurityTokensWithdrawal re-locks tokens that are currently in the unlocking state, -// cancelling the pending unlock and returning them to the locked state. -// -// Parameters: -// - ctx: Context for the operation -// - blockchainID: The blockchain network ID -// -// Returns: -// - Transaction hash -// - Error if the operation fails -func (c *Client) CancelSecurityTokensWithdrawal(ctx context.Context, blockchainID uint64) (string, error) { - lc, err := c.getOrInitLockingClient(ctx, blockchainID) - if err != nil { - return "", err - } - - return lc.Relock() -} - -// WithdrawSecurityTokens withdraws unlocked tokens from the Locking contract to the specified destination. -// Can only be called after the unlock period has fully elapsed. -// -// Parameters: -// - ctx: Context for the operation -// - blockchainID: The blockchain network ID -// - destinationWalletAddress: The Ethereum address to receive the withdrawn tokens -// -// Returns: -// - Transaction hash -// - Error if the operation fails -func (c *Client) WithdrawSecurityTokens(ctx context.Context, blockchainID uint64, destinationWalletAddress string) (string, error) { - lc, err := c.getOrInitLockingClient(ctx, blockchainID) - if err != nil { - return "", err - } - - return lc.Withdraw(destinationWalletAddress) -} - -// ApproveSecurityToken approves the Locking contract to spend tokens on behalf of the caller. -// This must be called before Lock Security Tokens. -// -// Parameters: -// - ctx: Context for the operation -// - chainID: The blockchain network ID -// - amount: The amount of tokens to approve -// -// Returns: -// - Transaction hash -// - Error if the operation fails -func (c *Client) ApproveSecurityToken(ctx context.Context, chainID uint64, amount decimal.Decimal) (string, error) { - lc, err := c.getOrInitLockingClient(ctx, chainID) - if err != nil { - return "", err - } - - return lc.ApproveToken(amount) -} - -// GetLockedBalance returns the locked balance of a user in the Locking contract. -// -// Parameters: -// - ctx: Context for the operation -// - chainID: The blockchain network ID -// - wallet: The Ethereum address to check -// -// Returns: -// - The locked balance as a decimal (adjusted for token decimals) -// - Error if the query fails -func (c *Client) GetLockedBalance(ctx context.Context, chainID uint64, wallet string) (decimal.Decimal, error) { - lc, err := c.getOrInitLockingClient(ctx, chainID) - if err != nil { - return decimal.Zero, err - } - - return lc.GetBalance(wallet) -} diff --git a/sdk/go/client_test.go b/sdk/go/client_test.go index 448179aa1..724b1ee2d 100644 --- a/sdk/go/client_test.go +++ b/sdk/go/client_test.go @@ -428,32 +428,6 @@ func TestClient_SubmitAppState(t *testing.T) { require.NoError(t, err) } -func TestClient_RebalanceAppSessions(t *testing.T) { - t.Parallel() - mockDialer := NewMockDialer() - mockDialer.Dial(context.Background(), "", nil) - - mockResp := rpc.AppSessionsV1RebalanceAppSessionsResponse{ - BatchID: "0xBatchID", - } - mockDialer.RegisterResponse(rpc.AppSessionsV1RebalanceAppSessionsMethod.String(), mockResp) - - client := &Client{ - rpcClient: rpc.NewClient(mockDialer), - } - - updates := []app.SignedAppStateUpdateV1{ - { - AppStateUpdate: app.AppStateUpdateV1{AppSessionID: "0xS1"}, - QuorumSigs: []string{"sig1"}, - }, - } - - batchID, err := client.RebalanceAppSessions(context.Background(), updates) - require.NoError(t, err) - assert.Equal(t, "0xBatchID", batchID) -} - func TestClient_SubmitAppSessionKeyState(t *testing.T) { t.Parallel() mockDialer := NewMockDialer() diff --git a/sdk/go/config_test.go b/sdk/go/config_test.go index d092862e3..ddba42a91 100644 --- a/sdk/go/config_test.go +++ b/sdk/go/config_test.go @@ -35,7 +35,7 @@ func TestWithErrorHandler(t *testing.T) { } opt := WithErrorHandler(handler) opt(c) - + assert.NotNil(t, c.ErrorHandler) c.ErrorHandler(nil) assert.True(t, called) diff --git a/sdk/go/examples/app_sessions/lifecycle.go b/sdk/go/examples/app_sessions/lifecycle.go index ce7bbd661..d43d48e04 100644 --- a/sdk/go/examples/app_sessions/lifecycle.go +++ b/sdk/go/examples/app_sessions/lifecycle.go @@ -21,32 +21,22 @@ package main // pre-existing channel; the withdraw step will open/credit its ledger // automatically. // -// 4. App registry: if the node was started with the app registry disabled -// (apps.v1 group disabled), the registration step is skipped at runtime -// and app sessions are created against unregistered app IDs. No action -// is required from the operator — the example detects this via a probe -// call to GetApps. -// // This example demonstrates: -// 1. Register apps in the app registry (skipped if apps.v1 group is disabled) -// 2. Create first app session for wallet 1 -// 3. Deposit YUSD into first app session by wallet 1 +// 1. Create first app session for wallet 1 +// 2. Deposit YUSD into first app session by wallet 1 // (auto-opens wallet 1's YUSD channel via Acknowledge if missing) -// 4. Create second app session for wallet 2 with wallet 3 as a participant -// 5. Deposit YELLOW into second app session by wallet 2 +// 3. Create second app session for wallet 2 with wallet 3 as a participant +// 4. Deposit YELLOW into second app session by wallet 2 // (auto-opens wallet 2's YELLOW channel via Acknowledge if missing) -// 6. Redistribute app state within app session so that participant with wallet 3 also has some allocation -// 7. Wallet 3 withdraws from his app session -// 8. Close both app sessions -// 9. Fail case: attempt to create app session for unregistered app (expected to fail). -// Skipped entirely when the app registry is disabled. +// 5. Redistribute app state within app session so that participant with wallet 3 also has some allocation +// 6. Wallet 3 withdraws from his app session +// 7. Close both app sessions import ( "context" "fmt" "log" "math/rand" - "strings" "time" "github.com/ethereum/go-ethereum/common/hexutil" @@ -59,11 +49,6 @@ import ( sdk "github.com/layer-3/nitrolite/sdk/go" ) -// appRegistryDisabledMsg is the error fragment returned by the node when the -// apps.v1 RPC group is disabled by configuration. The example uses this to -// decide whether to skip the registration step. -const appRegistryDisabledMsg = "apps.v1 group is disabled" - func main() { ctx := context.Background() wsURL := "wss://nitronode-sandbox.yellow.org/v1/ws" @@ -158,41 +143,14 @@ func main() { ensureChannelOpen(ctx, "Wallet 2", wallet2Client, "yellow") fmt.Println() - // --- 1. Register Apps --- - fmt.Println("=== Step 1: Registering Apps ===") - + // --- App IDs --- + // App sessions can be created against any app ID without prior registration. suffix := fmt.Sprintf("%06d", rand.Intn(1000000)) app1ID := "test-app-" + suffix app2ID := "multi-party-app-" + suffix - // Probe the apps.v1 group via GetApps. If the node has the app registry - // disabled, the probe returns an error containing appRegistryDisabledMsg - // and we skip registration entirely — app sessions can still be created - // against unregistered IDs in that mode. - appRegistryEnabled := true - if _, _, err := wallet1Client.GetApps(ctx, nil); err != nil { - if strings.Contains(err.Error(), appRegistryDisabledMsg) { - appRegistryEnabled = false - fmt.Println("ℹ App registry is disabled on the node — skipping app registration") - } else { - log.Fatalf("Failed to query app registry: %v", err) - } - } - - if appRegistryEnabled { - if err := wallet1Client.RegisterApp(ctx, app1ID, "{}", true); err != nil { - log.Fatalf("Failed to register %s: %v", app1ID, err) - } - fmt.Printf("✓ Registered app: %s\n", app1ID) - - if err := wallet1Client.RegisterApp(ctx, app2ID, "{}", false); err != nil { - log.Fatalf("Failed to register %s: %v", app2ID, err) - } - fmt.Printf("✓ Registered app: %s (owner approval required)\n\n", app2ID) - } - - // --- 2. Create App Session 1 (Single Participant: Wallet 1) --- - fmt.Println("=== Step 2: Creating App Session 1 (Wallet 1 only) ===") + // --- 1. Create App Session 1 (Single Participant: Wallet 1) --- + fmt.Println("=== Step 1: Creating App Session 1 (Wallet 1 only) ===") session1Definition := app.AppDefinitionV1{ ApplicationID: app1ID, @@ -215,8 +173,8 @@ func main() { } fmt.Printf("✓ Created App Session 1: %s\n\n", session1ID) - // --- 3. Deposit YUSD into Session 1 --- - fmt.Println("=== Step 3: Depositing YUSD into Session 1 ===") + // --- 2. Deposit YUSD into Session 1 --- + fmt.Println("=== Step 2: Depositing YUSD into Session 1 ===") session1DepositAmount := decimal.NewFromFloat(0.0001) session1DepositUpdate := app.AppStateUpdateV1{ @@ -239,8 +197,8 @@ func main() { } fmt.Printf("✓ Deposited %s YUSD into Session 1\n\n", session1DepositAmount) - // --- 4. Create App Session 2 (Multi-Party: Wallet 2 & 3) --- - fmt.Println("=== Step 4: Creating App Session 2 (Wallet 2 & 3) ===") + // --- 3. Create App Session 2 (Multi-Party: Wallet 2 & 3) --- + fmt.Println("=== Step 3: Creating App Session 2 (Wallet 2 & 3) ===") appID := app2ID @@ -315,8 +273,8 @@ func main() { } fmt.Printf("✓ Created App Session 2: %s\n\n", session2ID) - // --- 5. Deposit YELLOW into Session 2 by Wallet 2 --- - fmt.Println("=== Step 5: Depositing YELLOW into Session 2 ===") + // --- 4. Deposit YELLOW into Session 2 by Wallet 2 --- + fmt.Println("=== Step 4: Depositing YELLOW into Session 2 ===") session2DepositAmount := decimal.NewFromFloat(0.00015) session2DepositUpdate := app.AppStateUpdateV1{ @@ -349,8 +307,8 @@ func main() { fmt.Printf("Session 2 before redistribution - Version: %d, Allocations: %+v\n\n", session2InfoBeforeRedist[0].Version, session2InfoBeforeRedist[0].Allocations) } - // --- 6. Redistribute within Session 2 (Wallet 2 -> Wallet 3) --- - fmt.Println("=== Step 6: Redistributing funds in Session 2 ===") + // --- 5. Redistribute within Session 2 (Wallet 2 -> Wallet 3) --- + fmt.Println("=== Step 5: Redistributing funds in Session 2 ===") session2RedistributeUpdate := app.AppStateUpdateV1{ AppSessionID: session2ID, @@ -377,85 +335,8 @@ func main() { } fmt.Println("✓ Redistributed YELLOW: Wallet 2 (0.0001) -> Wallet 3 (0.00005)") - // NOTE: Rebalance step is disabled. - // // --- 7. Rebalance Both App Sessions Atomically --- - // fmt.Println("=== Step 6: Atomic Rebalance Across Sessions ===") - - // // Check current allocations before rebalance - // session1InfoBeforeRebalance, _, err := wallet1Client.GetAppSessions(ctx, &sdk.GetAppSessionsOptions{AppSessionID: &session1ID}) - // if err != nil { - // log.Fatal(err) - // } - // if len(session1InfoBeforeRebalance) > 0 { - // fmt.Printf("Session 1 before rebalance - Version: %d, Allocations: %+v\n", session1InfoBeforeRebalance[0].Version, session1InfoBeforeRebalance[0].Allocations) - // } - - // session2InfoBeforeRebalance, _, err := wallet2Client.GetAppSessions(ctx, &sdk.GetAppSessionsOptions{AppSessionID: &session2ID}) - // if err != nil { - // log.Fatal(err) - // } - // if len(session2InfoBeforeRebalance) > 0 { - // fmt.Printf("Session 2 before rebalance - Version: %d, Allocations: %+v\n\n", session2InfoBeforeRebalance[0].Version, session2InfoBeforeRebalance[0].Allocations) - // } - - // // Prepare rebalance updates for both sessions - // session1RebalanceUpdate := app.AppStateUpdateV1{ - // AppSessionID: session1ID, - // Intent: app.AppStateUpdateIntentRebalance, - // Version: 3, - // Allocations: []app.AppAllocationV1{ - // {Participant: wallet1Address, Asset: "yellow", Amount: decimal.NewFromFloat(0.005)}, - // {Participant: wallet1Address, Asset: "yusd", Amount: decimal.NewFromFloat(0.00005)}, - // }, - // } - - // session1RebalanceRequest, err := app.PackAppStateUpdateV1(session1RebalanceUpdate) - // if err != nil { - // log.Fatal(err) - // } - - // appSession1RebalanceSig, _ := appSession1Signer.Sign(session1RebalanceRequest) - - // session2RebalanceUpdate := app.AppStateUpdateV1{ - // AppSessionID: session2ID, - // Intent: app.AppStateUpdateIntentRebalance, - // Version: 4, - // Allocations: []app.AppAllocationV1{ - // {Participant: wallet2Address, Asset: "yusd", Amount: decimal.NewFromFloat(0.00005)}, - // {Participant: wallet2Address, Asset: "yellow", Amount: decimal.NewFromFloat(0.005)}, - // {Participant: wallet3Address, Asset: "yellow", Amount: decimal.NewFromFloat(0.005)}, - // }, - // } - - // session2RebalanceRequest, err := app.PackAppStateUpdateV1(session2RebalanceUpdate) - // if err != nil { - // log.Fatal(err) - // } - - // appSession2RebalanceSig, _ := appSession2Signer.Sign(session2RebalanceRequest) - // appSession3RebalanceSig, _ := appSession3Signer.Sign(session2RebalanceRequest) - - // // Submit atomic rebalance - // signedRebalanceUpdates := []app.SignedAppStateUpdateV1{ - // { - // AppStateUpdate: session1RebalanceUpdate, - // QuorumSigs: []string{appSession1RebalanceSig.String()}, - // }, - // { - // AppStateUpdate: session2RebalanceUpdate, - // QuorumSigs: []string{appSession2RebalanceSig.String(), appSession3RebalanceSig.String()}, - // }, - // } - - // rebalanceBatchID, err := wallet2Client.RebalanceAppSessions(ctx, signedRebalanceUpdates) - // if err != nil { - // log.Printf("⚠ Rebalance Error: %v", err) - // } else { - // fmt.Printf("✓ Atomic Rebalance Submitted. BatchID: %s\n\n", rebalanceBatchID) - // } - - // --- 7. Wallet 3 Withdraws from Session 2 --- - fmt.Println("=== Step 7: Wallet 3 Withdrawing from Session 2 ===") + // --- 6. Wallet 3 Withdraws from Session 2 --- + fmt.Println("=== Step 6: Wallet 3 Withdrawing from Session 2 ===") session2WithdrawUpdate := app.AppStateUpdateV1{ AppSessionID: session2ID, @@ -482,8 +363,8 @@ func main() { fmt.Println("✓ Wallet 3 successfully withdrew YELLOW back to channel") } - // --- 8. Close Both App Sessions --- - fmt.Println("=== Step 8: Closing Both App Sessions ===") + // --- 7. Close Both App Sessions --- + fmt.Println("=== Step 7: Closing Both App Sessions ===") // Close Session 1 session1CloseUpdate := app.AppStateUpdateV1{ @@ -535,36 +416,6 @@ func main() { fmt.Println("✓ Session 2 successfully closed") } - // --- 9. Fail Case: Create App Session for Unregistered App --- - // Only meaningful when the app registry is enabled — with apps.v1 disabled - // every app ID is "unregistered" from the registry's perspective and the - // node accepts the create call, so the fail-case has nothing to assert. - if appRegistryEnabled { - fmt.Println("\n=== Step 9: Creating App Session for Unregistered App (expected to fail) ===") - - unregisteredDefinition := app.AppDefinitionV1{ - ApplicationID: "unregistered-app-" + suffix, - Participants: []app.AppParticipantV1{ - {WalletAddress: wallet1Address, SignatureWeight: 100}, - }, - Quorum: 100, - Nonce: uint64(time.Now().UnixNano()), - } - - unregisteredCreateRequest, err := app.PackCreateAppSessionRequestV1(unregisteredDefinition, "{}") - if err != nil { - log.Fatal(err) - } - - unregisteredSig, _ := appSession1Signer.Sign(unregisteredCreateRequest) - _, _, _, err = wallet1Client.CreateAppSession(ctx, unregisteredDefinition, "{}", []string{unregisteredSig.String()}) - if err != nil { - fmt.Printf("✓ Expected error: %v\n", err) - } else { - fmt.Println("✗ Unexpected success: app session was created for unregistered app") - } - } - fmt.Println("\n=== Example Complete ===") } diff --git a/sdk/go/node.go b/sdk/go/node.go index 0e87fc50e..bf889c451 100644 --- a/sdk/go/node.go +++ b/sdk/go/node.go @@ -70,6 +70,31 @@ func (c *Client) GetBlockchains(ctx context.Context) ([]core.Blockchain, error) return config.Blockchains, nil } +// GetConfirmationDelay returns the confirmation-gate delay, in seconds, that the node +// applies before crediting an on-chain event for the given chain. A return value of 0 +// means the gate is disabled and events are credited immediately. +// +// This fetches the node config on each call (config is not cached on the client). +// +// Parameters: +// - chainID: The blockchain network ID (e.g., 1 for Ethereum mainnet) +// +// Returns: +// - Delay in seconds before off-chain credit lands; 0 if the gate is disabled +// - Error if the request fails or the chain is not present in the node config +func (c *Client) GetConfirmationDelay(ctx context.Context, chainID uint64) (uint32, error) { + blockchains, err := c.GetBlockchains(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get confirmation delay: %w", err) + } + for _, bc := range blockchains { + if bc.ID == chainID { + return bc.ConfirmationDelaySecs, nil + } + } + return 0, fmt.Errorf("blockchain %d not found in node config", chainID) +} + // GetAssets retrieves all supported assets with optional blockchain filter. // // Parameters: diff --git a/sdk/go/node_test.go b/sdk/go/node_test.go new file mode 100644 index 000000000..4630a5b9c --- /dev/null +++ b/sdk/go/node_test.go @@ -0,0 +1,79 @@ +package sdk + +import ( + "context" + "testing" + + "github.com/layer-3/nitrolite/pkg/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_GetConfirmationDelay(t *testing.T) { + t.Parallel() + + t.Run("returns confirmation delay for matching chain", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.NodeV1GetConfigMethod.String(), rpc.NodeV1GetConfigResponse{ + NodeAddress: "0xNodeAddress", + Blockchains: []rpc.BlockchainInfoV1{ + {Name: "Ethereum", BlockchainID: "1", ConfirmationDelaySecs: 36}, + {Name: "Polygon", BlockchainID: "137", ConfirmationDelaySecs: 5}, + }, + }) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + delay, err := client.GetConfirmationDelay(context.Background(), 1) + require.NoError(t, err) + assert.Equal(t, uint32(36), delay) + }) + + t.Run("returns 0 when gate is disabled", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.NodeV1GetConfigMethod.String(), rpc.NodeV1GetConfigResponse{ + NodeAddress: "0xNodeAddress", + Blockchains: []rpc.BlockchainInfoV1{ + {Name: "Polygon", BlockchainID: "137", ConfirmationDelaySecs: 0}, + }, + }) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + delay, err := client.GetConfirmationDelay(context.Background(), 137) + require.NoError(t, err) + assert.Equal(t, uint32(0), delay) + }) + + t.Run("returns error when chain not found", func(t *testing.T) { + t.Parallel() + mockDialer := NewMockDialer() + mockDialer.Dial(context.Background(), "", nil) + + mockDialer.RegisterResponse(rpc.NodeV1GetConfigMethod.String(), rpc.NodeV1GetConfigResponse{ + NodeAddress: "0xNodeAddress", + Blockchains: []rpc.BlockchainInfoV1{ + {Name: "Polygon", BlockchainID: "137", ConfirmationDelaySecs: 5}, + }, + }) + + client := &Client{ + rpcClient: rpc.NewClient(mockDialer), + } + + _, err := client.GetConfirmationDelay(context.Background(), 999) + require.Error(t, err) + assert.Contains(t, err.Error(), "999") + assert.Contains(t, err.Error(), "not found in node config") + }) +} diff --git a/sdk/go/user.go b/sdk/go/user.go index 6d53b7144..f372dcb17 100644 --- a/sdk/go/user.go +++ b/sdk/go/user.go @@ -3,7 +3,6 @@ package sdk import ( "context" "fmt" - "strconv" "github.com/layer-3/nitrolite/pkg/core" "github.com/layer-3/nitrolite/pkg/rpc" @@ -87,46 +86,3 @@ func (c *Client) GetTransactions(ctx context.Context, wallet string, opts *GetTr } return txs, transformPaginationMetadata(resp.Metadata), nil } - -// GetActionAllowances retrieves the action allowances for a user based on their staking level. -// -// Parameters: -// - wallet: The user's wallet address -// -// Returns: -// - Slice of ActionAllowance containing allowance information per gated action -// - Error if the request fails -func (c *Client) GetActionAllowances(ctx context.Context, wallet string) ([]core.ActionAllowance, error) { - req := rpc.UserV1GetActionAllowancesRequest{Wallet: wallet} - resp, err := c.rpcClient.UserV1GetActionAllowances(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to get action allowances: %w", err) - } - allowances, err := transformActionAllowances(resp.Allowances) - if err != nil { - return nil, fmt.Errorf("failed to transform action allowances: %w", err) - } - return allowances, nil -} - -// transformActionAllowances converts RPC ActionAllowanceV1 slice to core.ActionAllowance slice. -func transformActionAllowances(allowances []rpc.ActionAllowanceV1) ([]core.ActionAllowance, error) { - result := make([]core.ActionAllowance, 0, len(allowances)) - for _, a := range allowances { - allowance, err := strconv.ParseUint(a.Allowance, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid allowance value %q for action %q: %w", a.Allowance, a.GatedAction, err) - } - used, err := strconv.ParseUint(a.Used, 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid used value %q for action %q: %w", a.Used, a.GatedAction, err) - } - result = append(result, core.ActionAllowance{ - GatedAction: a.GatedAction, - TimeWindow: a.TimeWindow, - Allowance: allowance, - Used: used, - }) - } - return result, nil -} diff --git a/sdk/go/utils.go b/sdk/go/utils.go index 98929fe79..d321c1cc1 100644 --- a/sdk/go/utils.go +++ b/sdk/go/utils.go @@ -26,11 +26,11 @@ func transformNodeConfig(resp rpc.NodeV1GetConfigResponse) (*core.NodeConfig, er } blockchains = append(blockchains, core.Blockchain{ - Name: info.Name, - ID: blockchainID, - ChannelHubAddress: info.ChannelHubAddress, - LockingContractAddress: info.LockingContractAddress, - BlockStep: 0, // Not provided in RPC response + Name: info.Name, + ID: blockchainID, + ChannelHubAddress: info.ChannelHubAddress, + BlockStep: 0, // Not provided in RPC response + ConfirmationDelaySecs: info.ConfirmationDelaySecs, }) } diff --git a/sdk/go/utils_test.go b/sdk/go/utils_test.go index 021a47d6c..1d650e95f 100644 --- a/sdk/go/utils_test.go +++ b/sdk/go/utils_test.go @@ -19,9 +19,10 @@ func TestTransformNodeConfig(t *testing.T) { SupportedSigValidators: []core.ChannelSignerType{core.ChannelSignerType_SessionKey}, Blockchains: []rpc.BlockchainInfoV1{ { - Name: "Polygon", - BlockchainID: "137", - ChannelHubAddress: "0xHubAddress", + Name: "Polygon", + BlockchainID: "137", + ChannelHubAddress: "0xHubAddress", + ConfirmationDelaySecs: 10, }, }, } @@ -35,6 +36,7 @@ func TestTransformNodeConfig(t *testing.T) { assert.Len(t, config.Blockchains, 1) assert.Equal(t, uint64(137), config.Blockchains[0].ID) assert.Equal(t, "Polygon", config.Blockchains[0].Name) + assert.Equal(t, uint32(10), config.Blockchains[0].ConfirmationDelaySecs) // Test error case rpcResp.Blockchains[0].BlockchainID = "invalid" diff --git a/sdk/mcp/package-lock.json b/sdk/mcp/package-lock.json index 1f3062325..0cfb24bb3 100644 --- a/sdk/mcp/package-lock.json +++ b/sdk/mcp/package-lock.json @@ -25,9 +25,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", - "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -42,9 +42,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", - "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -59,9 +59,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", - "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -76,9 +76,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", - "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -93,9 +93,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", - "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -110,9 +110,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", - "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -127,9 +127,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", - "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -144,9 +144,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", - "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -161,9 +161,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", - "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -178,9 +178,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", - "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -195,9 +195,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", - "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -212,9 +212,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", - "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -229,9 +229,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", - "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -246,9 +246,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", - "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -263,9 +263,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", - "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -280,9 +280,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", - "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -297,9 +297,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", - "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -314,9 +314,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", - "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -331,9 +331,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", - "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -348,9 +348,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", - "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -365,9 +365,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", - "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -382,9 +382,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", - "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -399,9 +399,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", - "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -416,9 +416,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", - "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -433,9 +433,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", - "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -450,9 +450,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", - "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -793,9 +793,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.4", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", - "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -806,32 +806,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.4", - "@esbuild/android-arm": "0.27.4", - "@esbuild/android-arm64": "0.27.4", - "@esbuild/android-x64": "0.27.4", - "@esbuild/darwin-arm64": "0.27.4", - "@esbuild/darwin-x64": "0.27.4", - "@esbuild/freebsd-arm64": "0.27.4", - "@esbuild/freebsd-x64": "0.27.4", - "@esbuild/linux-arm": "0.27.4", - "@esbuild/linux-arm64": "0.27.4", - "@esbuild/linux-ia32": "0.27.4", - "@esbuild/linux-loong64": "0.27.4", - "@esbuild/linux-mips64el": "0.27.4", - "@esbuild/linux-ppc64": "0.27.4", - "@esbuild/linux-riscv64": "0.27.4", - "@esbuild/linux-s390x": "0.27.4", - "@esbuild/linux-x64": "0.27.4", - "@esbuild/netbsd-arm64": "0.27.4", - "@esbuild/netbsd-x64": "0.27.4", - "@esbuild/openbsd-arm64": "0.27.4", - "@esbuild/openbsd-x64": "0.27.4", - "@esbuild/openharmony-arm64": "0.27.4", - "@esbuild/sunos-x64": "0.27.4", - "@esbuild/win32-arm64": "0.27.4", - "@esbuild/win32-ia32": "0.27.4", - "@esbuild/win32-x64": "0.27.4" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escape-html": { @@ -1053,19 +1053,6 @@ "node": ">= 0.4" } }, - "node_modules/get-tsconfig": { - "version": "4.13.7", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", - "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1103,9 +1090,9 @@ } }, "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1414,16 +1401,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1609,14 +1586,13 @@ } }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" diff --git a/sdk/mcp/src/index.ts b/sdk/mcp/src/index.ts index 80f593e5e..b49576212 100644 --- a/sdk/mcp/src/index.ts +++ b/sdk/mcp/src/index.ts @@ -255,11 +255,9 @@ function categorizeMethod(name: string): string { function categorizeGoMethod(name: string): string { const lower = name.toLowerCase(); if (/channel|deposit|withdraw|transfer|checkpoint|challenge|acknowledge|close/.test(lower)) return 'Channels & Transactions'; - if (/appsession|appstate|appdef|rebalance/.test(lower)) return 'App Sessions'; + if (/appsession|appstate|appdef/.test(lower)) return 'App Sessions'; if (/sessionkey|keystate/.test(lower)) return 'Session Keys'; - if (/escrow|security|locked/.test(lower)) return 'Security Tokens'; - if (/app/.test(lower) && /register/.test(lower)) return 'App Registry'; - if (/balance|transaction|allowance|user/.test(lower)) return 'User Queries'; + if (/balance|transaction|user/.test(lower)) return 'User Queries'; if (/config|blockchain|asset|ping/.test(lower)) return 'Node & Config'; return 'Other'; } @@ -271,7 +269,6 @@ function loadGoTypes(): void { { path: resolve(PKG_ROOT, 'rpc/types.go'), source: 'pkg/rpc' }, { path: resolve(GO_SDK_ROOT, 'config.go'), source: 'sdk/go' }, { path: resolve(GO_SDK_ROOT, 'app_session.go'), source: 'sdk/go' }, - { path: resolve(GO_SDK_ROOT, 'app_registry.go'), source: 'sdk/go' }, { path: resolve(GO_SDK_ROOT, 'user.go'), source: 'sdk/go' }, { path: resolve(GO_SDK_ROOT, 'channel.go'), source: 'sdk/go' }, ]; @@ -372,7 +369,6 @@ function loadGoSdkMethods(): void { { path: resolve(GO_SDK_ROOT, 'node.go'), category: 'Node & Config' }, { path: resolve(GO_SDK_ROOT, 'user.go'), category: 'User Queries' }, { path: resolve(GO_SDK_ROOT, 'app_session.go'), category: 'App Sessions' }, - { path: resolve(GO_SDK_ROOT, 'app_registry.go'), category: 'App Registry' }, { path: resolve(GO_SDK_ROOT, 'client.go'), category: '' }, ]; @@ -405,7 +401,7 @@ function loadGoSdkMethods(): void { } function buildGoApiMethodsContent(): string { - const ORDER = ['Connection', 'Channels & Transactions', 'App Sessions', 'Session Keys', 'Security Tokens', 'App Registry', 'User Queries', 'Node & Config', 'Other']; + const ORDER = ['Connection', 'Channels & Transactions', 'App Sessions', 'Session Keys', 'User Queries', 'Node & Config', 'Other']; const grouped = new Map(); for (const m of goMethods) { const arr = grouped.get(m.category) ?? []; @@ -1519,10 +1515,14 @@ How to use Nitrolite for AI agent payments and agent-to-agent interactions. ## Why State Channels for AI Agents? AI agents need to make frequent, small payments — often thousands per session. On-chain transactions are too slow and expensive. State channels provide: -- **Instant finality** — no waiting for block confirmations +- **Instant off-chain transfers** — agent-to-agent payments settle the moment both parties co-sign; no waiting for block confirmations - **Near-zero cost** — gas only on channel open/close, not per-transfer - **Programmable** — agents manage channels autonomously via the SDK +> **On-chain steps lag.** Funding (deposit) and withdrawal still touch the chain. After the transaction is +> mined, the node waits \`confirmationDelaySecs\` (per chain, from \`node.v1.GetConfig\`) before crediting +> the off-chain balance — a reorg-safety gate. Only the **transfers between agents** are instant. + ## Agent Wallet Setup \`\`\`typescript diff --git a/sdk/ts-compat/README.md b/sdk/ts-compat/README.md index 764083fe2..e0b8d808b 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -143,7 +143,6 @@ await client.close(); | `getAccountInfo()` | Aggregate balance + channel count | | `getConfig()` | Node configuration | | `getBlockchains()` | List supported blockchains | -| `getActionAllowances(wallet?)` | Get gated action allowances for a wallet | | `getEscrowChannel(escrowChannelId)` | Query an escrow channel by ID | | `getChannelData(channelId)` | Full channel + state for a specific channel | | `getLastAppSessionsListError()` | Last `getAppSessionsList()` error message (if any) | @@ -160,13 +159,6 @@ await client.close(); App-session allocation strings remain **human-readable decimal strings** such as `'0.01'`. They are not raw smallest-unit token strings. -### App Registry - -| Method | Description | -|---|---| -| `getApps(options?)` | List registered applications (filter by appId, owner, pagination) | -| `registerApp(appID, metadata, creationApprovalNotRequired)` | Register a new application | - ### App Session Signing Helpers | Helper | Description | @@ -233,17 +225,6 @@ These legacy methods remain intentionally unsupported because the v1 runtime no Workflow-style RPC helpers such as `createTransferMessage(...)`, `createCreateChannelMessage(...)`, `createCloseChannelMessage(...)`, and `createResizeChannelMessage(...)` are in the same category: they stay exported for migration, but now fail fast with migration guidance instead of silently approximating old behavior. -### Security Token Locking - -| Method | Description | -|---|---| -| `lockSecurityTokens(targetWallet, chainId, amount)` | Lock tokens into the Locking contract for a target address | -| `initiateSecurityTokensWithdrawal(chainId)` | Start the unlock process for locked tokens | -| `cancelSecurityTokensWithdrawal(chainId)` | Re-lock tokens, cancelling a pending unlock | -| `withdrawSecurityTokens(chainId, destination)` | Withdraw unlocked tokens to a destination address | -| `approveSecurityToken(chainId, amount)` | Approve the Locking contract to spend tokens | -| `getLockedBalance(chainId, wallet?)` | Query locked balance (returns raw bigint) | - ### Lifecycle | Method | Description | @@ -378,41 +359,13 @@ poller.stop(); poller.setInterval(10000); // change interval ``` -## Security Token Locking - -Lock tokens into the on-chain Locking contract to provide security deposits. The locking token and its decimals are resolved from the contract at runtime — `blockchainRPCs` must be configured for the target chain or these methods will throw. - -```typescript -const chainId = 11155111; // Sepolia -// Yellow token on Sepolia has 18 decimals; 100 YELLOW = 100 * 10^18 -const amount = 100_000_000_000_000_000_000n; - -// Approve the Locking contract to spend tokens -await client.approveSecurityToken(chainId, amount); - -// Lock tokens for a target address -await client.lockSecurityTokens(targetWallet, chainId, amount); - -// Query locked balance (returns raw bigint in token's smallest unit) -const locked = await client.getLockedBalance(chainId); - -// Initiate unlock (starts the unlock period) -await client.initiateSecurityTokensWithdrawal(chainId); - -// Cancel unlock (re-lock tokens) -await client.cancelSecurityTokensWithdrawal(chainId); - -// After unlock period elapses, withdraw to a destination -await client.withdrawSecurityTokens(chainId, destinationWallet); -``` - ### Amount conventions The compat layer keeps the old amount conventions explicit instead of flattening them: | Method group | Input type | Example: 100 tokens (18 decimals) | |---|---|---| -| `deposit`, `withdrawal`, `lockSecurityTokens`, `approveSecurityToken`, `getLockedBalance` | Raw `bigint` | `100_000_000_000_000_000_000n` | +| `deposit`, `withdrawal` | Raw `bigint` | `100_000_000_000_000_000_000n` | | `transfer` | Raw asset-unit string via `TransferAllocation.amount` | `'100000000000000000000'` | | `createAppSession`, `closeAppSession`, `submitAppState` allocations | Human-readable decimal string | `'100.0'` | diff --git a/sdk/ts-compat/docs/migration-onchain.md b/sdk/ts-compat/docs/migration-onchain.md index 3b5facfc1..0550052ad 100644 --- a/sdk/ts-compat/docs/migration-onchain.md +++ b/sdk/ts-compat/docs/migration-onchain.md @@ -92,3 +92,44 @@ const addresses = { ``` **After (compat):** Fetched from nitronode `get_config` — no manual setup. The `addresses` field in config is deprecated and ignored. + +## 6. On-Chain Credit Timing (Confirmation Delay) + +**Before (v0.5.3):** The node pushed a `BalanceUpdate` (and credited the off-chain +balance) as soon as it observed the on-chain deposit event — effectively the moment the +deposit transaction was mined. + +**After (compat / v1 node):** The node now applies a per-chain **confirmation delay** +before crediting an on-chain deposit (or reflecting a withdrawal) off-chain. After your +deposit transaction is mined, the off-chain balance only updates once the node's +confirmation window for that chain has elapsed. This window is configured per chain on the +node (`confirmation_delay_secs`) and can be significant on slow-finality chains — up to +several minutes on Ethereum mainnet. It exists to protect against chain reorganizations +re-crediting balances that no longer exist on-chain. + +You can read the active delay per chain from the node config: + +```typescript +const config = await client.getConfig(); +// Each blockchain entry exposes `confirmationDelaySecs` (seconds). +// Display "off-chain credit in ~Ns" to users after a deposit. +``` + +**What this means for you:** + +- **Using `EventPoller`? No change required.** `EventPoller` polls the node every 5 + seconds (`new EventPoller(client, callbacks)`), so the credit simply arrives in a later + `onBalanceUpdate` callback — a few polls after the transaction receipt. The delay is fully + transparent; you do not need to do anything. + +- **Migrating a v0.5.3 consumer that assumed instant credit on tx receipt?** This is the one + behavior that changes. Any code that treated `await client.deposit(...)` resolving (or the + tx receipt landing) as "the off-chain balance is now updated" will read a stale balance if + it checks immediately. Replace that assumption with one of: + - Subscribe via `EventPoller` and react to `onBalanceUpdate` (recommended), or + - Poll `client.getBalances()` until the expected credit appears, waiting at least + `confirmationDelaySecs` for the relevant chain before treating absence as failure. + + Do not display "Confirmed" / "Credited" to the user the instant the deposit transaction is + mined. The transaction is confirmed; the off-chain credit is still pending the node's + confirmation window. diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index 840276d8a..fe933adbc 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -11,7 +11,6 @@ import type { AppParticipantV1, AppAllocationV1, AppSessionKeyStateV1, - AppInfoV1, ChannelSessionKeyStateV1, } from '@yellow-org/sdk'; import type * as core from '@yellow-org/sdk'; @@ -161,7 +160,6 @@ export class NitroliteClient { private _lastAppSessionsListError: string | null = null; private _lastAppSessionsListErrorLogged: string | null = null; private _blockchains: core.Blockchain[] | null = null; - private _lockingTokenDecimals = new Map(); private _blockchainRPCs: Record; private _publicClients = new Map(); @@ -1226,10 +1224,6 @@ export class NitroliteClient { return this._blockchains; } - async getActionAllowances(wallet?: Address): Promise { - return this.innerClient.getActionAllowances(wallet ?? this.userAddress); - } - async getEscrowChannel(escrowChannelId: string): Promise { const channel = await this.innerClient.getEscrowChannel(escrowChannelId); if (!channel) { @@ -1238,118 +1232,4 @@ export class NitroliteClient { return channel; } - // ----------------------------------------------------------------------- - // App registry - // ----------------------------------------------------------------------- - - async getApps(options?: { - appId?: string; - ownerWallet?: string; - page?: number; - pageSize?: number; - }): Promise<{ apps: AppInfoV1[]; metadata: core.PaginationMetadata }> { - return this.innerClient.getApps(options); - } - - async registerApp( - appID: string, - metadata: string, - creationApprovalNotRequired: boolean, - ): Promise { - await this.innerClient.registerApp(appID, metadata, creationApprovalNotRequired); - } - - // ----------------------------------------------------------------------- - // Security token locking - // ----------------------------------------------------------------------- - - async lockSecurityTokens( - targetWallet: Address, - chainId: number, - amount: bigint, - ): Promise { - if (amount <= 0n) throw new Error('amount must be positive'); - const decimals = await this.getLockingTokenDecimals(chainId); - const humanAmount = this.toHumanAmount(amount, decimals); - return this.innerClient.escrowSecurityTokens( - targetWallet, - BigInt(chainId), - humanAmount, - ); - } - - async initiateSecurityTokensWithdrawal(chainId: number): Promise { - return this.innerClient.initiateSecurityTokensWithdrawal(BigInt(chainId)); - } - - async cancelSecurityTokensWithdrawal(chainId: number): Promise { - return this.innerClient.cancelSecurityTokensWithdrawal(BigInt(chainId)); - } - - async withdrawSecurityTokens( - chainId: number, - destination: Address, - ): Promise { - return this.innerClient.withdrawSecurityTokens(BigInt(chainId), destination); - } - - async approveSecurityToken(chainId: number, amount: bigint): Promise { - if (amount <= 0n) throw new Error('amount must be positive'); - const decimals = await this.getLockingTokenDecimals(chainId); - const humanAmount = this.toHumanAmount(amount, decimals); - return this.innerClient.approveSecurityToken(BigInt(chainId), humanAmount); - } - - async getLockedBalance(chainId: number, wallet?: Address): Promise { - const balance = await this.innerClient.getLockedBalance( - BigInt(chainId), - wallet ?? this.userAddress, - ); - const decimals = await this.getLockingTokenDecimals(chainId); - return BigInt(balance.mul(new Decimal(10).pow(decimals)).toFixed(0)); - } - - private static readonly LOCKING_ASSET_ABI = [ - { type: 'function', name: 'asset', inputs: [], outputs: [{ type: 'address' }], stateMutability: 'view' }, - ] as const; - - private static readonly ERC20_DECIMALS_ABI = [ - { type: 'function', name: 'decimals', inputs: [], outputs: [{ type: 'uint8' }], stateMutability: 'view' }, - ] as const; - - private async getLockingTokenDecimals(chainId: number): Promise { - const cached = this._lockingTokenDecimals.get(chainId); - if (cached !== undefined) return cached; - - const blockchains = await this.ensureBlockchains(); - const chain = blockchains.find((b) => b.id === BigInt(chainId)); - if (!chain?.lockingContractAddress) { - throw new Error(`No locking contract configured for chain ${chainId}`); - } - - const rpcUrl = this._blockchainRPCs[chainId]; - if (!rpcUrl) { - throw new Error( - `No RPC URL configured for chain ${chainId}. ` + - 'Pass blockchainRPCs in NitroliteClientConfig to use locking methods.', - ); - } - - const publicClient = createPublicClient({ transport: http(rpcUrl) }); - - const tokenAddress = await publicClient.readContract({ - address: chain.lockingContractAddress, - abi: NitroliteClient.LOCKING_ASSET_ABI, - functionName: 'asset', - }) as Address; - - const decimals = await publicClient.readContract({ - address: tokenAddress, - abi: NitroliteClient.ERC20_DECIMALS_ABI, - functionName: 'decimals', - }) as number; - - this._lockingTokenDecimals.set(chainId, decimals); - return decimals; - } } 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 9dc60c883..5457c591a 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 @@ -518,9 +518,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "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", @@ -535,10 +533,8 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "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", @@ -552,18 +548,14 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "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", "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>", @@ -578,7 +570,6 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "transfer: (destination: Address, allocations: TransferAllocation[]): Promise", "userAddress: Address", "waitForClose: (): Promise", - "withdrawSecurityTokens: (chainId: number, destination: Address): Promise", "withdrawal: (tokenAddress: Address, amount: bigint): Promise", ], "staticProperties": [ diff --git a/sdk/ts-compat/test/unit/client.test.ts b/sdk/ts-compat/test/unit/client.test.ts index 9f18c94dd..2f36217a7 100644 --- a/sdk/ts-compat/test/unit/client.test.ts +++ b/sdk/ts-compat/test/unit/client.test.ts @@ -140,7 +140,6 @@ describe('NitroliteClient getAppSessionsList compat mapping', () => { name: 'Sepolia', id: 11155111n, channelHubAddress: '0x3333333333333333333333333333333333333333', - lockingContractAddress: '0x4444444444444444444444444444444444444444', blockStep: 0n, }, ], diff --git a/sdk/ts/README.md b/sdk/ts/README.md index 8ffc2310d..6be2a85c1 100644 --- a/sdk/ts/README.md +++ b/sdk/ts/README.md @@ -59,12 +59,6 @@ client.getEscrowChannel(escrowChannelId) // Escrow channel info client.getLatestState(wallet, asset, onlySigned) // Latest state ``` -### App Registry -```typescript -client.getApps(opts) // List registered apps -client.registerApp(appID, metadata, approvalNotRequired) // Register new app -``` - ### App Sessions ```typescript client.getAppSessions(opts) // List sessions @@ -73,7 +67,6 @@ client.createAppSession(definition, sessionData, sigs) // Create sessio client.createAppSession(def, data, sigs, { ownerSig }) // Create with owner approval client.submitAppSessionDeposit(update, sigs, asset, amount) // Deposit to session client.submitAppState(update, sigs) // Update session -client.rebalanceAppSessions(signedUpdates) // Atomic rebalance ``` ### App Session Keys @@ -340,6 +333,16 @@ Settles the latest co-signed state on-chain. This is the single entry point for const txHash = await client.checkpoint('usdc'); ``` +> **Confirmation delay.** `checkpoint()` resolves when the transaction is **mined**, not when the node has +> credited the result to your off-chain balance. The node applies a per-chain confirmation gate before +> committing any on-chain event: it waits `confirmationDelaySecs` seconds (from `getConfig()`) after the +> event is observed to protect against chain reorganizations. Until that window elapses, `getBalances()` +> will not reflect a freshly deposited amount. On chains where the gate is enabled this is typically a few +> seconds; operators may configure it as high as the chain's hard-finality time (~13 min on Ethereum L1). +> A `confirmationDelaySecs` of `0` means the gate is disabled and credit is immediate. Off-chain +> `transfer()` is never gated — it does not touch the chain. To wait for the credit, use +> `client.waitForCheckpoint(asset, txHash)` or poll `getBalances()`. + **Requirements:** - Blockchain RPC configured via `withBlockchainRPC()` - A co-signed state must exist (call `deposit()`, `withdraw()`, etc. first) @@ -386,6 +389,11 @@ const blockchains = await client.getBlockchains(); const assets = await client.getAssets(); // or client.getAssets(blockchainId) ``` +Each entry in `config.blockchains` (and each item from `getBlockchains()`) includes +`confirmationDelaySecs` — the number of seconds the node waits after observing an on-chain event before +crediting it to off-chain balances. `0` means the gate is disabled. Use this to show users the expected +wait after a `checkpoint()`. See [`checkpoint()`](#checkpointasset-promisestring) above. + ### User Data ```typescript @@ -407,21 +415,6 @@ const state = await client.getLatestState(wallet, asset, onlySigned); **Note:** State submission and channel creation are handled internally by state operations (`deposit()`, `withdraw()`, `transfer()`). On-chain settlement is handled by `checkpoint()`. -### App Registry - -```typescript -// List registered applications with optional filtering -const { apps, metadata } = await client.getApps({ - appId: 'my-app', - ownerWallet: '0x1234...', - page: 1, - pageSize: 10, -}); - -// Register a new application -await client.registerApp('my-app', '{"name": "My App"}', false); -``` - ### App Sessions (Low-Level) ```typescript @@ -444,8 +437,6 @@ const nodeSig = await client.submitAppSessionDeposit( ); await client.submitAppState(appUpdate, quorumSigs); - -const batchId = await client.rebalanceAppSessions(signedUpdates); ``` #### Owner Approval for App Session Creation @@ -839,9 +830,6 @@ async function appSessionExample() { ); try { - // Register app (required before creating sessions) - await client.registerApp('chess-v1', '{}', true); - // Create app session signer const msgSigner = new EthereumMsgSigner(process.env.PRIVATE_KEY!); const appSessionSigner = new AppSessionWalletSignerV1(msgSigner); @@ -969,9 +957,6 @@ import { AppSessionKeySignerV1, createSigners, } from '@yellow-org/sdk'; - -// App Registry types (from rpc/types) -import type { AppV1, AppInfoV1 } from '@yellow-org/sdk'; ``` ### BigInt for Chain IDs diff --git a/sdk/ts/examples/app_sessions/README.md b/sdk/ts/examples/app_sessions/README.md index ad8e31efa..cdc13a76b 100644 --- a/sdk/ts/examples/app_sessions/README.md +++ b/sdk/ts/examples/app_sessions/README.md @@ -7,9 +7,8 @@ This example demonstrates the complete lifecycle of Nitrolite app sessions, incl 3. **Create second app session** for wallet 2 with wallet 3 as a participant 4. **Deposit WETH** into second app session by wallet 2 5. **Redistribute app state** within app session so that participant with wallet 3 also has some allocation -6. **Rebalance 2 app sessions atomically** -7. **Wallet 3 withdraws** from his app session -8. **Close both app sessions** +6. **Wallet 3 withdraws** from his app session +7. **Close both app sessions** ## Prerequisites @@ -66,17 +65,11 @@ This script performs a complete app session lifecycle test: - Wallet 3 gets 0.005 WETH - This is an "operate" intent state update -### Step 6: Atomic Rebalance Across Sessions -- Performs an atomic rebalance across both app sessions -- Session 1: Wallet 1 gets 0.005 WETH and 0.00005 USDC -- Session 2: Wallet 2 gets 0.00005 USDC and 0.005 WETH, Wallet 3 gets 0.005 WETH -- All updates happen atomically or none at all - -### Step 7: Wallet 3 Withdraws from Session 2 +### Step 6: Wallet 3 Withdraws from Session 2 - Wallet 3 withdraws 0.004 WETH back to their channel - Final allocations: Wallet 2 (0.00005 USDC, 0.005 WETH), Wallet 3 (0.001 WETH) -### Step 8: Close Both App Sessions +### Step 7: Close Both App Sessions - Closes session 1 with wallet 1's signature - Closes session 2 with both wallet 2 and wallet 3's signatures - Finalizes all app session state @@ -90,7 +83,7 @@ This script performs a complete app session lifecycle test: - Unique nonce for session creation ### App State Updates -- Different intents: Deposit, Operate, Withdraw, Close, Rebalance +- Different intents: Deposit, Operate, Withdraw, Close - Version tracking - Allocations per participant per asset - Session data (JSON string) @@ -114,7 +107,6 @@ This script performs a complete app session lifecycle test: - `createAppSession()` - Create new app sessions - `submitAppSessionDeposit()` - Submit deposits to app sessions - `submitAppState()` - Submit state updates (operate, withdraw, close) -- `rebalanceAppSessions()` - Atomic rebalancing across sessions - `getAppSessions()` - Query app session information ## Comparison with Go SDK diff --git a/sdk/ts/examples/app_sessions/lifecycle.ts b/sdk/ts/examples/app_sessions/lifecycle.ts index 0bbbaf895..d0c6569ca 100644 --- a/sdk/ts/examples/app_sessions/lifecycle.ts +++ b/sdk/ts/examples/app_sessions/lifecycle.ts @@ -20,25 +20,16 @@ * pre-existing channel; the withdraw step will open/credit its ledger * automatically. * - * 4. App registry: if the node was started with the app registry disabled - * (apps.v1 group disabled), the registration step is skipped at runtime - * and app sessions are created against unregistered app IDs. No action - * is required from the operator — the example detects this via a probe - * call to getApps(). - * * This example demonstrates: - * 1. Register apps in the app registry (skipped if apps.v1 group is disabled) - * 2. Create first app session for wallet 1 - * 3. Deposit YUSD into first app session by wallet 1 + * 1. Create first app session for wallet 1 + * 2. Deposit YUSD into first app session by wallet 1 * (auto-opens wallet 1's YUSD channel via acknowledge() if missing) - * 4. Create second app session for wallet 2 with wallet 3 as a participant - * 5. Deposit YELLOW into second app session by wallet 2 + * 3. Create second app session for wallet 2 with wallet 3 as a participant + * 4. Deposit YELLOW into second app session by wallet 2 * (auto-opens wallet 2's YELLOW channel via acknowledge() if missing) - * 6. Redistribute app state within app session so that participant with wallet 3 also has some allocation - * 7. Wallet 3 withdraws from his app session - * 8. Close both app sessions - * 9. Fail case: attempt to create app session for unregistered app (expected to fail). - * Skipped entirely when the app registry is disabled. + * 5. Redistribute app state within app session so that participant with wallet 3 also has some allocation + * 6. Wallet 3 withdraws from his app session + * 7. Close both app sessions */ // Node <22 does not expose a stable global WebSocket. The SDK dialer uses @@ -68,11 +59,6 @@ import { import { packCreateAppSessionRequestV1, packAppStateUpdateV1, packAppSessionKeyStateV1 } from '../../src/app/packing'; import { isFinal } from '../../src/core/state'; -// appRegistryDisabledMsg is the error fragment returned by the node when the -// apps.v1 RPC group is disabled by configuration. The example uses this to -// decide whether to skip the registration step. -const APP_REGISTRY_DISABLED_MSG = 'apps.v1 group is disabled'; - /** * ensureChannelOpen guarantees that the given wallet has an acknowledged * channel open for asset. If the node holds no state for the wallet/asset @@ -156,40 +142,14 @@ async function main() { await ensureChannelOpen('Wallet 2', wallet2Client, 'yellow'); console.log(); - // --- 1. Register Apps --- - console.log('=== Step 1: Registering Apps ==='); - + // App session IDs — app sessions are created against arbitrary application + // IDs; no prior registration step is required. const suffix = String(Math.floor(Math.random() * 1000000)).padStart(6, '0'); const app1ID = `test-app-${suffix}`; const app2ID = `multi-party-app-${suffix}`; - // Probe the apps.v1 group via getApps. If the node has the app registry - // disabled, the probe throws an error containing APP_REGISTRY_DISABLED_MSG - // and we skip registration entirely — app sessions can still be created - // against unregistered IDs in that mode. - let appRegistryEnabled = true; - try { - await wallet1Client.getApps(); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - if (msg.includes(APP_REGISTRY_DISABLED_MSG)) { - appRegistryEnabled = false; - console.log('ℹ App registry is disabled on the node — skipping app registration'); - } else { - throw err; - } - } - - if (appRegistryEnabled) { - await wallet1Client.registerApp(app1ID, '{}', true); - console.log(`✓ Registered app: ${app1ID}`); - - await wallet1Client.registerApp(app2ID, '{}', false); - console.log(`✓ Registered app: ${app2ID} (owner approval required)\n`); - } - - // --- 2. Create App Session 1 (Single Participant: Wallet 1) --- - console.log('=== Step 2: Creating App Session 1 (Wallet 1 only) ==='); + // --- 1. Create App Session 1 (Single Participant: Wallet 1) --- + console.log('=== Step 1: Creating App Session 1 (Wallet 1 only) ==='); const session1Definition: AppDefinitionV1 = { applicationId: app1ID, @@ -209,7 +169,7 @@ async function main() { console.log(`✓ Created App Session 1: ${session1ID}\n`); // --- 3. Deposit YUSD into Session 1 --- - console.log('=== Step 3: Depositing YUSD into Session 1 ==='); + console.log('=== Step 2: Depositing YUSD into Session 1 ==='); const session1DepositAmount = new Decimal(0.0001); const session1DepositUpdate: AppStateUpdateV1 = { @@ -237,8 +197,8 @@ async function main() { console.log(`⚠ Deposit warning: ${err}`); } - // --- 4. Create App Session 2 (Multi-Party: Wallet 2 & 3) --- - console.log('=== Step 4: Creating App Session 2 (Wallet 2 & 3) ==='); + // --- 3. Create App Session 2 (Multi-Party: Wallet 2 & 3) --- + console.log('=== Step 3: Creating App Session 2 (Wallet 2 & 3) ==='); const appID = app2ID; @@ -288,19 +248,15 @@ async function main() { const appSession2CreateSig = await appSession2Signer.signMessage(session2CreateRequest); const appSession3CreateSig = await appSession3Signer.signMessage(session2CreateRequest); - // Owner approval: wallet1 is the owner of app2, sign the create request using app session signer - const ownerApprovalSig = await appSession1Signer.signMessage(session2CreateRequest); - const { appSessionId: session2ID } = await wallet2Client.createAppSession( session2Definition, '{}', - [appSession2CreateSig, appSession3CreateSig], - { ownerSig: ownerApprovalSig } + [appSession2CreateSig, appSession3CreateSig] ); console.log(`✓ Created App Session 2: ${session2ID}\n`); - // --- 5. Deposit YELLOW into Session 2 by Wallet 2 --- - console.log('=== Step 5: Depositing YELLOW into Session 2 ==='); + // --- 4. Deposit YELLOW into Session 2 by Wallet 2 --- + console.log('=== Step 4: Depositing YELLOW into Session 2 ==='); const session2DepositAmount = new Decimal(0.00015); const session2DepositUpdate: AppStateUpdateV1 = { @@ -335,8 +291,8 @@ async function main() { ); } - // --- 6. Redistribute within Session 2 (Wallet 2 -> Wallet 3) --- - console.log('=== Step 6: Redistributing funds in Session 2 ==='); + // --- 5. Redistribute within Session 2 (Wallet 2 -> Wallet 3) --- + console.log('=== Step 5: Redistributing funds in Session 2 ==='); const session2RedistributeUpdate: AppStateUpdateV1 = { appSessionId: session2ID, @@ -369,12 +325,8 @@ async function main() { throw err; } - // NOTE: Rebalance step is disabled. - // // --- 7. Rebalance Both App Sessions Atomically --- - // ... (rebalance code omitted) ... - - // --- 7. Wallet 3 Withdraws from Session 2 --- - console.log('=== Step 7: Wallet 3 Withdrawing from Session 2 ==='); + // --- 6. Wallet 3 Withdraws from Session 2 --- + console.log('=== Step 6: Wallet 3 Withdrawing from Session 2 ==='); const session2WithdrawUpdate: AppStateUpdateV1 = { appSessionId: session2ID, @@ -405,8 +357,8 @@ async function main() { console.log(`⚠ Withdraw Error: ${err}\n`); } - // --- 8. Close Both App Sessions --- - console.log('=== Step 8: Closing Both App Sessions ==='); + // --- 7. Close Both App Sessions --- + console.log('=== Step 7: Closing Both App Sessions ==='); // Close Session 1 const session1CloseUpdate: AppStateUpdateV1 = { @@ -455,35 +407,6 @@ async function main() { console.log(`⚠ Close Session 2 Error: ${err}`); } - // --- 9. Fail Case: Create App Session for Unregistered App --- - // Only meaningful when the app registry is enabled — with apps.v1 disabled - // every app ID is "unregistered" from the registry's perspective and the - // node accepts the create call, so the fail-case has nothing to assert. - if (appRegistryEnabled) { - console.log('\n=== Step 9: Creating App Session for Unregistered App (expected to fail) ==='); - - const unregisteredDefinition: AppDefinitionV1 = { - applicationId: `unregistered-app-${suffix}`, - participants: [{ walletAddress: wallet1Address, signatureWeight: 100 }], - quorum: 100, - nonce: BigInt(Date.now() * 1000000), - }; - - const unregisteredCreateRequest = packCreateAppSessionRequestV1(unregisteredDefinition, '{}'); - const unregisteredSig = await appSession1Signer.signMessage(unregisteredCreateRequest); - - try { - await wallet1Client.createAppSession( - unregisteredDefinition, - '{}', - [unregisteredSig] - ); - console.log('✗ Unexpected success: app session was created for unregistered app'); - } catch (err) { - console.log(`✓ Expected error: ${err}`); - } - } - console.log('\n=== Example Complete ==='); // Close clients diff --git a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx index 63428f5e5..07114acb6 100644 --- a/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx +++ b/sdk/ts/examples/example-app/src/components/WalletDashboard.tsx @@ -3,7 +3,7 @@ import { ArrowDownToLine, ArrowUpFromLine, Send, XCircle, Shield, AlertTriangle, CheckCircle2, ArrowRightLeft, RefreshCw, Key, Loader2, ChevronDown, ChevronUp, - Users, Database, Copy, Check, Lock, Unlock, AppWindow, Plus, + Users, Database, Copy, Check, } from 'lucide-react'; import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'; import type { WalletClient } from 'viem'; @@ -13,7 +13,7 @@ import { getChannelSessionKeyAuthMetadataHashV1, packChannelKeyStateV1, } from '@yellow-org/sdk'; -import type { Client, ChannelSessionKeyStateV1, ActionAllowance, AppInfoV1 } from '@yellow-org/sdk'; +import type { Client, ChannelSessionKeyStateV1 } from '@yellow-org/sdk'; import type { SessionKeyState, StatusMessage } from '../types'; import { formatAddress, formatBalance, timeAgo, formatTxType } from '../utils'; import { Button } from './ui/button'; @@ -86,12 +86,6 @@ export default function WalletDashboard({ const [selectedTx, setSelectedTx] = useState(null); const [copied, setCopied] = useState(false); - // Security token state - const [lockedBalance, setLockedBalance] = useState(null); - const [allowances, setAllowances] = useState([]); - const [lockingLoading, setLockingLoading] = useState(false); - const [lockAmount, setLockAmount] = useState(''); - // Advanced section const [showAdvanced, setShowAdvanced] = useState(false); const [nodeConfig, setNodeConfig] = useState(null); @@ -103,14 +97,6 @@ export default function WalletDashboard({ const [keyStatesLoading, setKeyStatesLoading] = useState(false); const [revokingKey, setRevokingKey] = useState(null); - // App Registry - const [myApps, setMyApps] = useState([]); - const [myAppsLoading, setMyAppsLoading] = useState(false); - const [registerAppId, setRegisterAppId] = useState(''); - const [registerAppMetadata, setRegisterAppMetadata] = useState(''); - const [registerAppNoApproval, setRegisterAppNoApproval] = useState(false); - const [registeringApp, setRegisteringApp] = useState(false); - // Fetch all wallet data const fetchData = useCallback(async (isManual = false) => { @@ -122,8 +108,6 @@ export default function WalletDashboard({ client.getLatestState(address as `0x${string}`, asset, true), client.getHomeChannel(address as `0x${string}`, asset), client.getTransactions(address as `0x${string}`, { page: 1, pageSize: 10 }), - client.getLockedBalance(BigInt(chainId), address), - client.getActionAllowances(address as `0x${string}`), !nodeConfig ? client.getConfig() : Promise.resolve(null), ]); @@ -134,16 +118,14 @@ export default function WalletDashboard({ if (results[4].status === 'fulfilled') { setTransactions((results[4].value as any).transactions || []); } - if (results[5].status === 'fulfilled') setLockedBalance(results[5].value as Decimal); - if (results[6].status === 'fulfilled') setAllowances(results[6].value as ActionAllowance[]); - if (results[7].status === 'fulfilled' && results[7].value) setNodeConfig(results[7].value); + if (results[5].status === 'fulfilled' && results[5].value) setNodeConfig(results[5].value); } catch (e) { console.error('Fetch error:', e); } finally { setLoading(false); setRefreshing(false); } - }, [client, address, asset, chainId]); + }, [client, address, asset]); // Initial fetch + polling useEffect(() => { @@ -336,77 +318,6 @@ export default function WalletDashboard({ } }; - // --- Security token handlers --- - - const GATED_ACTION_LABELS: Record = { - transfer: 'Transfers', - app_session_creation: 'App Session Creations', - app_session_operation: 'App Session Updates', - app_session_deposit: 'App Session Deposits', - app_session_withdrawal: 'App Session Withdrawals', - }; - - const handleLockTokens = async () => { - if (!lockAmount || lockingLoading) return; - try { - setLockingLoading(true); - const amount = new Decimal(lockAmount); - try { - await client.escrowSecurityTokens(address, BigInt(chainId), amount); - } catch (error) { - if (!isAllowanceError(error)) throw error; - await client.approveSecurityToken(BigInt(chainId), MAX_APPROVE_AMOUNT); - await client.escrowSecurityTokens(address, BigInt(chainId), amount); - } - showStatus('success', 'Tokens locked successfully'); - setLockAmount(''); - await fetchData(); - } catch (error) { - showStatus('error', 'Lock failed', error instanceof Error ? error.message : String(error)); - } finally { - setLockingLoading(false); - } - }; - - const handleInitiateUnlock = async () => { - try { - setLockingLoading(true); - await client.initiateSecurityTokensWithdrawal(BigInt(chainId)); - showStatus('success', 'Unlock initiated', 'Tokens will be available for withdrawal after the unlock period'); - await fetchData(); - } catch (error) { - showStatus('error', 'Unlock initiation failed', error instanceof Error ? error.message : String(error)); - } finally { - setLockingLoading(false); - } - }; - - const handleCancelUnlock = async () => { - try { - setLockingLoading(true); - await client.cancelSecurityTokensWithdrawal(BigInt(chainId)); - showStatus('success', 'Unlock cancelled, tokens re-locked'); - await fetchData(); - } catch (error) { - showStatus('error', 'Cancel unlock failed', error instanceof Error ? error.message : String(error)); - } finally { - setLockingLoading(false); - } - }; - - const handleWithdrawTokens = async () => { - try { - setLockingLoading(true); - await client.withdrawSecurityTokens(BigInt(chainId), address); - showStatus('success', 'Tokens withdrawn successfully'); - await fetchData(); - } catch (error) { - showStatus('error', 'Withdraw failed', error instanceof Error ? error.message : String(error)); - } finally { - setLockingLoading(false); - } - }; - // --- Advanced section handlers --- const fetchNodeConfig = async () => { @@ -453,35 +364,6 @@ export default function WalletDashboard({ } }; - const fetchMyApps = async () => { - try { - setMyAppsLoading(true); - const { apps } = await client.getApps({ ownerWallet: address }); - setMyApps(apps); - } catch { - setMyApps([]); - } finally { - setMyAppsLoading(false); - } - }; - - const handleRegisterApp = async () => { - if (!registerAppId || registeringApp) return; - try { - setRegisteringApp(true); - await client.registerApp(registerAppId, registerAppMetadata, registerAppNoApproval); - showStatus('success', 'App registered', `App ID: ${registerAppId}`); - setRegisterAppId(''); - setRegisterAppMetadata(''); - setRegisterAppNoApproval(false); - await fetchMyApps(); - } catch (error) { - showStatus('error', 'Registration failed', error instanceof Error ? error.message : String(error)); - } finally { - setRegisteringApp(false); - } - }; - const handleRevokeKey = async (ks: ChannelSessionKeyStateV1) => { if (!walletClient.account) return; const revokeId = `${ks.session_key}-${ks.version}`; @@ -540,7 +422,6 @@ export default function WalletDashboard({ if (!nodeConfig) fetchNodeConfig(); fetchAppSessions(); fetchKeyStates(); - fetchMyApps(); } }, [showAdvanced]); // eslint-disable-line react-hooks/exhaustive-deps @@ -606,10 +487,10 @@ export default function WalletDashboard({ )} - {/* Hero: Balance + Actions + Security Token */} -
- {/* Channel Balance Card — spans 2 cols */} -
+ {/* Hero: Balance + Actions */} +
+ {/* Channel Balance Card */} +
{/* Top: asset tabs + address */}
@@ -722,78 +603,6 @@ export default function WalletDashboard({ )}
- - {/* Security Token Card */} -
-
- - Security Token -
- -
-
- {lockedBalance ? lockedBalance.toString() : '0'} -
-
- YELLOW Locked -
-
- - {/* Lock input */} -
- setLockAmount(e.target.value)} - placeholder="Amount" - className="h-9 flex-1 px-3 text-xs glass-input rounded-lg font-mono focus:outline-none focus:border-accent" - /> - -
- - {/* Unlock controls — only when there's a balance */} - {lockedBalance && !lockedBalance.isZero() && ( -
- - - -
- )} -
{/* Activity Feed */} @@ -902,45 +711,6 @@ export default function WalletDashboard({
- {/* Action Allowances */} - {allowances.length > 0 && ( -
-
-
- - Action Allowances -
-
- Allowances increase as you lock more security tokens -
-
-
- {allowances.map((a) => { - const used = Number(a.used); - const total = Number(a.allowance); - const pct = total > 0 ? Math.min((used / total) * 100, 100) : 0; - return ( -
-
- {GATED_ACTION_LABELS[a.gatedAction] || a.gatedAction} - - {used}/{total} - ({a.timeWindow}) - -
-
-
= 90 ? 'bg-red-500 shadow-[0_0_6px_rgba(239,68,68,0.4)]' : pct >= 60 ? 'bg-yellow-500 shadow-[0_0_6px_rgba(234,179,8,0.4)]' : 'bg-accent shadow-[0_0_6px_rgba(234,179,8,0.3)]'}`} - style={{ width: `${pct}%` }} - /> -
-
- ); - })} -
-
- )} - {/* Advanced Section Toggle */}
-
- - {/* Register new app form */} -
-
- - Register New App -
-
- setRegisterAppId(e.target.value)} - placeholder="app-id (lowercase, hyphens)" - className="h-8 flex-1 px-2.5 text-xs glass-input rounded-lg font-mono focus:outline-none focus:border-accent" - /> - setRegisterAppMetadata(e.target.value)} - placeholder="Metadata (optional)" - className="h-8 flex-1 px-2.5 text-xs glass-input rounded-lg focus:outline-none focus:border-accent" - /> -
-
- - -
-
- - {/* Apps list */} - {myAppsLoading ? ( -
- - Loading apps... -
- ) : myApps.length === 0 ? ( -
- No registered apps -
- ) : ( -
- {myApps.map((app) => ( -
-
- {app.id} - v{app.version} -
-
- - Approval: {app.creation_approval_not_required - ? Not required - : Required - } - - {app.metadata && Meta: {app.metadata}} -
-
- Created {new Date(Number(app.created_at) * 1000).toLocaleString()} -
-
- ))} -
- )} -
- {/* Session Keys */}
diff --git a/sdk/ts/src/app/packing.ts b/sdk/ts/src/app/packing.ts index ae1020f26..12fe885f3 100644 --- a/sdk/ts/src/app/packing.ts +++ b/sdk/ts/src/app/packing.ts @@ -1,11 +1,5 @@ import { Address, encodeAbiParameters, keccak256, toHex } from 'viem'; -import { - AppDefinitionV1, - AppStateUpdateV1, - AppSessionKeyStateV1, - AppSessionVersionV1, -} from './types.js'; -import { AppV1 } from '../rpc/types.js'; +import { AppDefinitionV1, AppStateUpdateV1, AppSessionKeyStateV1 } from './types.js'; /** * PackCreateAppSessionRequestV1 packs the Definition and SessionData for signing using ABI encoding. @@ -124,89 +118,6 @@ export function generateAppSessionIDV1(definition: AppDefinitionV1): `0x${string return keccak256(packed); } -/** - * GenerateRebalanceBatchIDV1 creates a deterministic batch ID from session versions using ABI encoding. - * The batch ID is generated by hashing the list of (sessionID, version) pairs. - */ -export function generateRebalanceBatchIDV1( - sessionVersions: AppSessionVersionV1[] -): `0x${string}` { - // Define the session version tuple type components - const sessionVersionComponents = [ - { name: 'sessionID', type: 'bytes32' }, - { name: 'version', type: 'uint64' }, - ] as const; - - // Convert session versions to the format expected by ABI packing - const sessionVersionsArray = sessionVersions.map((sv) => ({ - sessionID: sv.sessionId as `0x${string}`, - version: sv.version, - })); - - // Pack the data using ABI encoding - const packed = encodeAbiParameters( - [ - { type: 'tuple[]', components: sessionVersionComponents }, // session versions array - ], - [sessionVersionsArray] - ); - - // Return the Keccak256 hash as hex string - return keccak256(packed); -} - -/** - * GenerateRebalanceTransactionIDV1 creates a deterministic transaction ID for a rebalance transaction using ABI encoding. - */ -export function generateRebalanceTransactionIDV1( - batchId: string, - sessionId: string, - asset: string -): `0x${string}` { - // Pack the data using ABI encoding - const packed = encodeAbiParameters( - [ - { type: 'bytes32' }, // batchID - { type: 'bytes32' }, // sessionID - { type: 'string' }, // asset - ], - [batchId as `0x${string}`, sessionId as `0x${string}`, asset] - ); - - // Return the Keccak256 hash as hex string - return keccak256(packed); -} - -/** - * PackAppV1 packs the AppV1 for signing using ABI encoding. - * Matches Go SDK's PackAppV1. - * - * @param app - The application definition to pack - * @returns Keccak256 hash of the ABI-encoded app data - */ -export function packAppV1(app: AppV1): `0x${string}` { - const metadataHash = keccak256(toHex(new TextEncoder().encode(app.metadata))); - - const packed = encodeAbiParameters( - [ - { type: 'string' }, // id - { type: 'address' }, // ownerWallet - { type: 'bytes32' }, // metadata (hashed) - { type: 'uint64' }, // version - { type: 'bool' }, // creationApprovalNotRequired - ], - [ - app.id, - app.owner_wallet as Address, - metadataHash, - BigInt(app.version), - app.creation_approval_not_required, - ] - ); - - return keccak256(packed); -} - /** * PackAppSessionKeyStateV1 packs the app session key state for signing using ABI encoding. * Matches Go SDK's PackAppSessionKeyStateV1. diff --git a/sdk/ts/src/app/types.ts b/sdk/ts/src/app/types.ts index a88019038..95d068443 100644 --- a/sdk/ts/src/app/types.ts +++ b/sdk/ts/src/app/types.ts @@ -13,7 +13,6 @@ export enum AppStateUpdateIntent { Deposit = 1, Withdraw = 2, Close = 3, - Rebalance = 4, } /** @@ -39,8 +38,6 @@ export function appStateUpdateIntentToString(intent: AppStateUpdateIntent): stri return 'withdraw'; case AppStateUpdateIntent.Close: return 'close'; - case AppStateUpdateIntent.Rebalance: - return 'rebalance'; default: return 'unknown'; } @@ -97,14 +94,6 @@ export interface AppDefinitionV1 { nonce: bigint; // uint64 } -/** - * AppSessionVersionV1 represents a session ID and version pair for rebalancing operations - */ -export interface AppSessionVersionV1 { - sessionId: string; - version: bigint; // uint64 -} - /** * AppAllocationV1 represents the allocation of assets to a participant in an app session */ diff --git a/sdk/ts/src/blockchain/evm/app_registry_abi.ts b/sdk/ts/src/blockchain/evm/app_registry_abi.ts deleted file mode 100644 index 9221c4de7..000000000 --- a/sdk/ts/src/blockchain/evm/app_registry_abi.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * NonSlashableAppRegistry contract ABI - * Provides lock/relock/unlock/withdraw functionality - */ - -export const AppRegistryAbi = [ - { - type: 'function', - name: 'asset', - inputs: [], - outputs: [{ name: '', type: 'address' }], - stateMutability: 'view', - }, - { - type: 'function', - name: 'UNLOCK_PERIOD', - inputs: [], - outputs: [{ name: '', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - name: 'balanceOf', - inputs: [{ name: 'user', type: 'address' }], - outputs: [{ name: '', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - name: 'lockStateOf', - inputs: [{ name: 'user', type: 'address' }], - outputs: [{ name: '', type: 'uint8' }], - stateMutability: 'view', - }, - { - type: 'function', - name: 'unlockTimestampOf', - inputs: [{ name: 'user', type: 'address' }], - outputs: [{ name: '', type: 'uint256' }], - stateMutability: 'view', - }, - { - type: 'function', - name: 'lock', - inputs: [ - { name: 'target', type: 'address' }, - { name: 'amount', type: 'uint256' }, - ], - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - name: 'relock', - inputs: [], - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - name: 'unlock', - inputs: [], - outputs: [], - stateMutability: 'nonpayable', - }, - { - type: 'function', - name: 'withdraw', - inputs: [{ name: 'destination', type: 'address' }], - outputs: [], - stateMutability: 'nonpayable', - }, -] as const; diff --git a/sdk/ts/src/blockchain/evm/index.ts b/sdk/ts/src/blockchain/evm/index.ts index 5e9427155..de5602a35 100644 --- a/sdk/ts/src/blockchain/evm/index.ts +++ b/sdk/ts/src/blockchain/evm/index.ts @@ -8,6 +8,4 @@ export * from './utils.js'; export * from './erc20.js'; 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/locking_client.ts b/sdk/ts/src/blockchain/evm/locking_client.ts deleted file mode 100644 index 2da6dc686..000000000 --- a/sdk/ts/src/blockchain/evm/locking_client.ts +++ /dev/null @@ -1,223 +0,0 @@ -/** - * Locking Contract Client - * Provides lock/relock/unlock/withdraw functionality for the NonSlashableAppRegistry contract - */ - -import { Address } from 'viem'; -import { Decimal } from 'decimal.js'; -import { decimalToBigInt } from '../../core/utils.js'; -import { EVMClient, WalletSigner } from './interface.js'; -import { AppRegistryAbi } from './app_registry_abi.js'; -import { Erc20Abi } from './erc20_abi.js'; - -/** - * LockingClient provides methods to interact with the Locking (NonSlashableAppRegistry) contract. - * Supports lock, relock, unlock, withdraw, and approve operations. - */ -export class LockingClient { - private contractAddress: Address; - private evmClient: EVMClient; - private walletSigner?: WalletSigner; - - private tokenAddress?: Address; - private tokenDecimals?: number; - - constructor( - contractAddress: Address, - evmClient: EVMClient, - walletSigner?: WalletSigner, - ) { - this.contractAddress = contractAddress; - this.evmClient = evmClient; - this.walletSigner = walletSigner; - } - - private requireWalletSigner(): WalletSigner { - if (!this.walletSigner) { - throw new Error('Write operations require a wallet signer. In Node.js, use a TransactionSigner that implements getAccount() (e.g., EthereumRawSigner)'); - } - return this.walletSigner; - } - - /** - * Lazily fetch and cache the token address and decimals from the contract. - */ - private async ensureTokenInfo(): Promise<{ address: Address; decimals: number }> { - if (this.tokenAddress !== undefined && this.tokenDecimals !== undefined) { - return { address: this.tokenAddress, decimals: this.tokenDecimals }; - } - - const tokenAddress = await this.evmClient.readContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'asset', - }) as Address; - - const decimals = await this.evmClient.readContract({ - address: tokenAddress, - abi: Erc20Abi, - functionName: 'decimals', - }) as number; - - this.tokenAddress = tokenAddress; - this.tokenDecimals = decimals; - return { address: tokenAddress, decimals }; - } - - /** - * Lock tokens into the Locking contract for the specified target address. - * The caller must have approved the contract to spend tokens beforehand. - * - * @param target - The address to lock tokens for - * @param amount - The amount of tokens to lock (human-readable decimals) - * @returns Transaction hash - */ - async lock(target: Address, amount: Decimal): Promise { - const walletSigner = this.requireWalletSigner(); - const { decimals } = await this.ensureTokenInfo(); - const amountBig = decimalToBigInt(amount, decimals); - - const { request } = await this.evmClient.simulateContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'lock', - args: [target, amountBig], - account: walletSigner.account!.address, - }); - - const hash = await walletSigner.writeContract(request as any); - await this.evmClient.waitForTransactionReceipt({ hash }); - return hash; - } - - /** - * Re-lock tokens that are in the unlocking state back to the locked state. - * - * @returns Transaction hash - */ - async relock(): Promise { - const walletSigner = this.requireWalletSigner(); - const { request } = await this.evmClient.simulateContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'relock', - account: walletSigner.account!.address, - }); - - const hash = await walletSigner.writeContract(request as any); - await this.evmClient.waitForTransactionReceipt({ hash }); - return hash; - } - - /** - * Initiate the unlock process for the caller's locked tokens. - * After the unlock period elapses, withdraw can be called. - * - * @returns Transaction hash - */ - async unlock(): Promise { - const walletSigner = this.requireWalletSigner(); - const { request } = await this.evmClient.simulateContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'unlock', - account: walletSigner.account!.address, - }); - - const hash = await walletSigner.writeContract(request as any); - await this.evmClient.waitForTransactionReceipt({ hash }); - return hash; - } - - /** - * Withdraw unlocked tokens to the specified destination address. - * Can only be called after the unlock period has elapsed. - * - * @param destination - The address to receive the withdrawn tokens - * @returns Transaction hash - */ - async withdraw(destination: Address): Promise { - const walletSigner = this.requireWalletSigner(); - const { request } = await this.evmClient.simulateContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'withdraw', - args: [destination], - account: walletSigner.account!.address, - }); - - const hash = await walletSigner.writeContract(request as any); - await this.evmClient.waitForTransactionReceipt({ hash }); - return hash; - } - - /** - * Approve the Locking contract to spend the specified amount of tokens. - * This must be called before lock. - * - * @param amount - The amount of tokens to approve (human-readable decimals) - * @returns Transaction hash - */ - async approveToken(amount: Decimal): Promise { - const walletSigner = this.requireWalletSigner(); - const { address: tokenAddress, decimals } = await this.ensureTokenInfo(); - const amountBig = decimalToBigInt(amount, decimals); - - const { request } = await this.evmClient.simulateContract({ - address: tokenAddress, - abi: Erc20Abi, - functionName: 'approve', - args: [this.contractAddress, amountBig], - account: walletSigner.account!.address, - }); - - const hash = await walletSigner.writeContract(request as any); - await this.evmClient.waitForTransactionReceipt({ hash }); - return hash; - } - - /** - * Get the locked balance of a user in the Locking contract. - * - * @param user - The address to check - * @returns The locked balance as a Decimal (adjusted for token decimals) - */ - async getBalance(user: Address): Promise { - const { decimals } = await this.ensureTokenInfo(); - - const balance = await this.evmClient.readContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'balanceOf', - args: [user], - }) as bigint; - - return new Decimal(balance.toString()).div(Decimal.pow(10, decimals)); - } - - /** - * Get the lock state of a user. - * Returns 0 for None, 1 for Locked, 2 for Unlocking. - * - * @param user - The address to check - * @returns Lock state (0=None, 1=Locked, 2=Unlocking) - */ - async getLockState(user: Address): Promise { - return await this.evmClient.readContract({ - address: this.contractAddress, - abi: AppRegistryAbi, - functionName: 'lockStateOf', - args: [user], - }) as number; - } - - /** - * Get the token decimals used by the Locking contract's asset. - * - * @returns Number of decimals - */ - async getTokenDecimals(): Promise { - const { decimals } = await this.ensureTokenInfo(); - return decimals; - } -} diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index fb1757449..63202c0ef 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -10,7 +10,7 @@ import { Decimal } from 'decimal.js'; import * as core from './core/index.js'; import * as app from './app/index.js'; import * as API from './rpc/api.js'; -import { StateV1, ChannelDefinitionV1, ChannelSessionKeyStateV1, AppV1, AppInfoV1 } from './rpc/types.js'; +import { StateV1, ChannelDefinitionV1, ChannelSessionKeyStateV1 } from './rpc/types.js'; import { RPCClient } from './rpc/client.js'; import { WebsocketDialer } from './rpc/dialer.js'; import { ClientAssetStore } from './asset_store.js'; @@ -29,7 +29,6 @@ import { transformSignedAppStateUpdateToRPC, transformAppSessionInfo, transformAppDefinitionFromRPC, - transformActionAllowance, } from './utils.js'; import { transformChannelSessionKeyState, @@ -46,6 +45,24 @@ import { EthereumMsgSigner, StateSigner, TransactionSigner } from './signers.js' */ export const DEFAULT_CHALLENGE_PERIOD = 86400; +/** Default poll interval for waitForCheckpoint (ms). */ +export const DEFAULT_CHECKPOINT_POLL_INTERVAL_MS = 3000; + +export interface WaitForCheckpointOptions { + /** Chain the checkpoint tx was submitted on. Used to apply the confirmation_delay_secs + * lower-bound wait before the first poll. If omitted, no lower-bound wait is applied. */ + chainId?: bigint; + /** Expected minimum ENFORCED balance the asset should reach (in display units). When set, + * the wait resolves once the polled `enforced` balance is >= this value. When omitted, the + * wait resolves on the first `enforced` balance change relative to the value at call time. + * NOTE: the gate credits on-chain events to `enforced`, not spendable `balance` — see below. */ + expectedBalance?: Decimal; + /** Max time to wait after the lower-bound delay, in ms. Default: 120_000. */ + timeoutMs?: number; + /** Poll interval in ms. Default: DEFAULT_CHECKPOINT_POLL_INTERVAL_MS (3000). */ + pollIntervalMs?: number; +} + /** * Strip the channel signer type prefix byte from a signature. * Session key registration requires a raw EIP-191 wallet signature, @@ -107,7 +124,6 @@ export class Client { private exitPromise: Promise; private exitResolve?: () => void; private blockchainClients: Map; - private blockchainLockingClients: Map; private homeBlockchains: Map; private stateSigner: StateSigner; private txSigner: TransactionSigner; @@ -127,7 +143,6 @@ export class Client { this.txSigner = txSigner; this.assetStore = assetStore; this.blockchainClients = new Map(); - this.blockchainLockingClients = new Map(); this.homeBlockchains = new Map(); this.stateAdvancer = new core.StateAdvancerV1(assetStore); @@ -963,92 +978,6 @@ export class Client { ); } - // ============================================================================ - // Locking On-Chain Methods - // ============================================================================ - - /** - * Lock tokens into the Locking contract on the specified blockchain. - * The tokens are locked for the specified target address. Before calling this method, - * you must approve the Locking contract to spend your tokens using approveSecurityToken. - * - * @param targetWalletAddress - The Ethereum address to lock tokens for - * @param blockchainId - The blockchain network ID - * @param amount - The amount of tokens to lock (in human-readable decimals, e.g., 100.5 USDC) - * @returns Transaction hash - */ - async escrowSecurityTokens(targetWalletAddress: string, blockchainId: bigint, amount: Decimal): Promise { - await this.initializeLockingClient(blockchainId); - return this.blockchainLockingClients.get(blockchainId)!.lock( - targetWalletAddress as Address, - amount, - ); - } - - /** - * Initiate the unlock process for locked tokens in the Locking contract. - * After the unlock period elapses, withdrawSecurityTokens can be called to retrieve the tokens. - * - * @param blockchainId - The blockchain network ID - * @returns Transaction hash - */ - async initiateSecurityTokensWithdrawal(blockchainId: bigint): Promise { - await this.initializeLockingClient(blockchainId); - return this.blockchainLockingClients.get(blockchainId)!.unlock(); - } - - /** - * Re-lock tokens that are currently in the unlocking state, - * cancelling the pending unlock and returning them to the locked state. - * - * @param blockchainId - The blockchain network ID - * @returns Transaction hash - */ - async cancelSecurityTokensWithdrawal(blockchainId: bigint): Promise { - await this.initializeLockingClient(blockchainId); - return this.blockchainLockingClients.get(blockchainId)!.relock(); - } - - /** - * Withdraw unlocked tokens from the Locking contract to the specified destination. - * Can only be called after the unlock period has fully elapsed. - * - * @param blockchainId - The blockchain network ID - * @param destinationWalletAddress - The Ethereum address to receive the withdrawn tokens - * @returns Transaction hash - */ - async withdrawSecurityTokens(blockchainId: bigint, destinationWalletAddress: string): Promise { - await this.initializeLockingClient(blockchainId); - return this.blockchainLockingClients.get(blockchainId)!.withdraw( - destinationWalletAddress as Address, - ); - } - - /** - * Approve the Locking contract to spend tokens on behalf of the caller. - * This must be called before escrowSecurityTokens. - * - * @param chainId - The blockchain network ID - * @param amount - The amount of tokens to approve - * @returns Transaction hash - */ - async approveSecurityToken(chainId: bigint, amount: Decimal): Promise { - await this.initializeLockingClient(chainId); - return this.blockchainLockingClients.get(chainId)!.approveToken(amount); - } - - /** - * Get the locked balance of a user in the Locking contract. - * - * @param chainId - The blockchain network ID - * @param wallet - The Ethereum address to check - * @returns The locked balance as a Decimal (adjusted for token decimals) - */ - async getLockedBalance(chainId: bigint, wallet: string): Promise { - await this.initializeLockingClient(chainId); - return this.blockchainLockingClients.get(chainId)!.getBalance(wallet as Address); - } - // ============================================================================ // Node Information Methods // ============================================================================ @@ -1101,6 +1030,26 @@ export class Client { return config.blockchains; } + /** + * GetConfirmationDelay returns the confirmation-gate delay, in seconds, that the node + * applies before crediting an on-chain event for the given chain. A return value of 0 + * means the gate is disabled and events are credited immediately. + * + * This fetches the node config on each call (config is not cached on the client). + * + * @param chainId - The blockchain network ID (e.g., 1n for Ethereum mainnet) + * @returns Delay in seconds before off-chain credit lands; 0 if the gate is disabled + * @throws If the chain is not present in the node config + */ + async getConfirmationDelay(chainId: bigint): Promise { + const blockchains = await this.getBlockchains(); + const chain = blockchains.find((b) => b.id === chainId); + if (!chain) { + throw new Error(`blockchain ${chainId} not found in node config`); + } + return chain.confirmationDelaySecs; + } + /** * GetAssets retrieves the list of supported assets, optionally filtered by blockchain. * @@ -1151,6 +1100,81 @@ export class Client { return transformBalances(resp.balances); } + /** + * WaitForCheckpoint waits until the off-chain credit for `asset` lands after an on-chain + * checkpoint transaction. Because the node applies a per-chain confirmation gate + * (confirmation_delay_secs) before crediting an event, the off-chain balance does not + * update the instant the tx receipt is mined — it updates up to confirmation_delay_secs later. + * + * The method: + * 1. If opts.chainId is given, sleeps for that chain's confirmation_delay_secs (the + * lower bound — the credit cannot arrive before the gate elapses) before polling. + * 2. Polls getBalances(user) every pollIntervalMs until either the target condition is + * met or timeoutMs elapses. + * + * Target condition: + * - opts.expectedBalance set → balance for `asset` is >= expectedBalance. + * - otherwise → balance for `asset` differs from the value observed at call time + * ("balance changed"). + * + * @param asset - The asset symbol (e.g., "usdc") + * @param txHash - The checkpoint transaction hash (informational; included in the timeout error) + * @param opts - See WaitForCheckpointOptions + * @returns The BalanceEntry for `asset` once the condition is met + * @throws If the timeout elapses before the credit lands, or on RPC failure + */ + async waitForCheckpoint( + asset: string, + txHash: string, + opts?: WaitForCheckpointOptions + ): Promise { + const wallet = this.getUserAddress(); + const pollIntervalMs = opts?.pollIntervalMs ?? DEFAULT_CHECKPOINT_POLL_INTERVAL_MS; + const timeoutMs = opts?.timeoutMs ?? 120_000; + + // Snapshot the starting ENFORCED balance for "changed" mode. The confirmation gate + // credits on-chain events to the `enforced` balance (RefreshUserEnforcedBalance updates + // the `enforced` column), so that is the field that lands after the gate elapses — NOT + // spendable `balance`, which only moves on the user's own signed home_deposit transition. + const findEnforced = (entries: core.BalanceEntry[]): Decimal => + entries.find((e) => e.asset === asset)?.enforced ?? new Decimal(0); + const startEnforced = findEnforced(await this.getBalances(wallet)); + + // Lower-bound wait: the credit cannot land before the gate elapses. + if (opts?.chainId !== undefined) { + const delaySecs = await this.getConfirmationDelay(opts.chainId); + if (delaySecs > 0) { + await new Promise((r) => setTimeout(r, delaySecs * 1000)); + } + } + + const deadline = Date.now() + timeoutMs; + // Loop: poll, check condition, sleep. First poll happens immediately after the + // lower-bound wait. + // eslint-disable-next-line no-constant-condition + while (true) { + const balances = await this.getBalances(wallet); + const entry = balances.find((e) => e.asset === asset); + const current = entry?.enforced ?? new Decimal(0); + + const satisfied = + opts?.expectedBalance !== undefined + ? current.gte(opts.expectedBalance) + : !current.eq(startEnforced); + + if (satisfied && entry) { + return entry; + } + + if (Date.now() >= deadline) { + throw new Error( + `waitForCheckpoint timed out after ${timeoutMs}ms waiting for ${asset} credit (tx ${txHash})` + ); + } + await new Promise((r) => setTimeout(r, pollIntervalMs)); + } + } + /** * GetTransactions retrieves the transaction history for a user's wallet. * @@ -1196,26 +1220,6 @@ export class Client { }; } - /** - * GetActionAllowances retrieves the action allowances for a user based on their staking level. - * - * @param wallet - The user's wallet address - * @returns Array of action allowances for each gated action - * - * @example - * ```typescript - * const allowances = await client.getActionAllowances('0x1234...'); - * for (const a of allowances) { - * console.log(`${a.gatedAction}: ${a.used}/${a.allowance} (${a.timeWindow})`); - * } - * ``` - */ - async getActionAllowances(wallet: Address): Promise { - const req: API.UserV1GetActionAllowancesRequest = { wallet }; - const resp = await this.rpcClient.userV1GetActionAllowances(req); - return resp.allowances.map(transformActionAllowance); - } - // ============================================================================ // Channel Query Methods // ============================================================================ @@ -1573,126 +1577,6 @@ export class Client { await this.rpcClient.appSessionsV1SubmitAppState(req); } - /** - * RebalanceAppSessions rebalances multiple application sessions atomically. - * - * This method performs atomic rebalancing across multiple app sessions, ensuring - * that funds are redistributed consistently without the risk of partial updates. - * - * @param signedUpdates - Array of signed app state updates to apply atomically - * @returns BatchID for tracking the rebalancing operation - * - * @example - * ```typescript - * const updates: app.SignedAppStateUpdateV1[] = [ - * { - * appStateUpdate: { appSessionId: 'session1', intent: app.AppStateUpdateIntent.Rebalance, ... }, - * quorumSigs: ['sig1', 'sig2'], - * }, - * { - * appStateUpdate: { appSessionId: 'session2', intent: app.AppStateUpdateIntent.Rebalance, ... }, - * quorumSigs: ['sig3', 'sig4'], - * }, - * ]; - * const batchId = await client.rebalanceAppSessions(updates); - * console.log('Rebalance batch ID:', batchId); - * ``` - */ - async rebalanceAppSessions( - signedUpdates: app.SignedAppStateUpdateV1[] - ): Promise { - // Transform SDK types to RPC types - const rpcUpdates = signedUpdates.map(transformSignedAppStateUpdateToRPC); - - const req: API.AppSessionsV1RebalanceAppSessionsRequest = { - signed_updates: rpcUpdates as any, // RPC type - }; - - const resp = await this.rpcClient.appSessionsV1RebalanceAppSessions(req); - return resp.batch_id; - } - - // ============================================================================ - // App Registry Methods - // ============================================================================ - - /** - * GetApps retrieves registered applications with optional filtering. - * - * @param options - Optional filters (appId, ownerWallet, pagination) - * @returns Array of registered apps and pagination metadata - * - * @example - * ```typescript - * const { apps, metadata } = await client.getApps({ ownerWallet: '0x1234...' }); - * for (const app of apps) { - * console.log(`${app.id}: owned by ${app.owner_wallet}`); - * } - * ``` - */ - async getApps(options?: { - appId?: string; - ownerWallet?: string; - page?: number; - pageSize?: number; - }): Promise<{ apps: AppInfoV1[]; metadata: core.PaginationMetadata }> { - const req: API.AppsV1GetAppsRequest = { - app_id: options?.appId, - owner_wallet: options?.ownerWallet, - pagination: options?.page && options?.pageSize ? { - offset: (options.page - 1) * options.pageSize, - limit: options.pageSize, - } : undefined, - }; - const resp = await this.rpcClient.appsV1GetApps(req); - return { - apps: resp.apps, - metadata: transformPaginationMetadata(resp.metadata), - }; - } - - /** - * RegisterApp registers a new application in the app registry. - * Currently only version 1 (creation) is supported. - * - * The method builds the app definition from the provided parameters, - * using the client's signer address as the owner wallet and version 1. - * It then packs and signs the definition automatically. - * - * Session key signers are not allowed to perform this action; the main - * wallet signer must be used. - * - * @param appID - The application identifier - * @param metadata - The application metadata - * @param creationApprovalNotRequired - Whether sessions can be created without owner approval - * - * @example - * ```typescript - * await client.registerApp('my-app', '{"name": "My App"}', false); - * ``` - */ - async registerApp(appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise { - const appDef: AppV1 = { - id: appID, - owner_wallet: this.txSigner.getAddress(), - metadata, - version: '1', - creation_approval_not_required: creationApprovalNotRequired, - }; - - const packed = app.packAppV1(appDef); - if (!this.txSigner.signPersonalMessage) { - throw new Error('TransactionSigner must implement signPersonalMessage for app registration'); - } - const ownerSig = await this.txSigner.signPersonalMessage(packed); - - const req: API.AppsV1SubmitAppVersionRequest = { - app: appDef, - owner_sig: ownerSig, - }; - await this.rpcClient.appsV1SubmitAppVersion(req); - } - // ============================================================================ // Channel Session Key Methods // ============================================================================ @@ -1762,10 +1646,11 @@ export class Client { * - revocation: bump version with expires_at <= now to retire the key; the slot is freed * for the per-user cap and the auth path stops accepting state signed by it * - * 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, rotated, or revoked). Submits without a valid session_key_sig - * are rejected. + * Activation, rotation, and re-activation (expires_at > now) require both the wallet's + * user_sig (proving the user authorized the delegation) and the session-key holder's + * session_key_sig (proving possession). Revocation (expires_at <= now) requires only + * user_sig — use revokeChannelSessionKey for the wallet-only revocation of a lost, + * unavailable, or compromised key. * * @param state - The channel session key state containing delegation information */ @@ -1776,6 +1661,34 @@ export class Client { await this.rpcClient.channelsV1SubmitSessionKeyState(req); } + /** + * Revoke a channel session key using only the wallet's signature. Use it when the + * session-key holder cannot or will not co-sign — a lost, unavailable, or compromised + * delegate. The supplied state must carry the next monotonic version (latest + 1) and an + * expires_at at or before now; this method signs it with the wallet (user_sig) and submits + * with an empty session_key_sig. The server accepts user-only signatures only on the + * revocation path (expires_at <= now). For registration, rotation, or extension use + * submitChannelSessionKeyState with both signatures. + * + * @param state - The channel session key state to revoke (version = latest + 1, expires_at <= now). Signature fields are supplied by this method, so callers omit them. + */ + async revokeChannelSessionKey( + state: Omit + ): Promise { + const nowSec = BigInt(Math.floor(Date.now() / 1000)); + if (BigInt(state.expires_at) > nowSec) { + throw new Error( + `revocation requires expires_at at or before now, got ${state.expires_at}` + ); + } + const fullState: ChannelSessionKeyStateV1 = { ...state, user_sig: '', session_key_sig: '' }; + const userSig = await this.signChannelSessionKeyState(fullState); + await this.submitChannelSessionKeyState({ + ...fullState, + user_sig: userSig, + }); + } + /** * Retrieve the latest channel session key states for a user. Defaults to * active-only (server filters expired states); pass `includeInactive: true` @@ -1856,10 +1769,11 @@ export class Client { * - revocation: bump version with expires_at <= now to retire the key; the slot is freed * for the per-user cap and the auth path stops accepting state signed by it * - * 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, rotated, or revoked). Submits without a valid session_key_sig - * are rejected. + * Activation, rotation, and re-activation (expires_at > now) require both the wallet's + * user_sig (proving the user authorized the delegation) and the session-key holder's + * session_key_sig (proving possession). Revocation (expires_at <= now) requires only + * user_sig — use revokeSessionKey for the wallet-only revocation of a lost, unavailable, + * or compromised key. * * @param state - The session key state containing delegation information */ @@ -1870,6 +1784,34 @@ export class Client { await this.rpcClient.appSessionsV1SubmitSessionKeyState(req); } + /** + * Revoke an app session key using only the wallet's signature. Use it when the + * session-key holder cannot or will not co-sign — a lost, unavailable, or compromised + * delegate. The supplied state must carry the next monotonic version (latest + 1) and an + * expires_at at or before now; this method signs it with the wallet (user_sig) and submits + * with an empty session_key_sig. The server accepts user-only signatures only on the + * revocation path (expires_at <= now). For registration, rotation, or extension use + * submitSessionKeyState with both signatures. + * + * @param state - The app session key state to revoke (version = latest + 1, expires_at <= now). Signature fields are supplied by this method, so callers omit them. + */ + async revokeSessionKey( + state: Omit + ): Promise { + const nowSec = BigInt(Math.floor(Date.now() / 1000)); + if (BigInt(state.expires_at) > nowSec) { + throw new Error( + `revocation requires expires_at at or before now, got ${state.expires_at}` + ); + } + const fullState: app.AppSessionKeyStateV1 = { ...state, user_sig: '', session_key_sig: '' }; + const userSig = await this.signSessionKeyState(fullState); + await this.submitSessionKeyState({ + ...fullState, + user_sig: userSig, + }); + } + /** * Retrieve the latest app session key states for a user. Defaults to * active-only (server filters expired states); pass `includeInactive: true` @@ -2055,32 +1997,6 @@ export class Client { this.blockchainClients.set(chainId, blockchainClient); } - /** - * Initialize a Locking contract client for a specific chain. - * This is called lazily when a locking operation is needed. - */ - private async initializeLockingClient(chainId: bigint): Promise { - if (this.blockchainLockingClients.has(chainId)) { - return; - } - - const { rpcUrl, blockchainInfo } = await this.getBlockchainRPCInfo(chainId); - - if (!blockchainInfo.lockingContractAddress) { - throw new Error(`locking contract address not configured for blockchain ${chainId}`); - } - - const { publicClient, walletClient } = this.createEVMClients(chainId, rpcUrl); - - const lockingClient = new blockchain.evm.LockingClient( - blockchainInfo.lockingContractAddress, - publicClient, - walletClient || undefined, - ); - - this.blockchainLockingClients.set(chainId, lockingClient); - } - /** * Build a hex bitmap string from an array of signer type numbers. * Each signer type sets a bit at its corresponding position in a 256-bit value. diff --git a/sdk/ts/src/core/state.ts b/sdk/ts/src/core/state.ts index ec1837ed1..dcf17ba5e 100644 --- a/sdk/ts/src/core/state.ts +++ b/sdk/ts/src/core/state.ts @@ -59,14 +59,18 @@ export function nextState(state: State): State { const voidTransition: Transition = { type: TransitionType.Void, txId: '', amount: new Decimal(0) }; if (isFinal(state)) { - // After finalization, increment epoch and reset version + // After finalization, open a fresh epoch starting at version 1. Version 0 is + // reserved as a sentinel for "channel created but no on-chain state has + // materialised yet" (NewChannel/Void). Starting at 1 prevents the + // EnsureNoOngoingStateTransitions gate from accidentally treating a Void + // channel's first signed HomeDeposit as already settled on-chain (MF3-C01). newState = { id: '', transition: voidTransition, asset: state.asset, userWallet: state.userWallet, epoch: state.epoch + 1n, - version: 0n, + version: 1n, homeChannelId: undefined, escrowChannelId: undefined, homeLedger: { diff --git a/sdk/ts/src/core/types.ts b/sdk/ts/src/core/types.ts index fe2c300dc..c7d18ad8d 100644 --- a/sdk/ts/src/core/types.ts +++ b/sdk/ts/src/core/types.ts @@ -53,7 +53,6 @@ export enum TransactionType { Transfer = 30, Commit = 40, Release = 41, - Rebalance = 42, Migrate = 100, EscrowLock = 110, MutualLock = 120, @@ -166,8 +165,8 @@ export interface Blockchain { name: string; id: bigint; // uint64 channelHubAddress: Address; - lockingContractAddress?: Address; blockStep: bigint; // uint64 + confirmationDelaySecs: number; // seconds; 0 means gate is disabled } export interface Token { @@ -212,13 +211,6 @@ export interface BalanceEntry { enforced: Decimal; } -export interface ActionAllowance { - gatedAction: string; - timeWindow: string; - allowance: bigint; - used: bigint; -} - // ============================================================================ // Pagination Types // ============================================================================ diff --git a/sdk/ts/src/index.ts b/sdk/ts/src/index.ts index 927fbdf4d..d046b411a 100644 --- a/sdk/ts/src/index.ts +++ b/sdk/ts/src/index.ts @@ -3,7 +3,7 @@ // ============================================================================ // Export SDK Client (main entry point) -export { Client, DEFAULT_CHALLENGE_PERIOD, type StateSigner, type TransactionSigner } from './client.js'; +export { Client, DEFAULT_CHALLENGE_PERIOD, DEFAULT_CHECKPOINT_POLL_INTERVAL_MS, type WaitForCheckpointOptions, type StateSigner, type TransactionSigner } from './client.js'; // Export signers export { diff --git a/sdk/ts/src/rpc/api.ts b/sdk/ts/src/rpc/api.ts index 76c2af732..b86e4290a 100644 --- a/sdk/ts/src/rpc/api.ts +++ b/sdk/ts/src/rpc/api.ts @@ -16,9 +16,6 @@ import { PaginationMetadataV1, AssetV1, BlockchainInfoV1, - AppV1, - AppInfoV1, - ActionAllowanceV1, } from './types.js'; import { AppDefinitionV1, @@ -186,16 +183,6 @@ export interface AppSessionsV1SubmitAppStateRequest { export interface AppSessionsV1SubmitAppStateResponse {} -export interface AppSessionsV1RebalanceAppSessionsRequest { - /** List of signed application session state updates */ - signed_updates: SignedAppStateUpdateV1[]; -} - -export interface AppSessionsV1RebalanceAppSessionsResponse { - /** Unique identifier for this rebalancing operation */ - batch_id: string; -} - export interface AppSessionsV1GetAppDefinitionRequest { /** Application session ID */ app_session_id: string; @@ -279,35 +266,6 @@ export interface AppSessionsV1GetLastKeyStatesResponse { metadata: PaginationMetadataV1; } -// ============================================================================ -// Apps Group - V1 API -// ============================================================================ - -export interface AppsV1GetAppsRequest { - /** Application ID filter */ - app_id?: string; - /** Owner wallet address filter */ - owner_wallet?: string; - /** Pagination parameters */ - pagination?: PaginationParamsV1; -} - -export interface AppsV1GetAppsResponse { - /** List of registered applications */ - apps: AppInfoV1[]; - /** Pagination information */ - metadata: PaginationMetadataV1; -} - -export interface AppsV1SubmitAppVersionRequest { - /** Application definition */ - app: AppV1; - /** Owner's signature over the packed app data */ - owner_sig: string; -} - -export interface AppsV1SubmitAppVersionResponse {} - // ============================================================================ // User Group - V1 API // ============================================================================ @@ -344,16 +302,6 @@ export interface UserV1GetTransactionsResponse { metadata: PaginationMetadataV1; } -export interface UserV1GetActionAllowancesRequest { - /** User's wallet address */ - wallet: Address; -} - -export interface UserV1GetActionAllowancesResponse { - /** List of action allowances */ - allowances: ActionAllowanceV1[]; -} - // ============================================================================ // Node Group - V1 API // ============================================================================ diff --git a/sdk/ts/src/rpc/client.ts b/sdk/ts/src/rpc/client.ts index 9badbb54a..7a56ba641 100644 --- a/sdk/ts/src/rpc/client.ts +++ b/sdk/ts/src/rpc/client.ts @@ -138,13 +138,6 @@ export class RPCClient { return this.call(Methods.AppSessionsV1SubmitAppStateMethod, req, signal); } - async appSessionsV1RebalanceAppSessions( - req: API.AppSessionsV1RebalanceAppSessionsRequest, - signal?: AbortSignal - ): Promise { - return this.call(Methods.AppSessionsV1RebalanceAppSessionsMethod, req, signal); - } - async appSessionsV1GetAppDefinition( req: API.AppSessionsV1GetAppDefinitionRequest, signal?: AbortSignal @@ -184,24 +177,6 @@ export class RPCClient { return this.call(Methods.AppSessionsV1GetLastKeyStatesMethod, req, signal); } - // ============================================================================ - // Apps Group - V1 API Methods - // ============================================================================ - - async appsV1GetApps( - req: API.AppsV1GetAppsRequest, - signal?: AbortSignal - ): Promise { - return this.call(Methods.AppsV1GetAppsMethod, req, signal); - } - - async appsV1SubmitAppVersion( - req: API.AppsV1SubmitAppVersionRequest, - signal?: AbortSignal - ): Promise { - return this.call(Methods.AppsV1SubmitAppVersionMethod, req, signal); - } - // ============================================================================ // User Group - V1 API Methods // ============================================================================ @@ -220,13 +195,6 @@ export class RPCClient { return this.call(Methods.UserV1GetTransactionsMethod, req, signal); } - async userV1GetActionAllowances( - req: API.UserV1GetActionAllowancesRequest, - signal?: AbortSignal - ): Promise { - return this.call(Methods.UserV1GetActionAllowancesMethod, req, signal); - } - // ============================================================================ // Node Group - V1 API Methods // ============================================================================ diff --git a/sdk/ts/src/rpc/methods.ts b/sdk/ts/src/rpc/methods.ts index 53f5553df..42b6769be 100644 --- a/sdk/ts/src/rpc/methods.ts +++ b/sdk/ts/src/rpc/methods.ts @@ -29,7 +29,6 @@ export const ChannelsV1GetLastKeyStatesMethod: Method = 'channels.v1.get_last_ke export const AppSessionsV1Group: Group = 'app_sessions.v1'; export const AppSessionsV1SubmitDepositStateMethod: Method = 'app_sessions.v1.submit_deposit_state'; export const AppSessionsV1SubmitAppStateMethod: Method = 'app_sessions.v1.submit_app_state'; -export const AppSessionsV1RebalanceAppSessionsMethod: Method = 'app_sessions.v1.rebalance_app_sessions'; export const AppSessionsV1GetAppDefinitionMethod: Method = 'app_sessions.v1.get_app_definition'; export const AppSessionsV1GetAppSessionsMethod: Method = 'app_sessions.v1.get_app_sessions'; export const AppSessionsV1CreateAppSessionMethod: Method = 'app_sessions.v1.create_app_session'; @@ -38,16 +37,10 @@ export const AppSessionsV1CreateAppSessionMethod: Method = 'app_sessions.v1.crea export const AppSessionsV1SubmitSessionKeyStateMethod: Method = 'app_sessions.v1.submit_session_key_state'; export const AppSessionsV1GetLastKeyStatesMethod: Method = 'app_sessions.v1.get_last_key_states'; -// Apps Group - V1 Methods -export const AppsV1Group: Group = 'apps.v1'; -export const AppsV1GetAppsMethod: Method = 'apps.v1.get_apps'; -export const AppsV1SubmitAppVersionMethod: Method = 'apps.v1.submit_app_version'; - // User Group - V1 Methods export const UserV1Group: Group = 'user.v1'; export const UserV1GetBalancesMethod: Method = 'user.v1.get_balances'; export const UserV1GetTransactionsMethod: Method = 'user.v1.get_transactions'; -export const UserV1GetActionAllowancesMethod: Method = 'user.v1.get_action_allowances'; // Node Group - V1 Methods export const NodeV1Group: Group = 'node.v1'; diff --git a/sdk/ts/src/rpc/types.ts b/sdk/ts/src/rpc/types.ts index 549be8f17..8db60945d 100644 --- a/sdk/ts/src/rpc/types.ts +++ b/sdk/ts/src/rpc/types.ts @@ -149,36 +149,6 @@ export interface ChannelSessionKeyStateV1 { session_key_sig: string; } -// ============================================================================ -// App Registry Types -// ============================================================================ - -/** - * AppV1 represents a registered application definition (without timestamps) - */ -export interface AppV1 { - /** Application identifier */ - id: string; - /** Owner's wallet address */ - owner_wallet: string; - /** Application metadata */ - metadata: string; - /** Current version */ - version: string; - /** Whether sessions can be created without owner approval */ - creation_approval_not_required: boolean; -} - -/** - * AppInfoV1 represents full application info including timestamps - */ -export interface AppInfoV1 extends AppV1 { - /** Creation timestamp (unix seconds) */ - created_at: string; - /** Last update timestamp (unix seconds) */ - updated_at: string; -} - // ============================================================================ // Asset and Blockchain Types // ============================================================================ @@ -225,8 +195,8 @@ export interface BlockchainInfoV1 { blockchain_id: string; // uint64 as string /** Channel hub contract address on this network */ channel_hub_address: Address; - /** Locking contract address on this network */ - locking_contract_address?: Address; + /** Seconds the node waits before crediting a deposit event; 0 means gate is disabled */ + confirmation_delay_secs?: number; } // ============================================================================ @@ -283,24 +253,6 @@ export interface PaginationParamsV1 { limit?: number; // uint32 } -// ============================================================================ -// Action Allowance Types -// ============================================================================ - -/** - * ActionAllowanceV1 represents the allowance information for a specific gated action - */ -export interface ActionAllowanceV1 { - /** The specific action being gated (transfer, app_session_deposit, app_session_operation, app_session_withdrawal) */ - gated_action: string; - /** Time window for which the allowance is valid (e.g. "24h0m0s") */ - time_window: string; - /** Total allowance for the action within the time window */ - allowance: string; - /** Amount already used within the time window */ - used: string; -} - /** * PaginationMetadataV1 represents pagination information */ diff --git a/sdk/ts/src/utils.ts b/sdk/ts/src/utils.ts index 65fc7c7b1..83df72f90 100644 --- a/sdk/ts/src/utils.ts +++ b/sdk/ts/src/utils.ts @@ -4,7 +4,7 @@ import * as core from './core/types.js'; import * as API from './rpc/api.js'; -import { AssetV1, BalanceEntryV1, ChannelV1, LedgerV1, TransitionV1, StateV1, TransactionV1, PaginationMetadataV1, ActionAllowanceV1 } from './rpc/types.js'; +import { AssetV1, BalanceEntryV1, ChannelV1, LedgerV1, TransitionV1, StateV1, TransactionV1, PaginationMetadataV1 } from './rpc/types.js'; import { Decimal } from 'decimal.js'; import { Address } from 'viem'; @@ -41,8 +41,8 @@ export function transformNodeConfig(resp: API.NodeV1GetConfigResponse): core.Nod name: info.name, id: BigInt(info.blockchain_id), channelHubAddress: info.channel_hub_address as Address, - lockingContractAddress: info.locking_contract_address as Address | undefined, blockStep: 0n, // Not provided in RPC response + confirmationDelaySecs: info.confirmation_delay_secs ?? 0, })); return { @@ -281,22 +281,6 @@ export function transformPaginationMetadata( }; } -// ============================================================================ -// Action Allowance Transformations -// ============================================================================ - -/** - * Transform RPC ActionAllowanceV1 to core ActionAllowance - */ -export function transformActionAllowance(a: ActionAllowanceV1): core.ActionAllowance { - return { - gatedAction: a.gated_action, - timeWindow: a.time_window, - allowance: BigInt(a.allowance), - used: BigInt(a.used), - }; -} - // ============================================================================ // App Session Transformations // ============================================================================ 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 7692360b1..b67349dce 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 @@ -2,28 +2,6 @@ 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", @@ -52,20 +30,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "(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", @@ -343,27 +307,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "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", @@ -443,15 +386,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "signatures": [], }, - { - "kind": "interface", - "name": "AppSessionVersionV1", - "properties": [ - "sessionId: string", - "version: bigint", - ], - "signatures": [], - }, { "constructors": [ "(inner: StateSigner): AppSessionWalletSignerV1", @@ -471,7 +405,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "Deposit = 1", "Withdraw = 2", "Close = 3", - "Rebalance = 4", ], "name": "AppStateUpdateIntent", }, @@ -494,67 +427,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "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", @@ -634,8 +506,8 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "properties": [ "blockStep: bigint", "channelHubAddress: Address", + "confirmationDelaySecs: number", "id: bigint", - "lockingContractAddress: Address", "name: string", ], "signatures": [], @@ -677,7 +549,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "properties": [ "blockchain_id: string", "channel_hub_address: Address", - "locking_contract_address: Address", + "confirmation_delay_secs: number", "name: string", ], "signatures": [], @@ -1039,9 +911,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "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", @@ -1049,29 +919,25 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "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", + "getConfirmationDelay: (chainId: bigint): Promise", "getEscrowChannel: (escrowChannelId: string): Promise", "getHomeChannel: (wallet: Address, asset: 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", "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", + "revokeChannelSessionKey: (state: Omit): Promise", + "revokeSessionKey: (state: Omit): Promise", "setHomeBlockchain: (asset: string, blockchainId: bigint): Promise", "signAppSessionKeyOwnership: (state: app.AppSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", "signChannelSessionKeyOwnership: (state: ChannelSessionKeyStateV1, sessionKeySigner: EthereumMsgSigner): Promise", @@ -1084,10 +950,10 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "submitSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", "transfer: (recipientWallet: string, asset: string, amount: Decimal): Promise", "validateAndSignState: (currentState: core.State, proposedState: core.State): Promise", + "waitForCheckpoint: (asset: string, txHash: string, opts?: WaitForCheckpointOptions): 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", ], "staticProperties": [ "create: (wsURL: string, stateSigner: StateSigner, txSigner: TransactionSigner, ...opts: Option[]): Promise", @@ -1141,6 +1007,11 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "name": "DEFAULT_CHALLENGE_PERIOD", "type": "86400", }, + { + "kind": "const", + "name": "DEFAULT_CHECKPOINT_POLL_INTERVAL_MS", + "type": "3000", + }, { "kind": "const", "name": "DefaultConfig", @@ -1346,20 +1217,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "(): 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", @@ -1811,13 +1668,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "(stateUpdate: AppStateUpdateV1): \`0x\${string}\`", ], }, - { - "kind": "function", - "name": "packAppV1", - "signatures": [ - "(app: AppV1): \`0x\${string}\`", - ], - }, { "kind": "function", "name": "packChallengeState", @@ -1910,12 +1760,9 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "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", @@ -1931,7 +1778,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "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", ], @@ -2144,7 +1990,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "Transfer = 30", "Commit = 40", "Release = 41", - "Rebalance = 42", "Migrate = 100", "EscrowLock = 110", "MutualLock = 120", @@ -2168,13 +2013,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "signatures": [], }, - { - "kind": "function", - "name": "transformActionAllowance", - "signatures": [ - "(a: ActionAllowanceV1): core.ActionAllowance", - ], - }, { "kind": "function", "name": "transformAppDefinitionFromRPC", @@ -2357,27 +2195,6 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "(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", @@ -2456,6 +2273,17 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "signatures": [], }, + { + "kind": "interface", + "name": "WaitForCheckpointOptions", + "properties": [ + "chainId: bigint", + "expectedBalance: Decimal", + "pollIntervalMs: number", + "timeoutMs: number", + ], + "signatures": [], + }, { "constructors": [ "(config?: WebsocketDialerConfig): WebsocketDialer", @@ -2522,14 +2350,10 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "AppSessionsV1GetAppSessionsMethod", "AppSessionsV1GetLastKeyStatesMethod", "AppSessionsV1Group", - "AppSessionsV1RebalanceAppSessionsMethod", "AppSessionsV1SubmitAppStateMethod", "AppSessionsV1SubmitDepositStateMethod", "AppSessionsV1SubmitSessionKeyStateMethod", "AppStateUpdateIntent", - "AppsV1GetAppsMethod", - "AppsV1Group", - "AppsV1SubmitAppVersionMethod", "CHANNEL_HUB_VERSION", "ChannelDefaultSigner", "ChannelParticipant", @@ -2549,6 +2373,7 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "Client", "ClientAssetStore", "DEFAULT_CHALLENGE_PERIOD", + "DEFAULT_CHECKPOINT_POLL_INTERVAL_MS", "DefaultConfig", "DefaultWebsocketDialerConfig", "ERROR_PARAM_KEY", @@ -2587,7 +2412,6 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "StatePackerV1", "TransactionType", "TransitionType", - "UserV1GetActionAllowancesMethod", "UserV1GetBalancesMethod", "UserV1GetTransactionsMethod", "UserV1Group", @@ -2617,8 +2441,6 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "generateAppSessionIDV1", "generateChannelMetadata", "generateNonce", - "generateRebalanceBatchIDV1", - "generateRebalanceTransactionIDV1", "getChannelSessionKeyAuthMetadataHashV1", "getEscrowChannelId", "getHomeChannelId", @@ -2649,13 +2471,11 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "nextState", "packAppSessionKeyStateV1", "packAppStateUpdateV1", - "packAppV1", "packChallengeState", "packChannelKeyStateV1", "packCreateAppSessionRequestV1", "packState", "payloadError", - "transformActionAllowance", "transformAppDefinitionFromRPC", "transformAppDefinitionToRPC", "transformAppSessionInfo", diff --git a/sdk/ts/test/unit/abi-drift.test.ts b/sdk/ts/test/unit/abi-drift.test.ts index eb0b18574..990887da5 100644 --- a/sdk/ts/test/unit/abi-drift.test.ts +++ b/sdk/ts/test/unit/abi-drift.test.ts @@ -2,7 +2,6 @@ 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'; @@ -92,23 +91,6 @@ function diffConsumedFunctions( ); } -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( @@ -169,30 +151,6 @@ describe('contract ABI drift guards', () => { ).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( @@ -253,26 +211,4 @@ describe('contract ABI drift guards', () => { ]); }); - 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/client.test.ts b/sdk/ts/test/unit/client.test.ts index 3688bdd96..258dce205 100644 --- a/sdk/ts/test/unit/client.test.ts +++ b/sdk/ts/test/unit/client.test.ts @@ -1,6 +1,6 @@ import { Decimal } from 'decimal.js'; import { jest } from '@jest/globals'; -import { Client } from '../../src/client.js'; +import { Client, DEFAULT_CHECKPOINT_POLL_INTERVAL_MS } from '../../src/client.js'; import * as core from '../../src/core/index.js'; const USER_WALLET = '0x1234567890123456789012345678901234567890' as const; @@ -392,3 +392,147 @@ describe('Client.withdraw cross-chain guard', () => { expect(client.requestChannelCreation).not.toHaveBeenCalled(); }); }); + +// Helper: create a stub BalanceEntry array for a given asset + enforced amount. +function makeBalances(asset: string, enforced: string, balance = '0'): core.BalanceEntry[] { + return [{ asset, balance: new Decimal(balance), enforced: new Decimal(enforced) }]; +} + +describe('Client.getConfirmationDelay', () => { + it('returns confirmationDelaySecs for a matching chain', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getBlockchains = jest.fn().mockResolvedValue([ + { id: 1n, name: 'Ethereum', confirmationDelaySecs: 36, channelHubAddress: '0x0', blockStep: 10n }, + { id: 137n, name: 'Polygon', confirmationDelaySecs: 5, channelHubAddress: '0x0', blockStep: 5n }, + ]); + + const delay = await client.getConfirmationDelay(1n); + expect(delay).toBe(36); + }); + + it('returns 0 when the gate is disabled for the matched chain', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getBlockchains = jest.fn().mockResolvedValue([ + { id: 137n, name: 'Polygon', confirmationDelaySecs: 0, channelHubAddress: '0x0', blockStep: 5n }, + ]); + + const delay = await client.getConfirmationDelay(137n); + expect(delay).toBe(0); + }); + + it('throws when the chainId is not in the returned blockchains list', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getBlockchains = jest.fn().mockResolvedValue([ + { id: 1n, name: 'Ethereum', confirmationDelaySecs: 36, channelHubAddress: '0x0', blockStep: 10n }, + ]); + + await expect(client.getConfirmationDelay(999n)).rejects.toThrow( + 'blockchain 999 not found in node config' + ); + }); +}); + +describe('Client.waitForCheckpoint', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('resolves immediately when enforced balance satisfies expectedBalance', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getUserAddress = jest.fn().mockReturnValue(USER_WALLET); + client.getBalances = jest.fn().mockResolvedValue(makeBalances('usdc', '100')); + client.getConfirmationDelay = jest.fn(); + + const result = await client.waitForCheckpoint('usdc', '0xTxHash', { + expectedBalance: new Decimal(100), + timeoutMs: 5000, + pollIntervalMs: 100, + }); + expect(result.asset).toBe('usdc'); + expect(result.enforced.gte(new Decimal(100))).toBe(true); + }); + + it('resolves in changed mode when enforced balance changes on second poll', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getUserAddress = jest.fn().mockReturnValue(USER_WALLET); + // First call (snapshot): enforced = 0. Subsequent calls: enforced = 50. + const getBalances = jest.fn() + .mockResolvedValueOnce(makeBalances('usdc', '0')) + .mockResolvedValue(makeBalances('usdc', '50')); + client.getBalances = getBalances; + client.getConfirmationDelay = jest.fn().mockResolvedValue(5); + + const promise = client.waitForCheckpoint('usdc', '0xTxHash', { + chainId: 1n, + timeoutMs: 30_000, + pollIntervalMs: 100, + }); + + // Advance past the lower-bound delay (5s) and one poll interval. + await jest.advanceTimersByTimeAsync(5_000); + await jest.advanceTimersByTimeAsync(100); + + const result = await promise; + expect(result.enforced.eq(new Decimal(50))).toBe(true); + }); + + it('does not poll getBalances before lower-bound wait elapses', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getUserAddress = jest.fn().mockReturnValue(USER_WALLET); + // Always return enforced = 0 so condition never satisfies during this test. + const getBalances = jest.fn().mockResolvedValue(makeBalances('usdc', '0')); + client.getBalances = getBalances; + client.getConfirmationDelay = jest.fn().mockResolvedValue(5); + + const promise = client.waitForCheckpoint('usdc', '0xTxHash', { + chainId: 1n, + timeoutMs: 30_000, + pollIntervalMs: 1000, + }); + // Suppress unhandled-rejection warnings. + promise.catch(() => {}); + + // Let the snapshot call (before the lower-bound wait) resolve. + await Promise.resolve(); + + // Advance only 3s (less than the 5s lower-bound). + await jest.advanceTimersByTimeAsync(3_000); + // Only the snapshot call (before the lower-bound wait) should have happened. + // The snapshot is call #1; the first actual poll happens after 5s. + expect(getBalances).toHaveBeenCalledTimes(1); + + // Clean up: advance past deadline to avoid hanging promise. + await jest.advanceTimersByTimeAsync(35_000); + await expect(promise).rejects.toThrow('timed out'); + }, 15_000); + + it('times out and error message contains txHash', async () => { + const client = Object.create(Client.prototype) as Client & Record; + client.getUserAddress = jest.fn().mockReturnValue(USER_WALLET); + // Always return enforced = 0. + client.getBalances = jest.fn().mockResolvedValue(makeBalances('usdc', '0')); + client.getConfirmationDelay = jest.fn(); + + const timeoutMs = 500; + // Attach .catch before advancing timers to avoid unhandled rejection. + const promise = client.waitForCheckpoint('usdc', '0xDeadBeef', { + expectedBalance: new Decimal(50), + timeoutMs, + pollIntervalMs: 50, + }); + // Suppress unhandled-rejection warnings during timer advancement. + promise.catch(() => {}); + + await jest.advanceTimersByTimeAsync(timeoutMs + 100); + + await expect(promise).rejects.toThrow('0xDeadBeef'); + }); + + it('DEFAULT_CHECKPOINT_POLL_INTERVAL_MS is 3000', () => { + expect(DEFAULT_CHECKPOINT_POLL_INTERVAL_MS).toBe(3000); + }); +}); diff --git a/sdk/ts/test/unit/core/state_advancer.test.ts b/sdk/ts/test/unit/core/state_advancer.test.ts index 569e0a77b..b938b3a65 100644 --- a/sdk/ts/test/unit/core/state_advancer.test.ts +++ b/sdk/ts/test/unit/core/state_advancer.test.ts @@ -8,6 +8,7 @@ import { applyMutualLockTransition, applyEscrowDepositTransition, applyHomeDepositTransition, + applyFinalizeTransition, } from '../../../src/core/state'; import { getStateId } from '../../../src/core/utils'; @@ -103,6 +104,25 @@ describe('ValidateAdvancement_EscrowDeposit', () => { }); }); +describe('nextState - post-Finalize epoch', () => { + test('starts at version 1 and increments epoch (MF3-C01)', () => { + // Version 0 is reserved as the "no on-chain state materialised yet" sentinel + // (NewChannel/Void), so the fresh epoch after a Finalize must begin at version 1. + const state = newVoidState('USDC', USER_WALLET); + state.homeChannelId = HOME_CHANNEL_ID; + state.epoch = 3n; + state.version = 5n; + state.id = getStateId(USER_WALLET, 'USDC', state.epoch, state.version); + applyFinalizeTransition(state); + + const next = nextState(state); + + expect(next.version).toBe(1n); + expect(next.epoch).toBe(4n); + expect(next.homeChannelId).toBeUndefined(); + }); +}); + describe('ValidateAdvancement_RejectsInvalidAmount', () => { const advancer = new StateAdvancerV1(new MockAssetStore()); const chanId = '0xChannel'; diff --git a/sdk/ts/test/unit/rpc-drift.test.ts b/sdk/ts/test/unit/rpc-drift.test.ts index 44404c310..5ed194d6a 100644 --- a/sdk/ts/test/unit/rpc-drift.test.ts +++ b/sdk/ts/test/unit/rpc-drift.test.ts @@ -69,7 +69,6 @@ describe('TS RPC drift guards', () => { ['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'], @@ -78,14 +77,11 @@ describe('TS RPC drift guards', () => { ['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', 'getLastAppKeyStates'], - ['apps.v1.get_apps', 'getApps'], - ['apps.v1.submit_app_version', 'registerApp'], ]); const intentionallyRawOnlyMethods = new Set([ From 7622391653e675c0c755f175beddd8cca092ca32 Mon Sep 17 00:00:00 2001 From: Sazonov Nikita <35502225+nksazonov@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:40:48 +0200 Subject: [PATCH 04/11] feat(nitronode/config/prod): add USDC asset across 7 chains (#845) --- .../1/run-1781689669039.json | 152 ++++++++++++++ .../DepositToNode.s.sol/1/run-latest.json | 152 ++++++++++++++ .../137/run-1781689647409.json | 186 ++++++++++++++++++ .../DepositToNode.s.sol/137/run-latest.json | 186 ++++++++++++++++++ .../14/run-1781689647494.json | 148 ++++++++++++++ .../DepositToNode.s.sol/14/run-latest.json | 148 ++++++++++++++ .../480/run-1781689648238.json | 168 ++++++++++++++++ .../DepositToNode.s.sol/480/run-latest.json | 168 ++++++++++++++++ .../56/run-1781689765538.json | 168 ++++++++++++++++ .../DepositToNode.s.sol/56/run-latest.json | 168 ++++++++++++++++ .../59144/run-1781689648201.json | 152 ++++++++++++++ .../DepositToNode.s.sol/59144/run-latest.json | 152 ++++++++++++++ .../8453/run-1781689649262.json | 168 ++++++++++++++++ .../DepositToNode.s.sol/8453/run-latest.json | 168 ++++++++++++++++ nitronode/chart/config/prod-v1/assets.yaml | 30 +++ 15 files changed, 2314 insertions(+) create mode 100644 contracts/broadcast/DepositToNode.s.sol/1/run-1781689669039.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/1/run-latest.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/137/run-1781689647409.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/137/run-latest.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/14/run-1781689647494.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/14/run-latest.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/480/run-1781689648238.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/480/run-latest.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/56/run-1781689765538.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/56/run-latest.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/59144/run-1781689648201.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/59144/run-latest.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/8453/run-1781689649262.json create mode 100644 contracts/broadcast/DepositToNode.s.sol/8453/run-latest.json diff --git a/contracts/broadcast/DepositToNode.s.sol/1/run-1781689669039.json b/contracts/broadcast/DepositToNode.s.sol/1/run-1781689669039.json new file mode 100644 index 000000000..6b69d0ac0 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/1/run-1781689669039.json @@ -0,0 +1,152 @@ +{ + "transactions": [ + { + "hash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "gas": "0x12bc3", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x25", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xe89f2ba6e2505adde5bdd00c60211b6e0b7ff8400fefe6dde2b78702b82d5c9f", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1febe", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x26", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x15d9769", + "logs": [ + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x6d49ab2c650b750f176b9142e22108f4b3aefc164edbdd11cc4f56ac488e410b", + "blockNumber": "0x1829a94", + "blockTimestamp": "0x6a326d37", + "transactionHash": "0xe89f2ba6e2505adde5bdd00c60211b6e0b7ff8400fefe6dde2b78702b82d5c9f", + "transactionIndex": "0xb7", + "logIndex": "0x453", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000080020000000400200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xe89f2ba6e2505adde5bdd00c60211b6e0b7ff8400fefe6dde2b78702b82d5c9f", + "transactionIndex": "0xb7", + "blockHash": "0x6d49ab2c650b750f176b9142e22108f4b3aefc164edbdd11cc4f56ac488e410b", + "blockNumber": "0x1829a94", + "gasUsed": "0xd906", + "effectiveGasPrice": "0x70c2f8b", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1a564e6", + "logs": [ + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "blockTimestamp": "0x6a326d43", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "logIndex": "0x3df", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "blockTimestamp": "0x6a326d43", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "logIndex": "0x3e0", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "blockTimestamp": "0x6a326d43", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "logIndex": "0x3e1", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000002000000000000000000000000000000000002200000000000000000000008000008000000000000000000000000000040000000200000000000000000000000000000000000000000008000000008000010000200000000000000000000000000000080000200000000010000000000000000000080000000000400200000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000040000000000040000000000000000004000000000000000000000000000000", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "gasUsed": "0x171c5", + "effectiveGasPrice": "0x6f85583", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689669039, + "chain": 1, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/1/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/1/run-latest.json new file mode 100644 index 000000000..6b69d0ac0 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/1/run-latest.json @@ -0,0 +1,152 @@ +{ + "transactions": [ + { + "hash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "gas": "0x12bc3", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x25", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xe89f2ba6e2505adde5bdd00c60211b6e0b7ff8400fefe6dde2b78702b82d5c9f", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1febe", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x26", + "chainId": "0x1" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x15d9769", + "logs": [ + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x6d49ab2c650b750f176b9142e22108f4b3aefc164edbdd11cc4f56ac488e410b", + "blockNumber": "0x1829a94", + "blockTimestamp": "0x6a326d37", + "transactionHash": "0xe89f2ba6e2505adde5bdd00c60211b6e0b7ff8400fefe6dde2b78702b82d5c9f", + "transactionIndex": "0xb7", + "logIndex": "0x453", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000080020000000400200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xe89f2ba6e2505adde5bdd00c60211b6e0b7ff8400fefe6dde2b78702b82d5c9f", + "transactionIndex": "0xb7", + "blockHash": "0x6d49ab2c650b750f176b9142e22108f4b3aefc164edbdd11cc4f56ac488e410b", + "blockNumber": "0x1829a94", + "gasUsed": "0xd906", + "effectiveGasPrice": "0x70c2f8b", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1a564e6", + "logs": [ + { + "address": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "blockTimestamp": "0x6a326d43", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "logIndex": "0x3df", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "blockTimestamp": "0x6a326d43", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "logIndex": "0x3e0", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "blockTimestamp": "0x6a326d43", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "logIndex": "0x3e1", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000002000000000000000000000000000000000002200000000000000000000008000008000000000000000000000000000040000000200000000000000000000000000000000000000000008000000008000010000200000000000000000000000000000080000200000000010000000000000000000080000000000400200000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000040000000000040000000000000000004000000000000000000000000000000", + "transactionHash": "0x7223276d2f73cb0b6f2fcd5a72b0deead4840815af61aff7598b050e127bfe1c", + "transactionIndex": "0xdb", + "blockHash": "0xb03b19f03953ee66e0cd2bd7a81158453745cfe214d88fb7840472b6186d080f", + "blockNumber": "0x1829a95", + "gasUsed": "0x171c5", + "effectiveGasPrice": "0x6f85583", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689669039, + "chain": 1, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/137/run-1781689647409.json b/contracts/broadcast/DepositToNode.s.sol/137/run-1781689647409.json new file mode 100644 index 000000000..12bfd79f5 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/137/run-1781689647409.json @@ -0,0 +1,186 @@ +{ + "transactions": [ + { + "hash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "gas": "0x12b1b", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0xa", + "chainId": "0x89" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fe16", + "value": "0x0", + "input": "0xb65b78d10000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0xb", + "chainId": "0x89" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1755f68", + "logs": [ + { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionIndex": "0x5a", + "logIndex": "0x357", + "removed": false + }, + { + "address": "0x0000000000000000000000000000000000001010", + "topics": [ + "0x4dfe1bbbcf077ddc3e01291eea2d5c70c2b422b415d95645b9adcfd678cb1d63", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000007ee41d8a25641000661b1ef5e6ae8a00400466b0" + ], + "data": "0x0000000000000000000000000000000000000000000000000024ccf701657b80000000000000000000000000000000000000000000000000296d322784ee2b1e000000000000000000000000000000000000000000173b99229edfe7d174807c000000000000000000000000000000000000000000000000294865308388af9e000000000000000000000000000000000000000000173b9922c3acded2d9fbfc", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionIndex": "0x5a", + "logIndex": "0x358", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000008000000000000000000002200000000000000000000000000000000000800000000000000000000100000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000020000000400000000000000000000000000000000000080220000000400000000000000000000000800000000000000000000000000004000000000000000000001000000000000000000400000200000100000000000000010000000000040000000000000010000000000100000000000000000100000", + "transactionHash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionIndex": "0x5a", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "gasUsed": "0x10335", + "effectiveGasPrice": "0x5e2c9bd2a7", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x177366c", + "logs": [ + { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x359", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x35a", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x35b", + "removed": false + }, + { + "address": "0x0000000000000000000000000000000000001010", + "topics": [ + "0x4dfe1bbbcf077ddc3e01291eea2d5c70c2b422b415d95645b9adcfd678cb1d63", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000007ee41d8a25641000661b1ef5e6ae8a00400466b0" + ], + "data": "0x0000000000000000000000000000000000000000000000000042df37c9847600000000000000000000000000000000000000000000000000290dd786a28c998b000000000000000000000000000000000000000000173b9922c3acded2d9fbfc00000000000000000000000000000000000000000000000028caf84ed908238b000000000000000000000000000000000000000000173b9923068c169c5e71fc", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x35c", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000008000000000000000000002200000000000000040000000000008000000800000000000000000000140000000200000000000000000000000000000000000000000008000000088000010000200000000000000000000020000800480000000000000000000000000000000000080200000000400000000000000000000000800000000000000000000000000004000000002000000000001000000000000000000420000200000100000000000000040000000000040000000000000010000000000100000000000000000100000", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "gasUsed": "0x1d704", + "effectiveGasPrice": "0x5e2c9bd2a7", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689647409, + "chain": 137, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/137/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/137/run-latest.json new file mode 100644 index 000000000..12bfd79f5 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/137/run-latest.json @@ -0,0 +1,186 @@ +{ + "transactions": [ + { + "hash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "gas": "0x12b1b", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0xa", + "chainId": "0x89" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fe16", + "value": "0x0", + "input": "0xb65b78d10000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0xb", + "chainId": "0x89" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1755f68", + "logs": [ + { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionIndex": "0x5a", + "logIndex": "0x357", + "removed": false + }, + { + "address": "0x0000000000000000000000000000000000001010", + "topics": [ + "0x4dfe1bbbcf077ddc3e01291eea2d5c70c2b422b415d95645b9adcfd678cb1d63", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000007ee41d8a25641000661b1ef5e6ae8a00400466b0" + ], + "data": "0x0000000000000000000000000000000000000000000000000024ccf701657b80000000000000000000000000000000000000000000000000296d322784ee2b1e000000000000000000000000000000000000000000173b99229edfe7d174807c000000000000000000000000000000000000000000000000294865308388af9e000000000000000000000000000000000000000000173b9922c3acded2d9fbfc", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionIndex": "0x5a", + "logIndex": "0x358", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000008000000000000000000002200000000000000000000000000000000000800000000000000000000100000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000020000000400000000000000000000000000000000000080220000000400000000000000000000000800000000000000000000000000004000000000000000000001000000000000000000400000200000100000000000000010000000000040000000000000010000000000100000000000000000100000", + "transactionHash": "0xc066092debd184d7d4bdca5224210798340e508d97042f6b8de26ac20194614a", + "transactionIndex": "0x5a", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "gasUsed": "0x10335", + "effectiveGasPrice": "0x5e2c9bd2a7", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x177366c", + "logs": [ + { + "address": "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x359", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x35a", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x0000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c3359" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x35b", + "removed": false + }, + { + "address": "0x0000000000000000000000000000000000001010", + "topics": [ + "0x4dfe1bbbcf077ddc3e01291eea2d5c70c2b422b415d95645b9adcfd678cb1d63", + "0x0000000000000000000000000000000000000000000000000000000000001010", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000007ee41d8a25641000661b1ef5e6ae8a00400466b0" + ], + "data": "0x0000000000000000000000000000000000000000000000000042df37c9847600000000000000000000000000000000000000000000000000290dd786a28c998b000000000000000000000000000000000000000000173b9922c3acded2d9fbfc00000000000000000000000000000000000000000000000028caf84ed908238b000000000000000000000000000000000000000000173b9923068c169c5e71fc", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "blockTimestamp": "0x6a326d30", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "logIndex": "0x35c", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000008000000000000000000002200000000000000040000000000008000000800000000000000000000140000000200000000000000000000000000000000000000000008000000088000010000200000000000000000000020000800480000000000000000000000000000000000080200000000400000000000000000000000800000000000000000000000000004000000002000000000001000000000000000000420000200000100000000000000040000000000040000000000000010000000000100000000000000000100000", + "transactionHash": "0xdf2a20cb7b7e0d35828ff67e4f87b94f81bc4fcb15ebb58e2c1c5dfcb214bef8", + "transactionIndex": "0x5b", + "blockHash": "0xee523d5e300f93f0de1496d4bb7afc1010e3af69fcfb82a3e7fa276f0e9475be", + "blockNumber": "0x548ca37", + "gasUsed": "0x1d704", + "effectiveGasPrice": "0x5e2c9bd2a7", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689647409, + "chain": 137, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/14/run-1781689647494.json b/contracts/broadcast/DepositToNode.s.sol/14/run-1781689647494.json new file mode 100644 index 000000000..a7aefa808 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/14/run-1781689647494.json @@ -0,0 +1,148 @@ +{ + "transactions": [ + { + "hash": "0x8b1a14ef5bf3fdc4ff3ae365c993a406b27520f1669c4a82c0fb1676d92f2990", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "gas": "0x12b9f", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xd", + "chainId": "0xe" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0xFbDa5F676cB37624f28265A144A48B0d6e87d3b6", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fecd", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000fbda5f676cb37624f28265a144a48b0d6e87d3b60000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xe", + "chainId": "0xe" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xe57c0", + "logs": [ + { + "address": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0x8b1a14ef5bf3fdc4ff3ae365c993a406b27520f1669c4a82c0fb1676d92f2990", + "transactionIndex": "0x13", + "logIndex": "0x8", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000001000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000004000000000", + "transactionHash": "0x8b1a14ef5bf3fdc4ff3ae365c993a406b27520f1669c4a82c0fb1676d92f2990", + "transactionIndex": "0x13", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "gasUsed": "0xd8ed", + "effectiveGasPrice": "0x49e122f", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xfe740", + "logs": [ + { + "address": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "logIndex": "0x9", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000fbda5f676cb37624f28265a144a48b0d6e87d3b6" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "logIndex": "0xa", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000fbda5f676cb37624f28265a144a48b0d6e87d3b6" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "logIndex": "0xb", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000001000000000000000008000000000002000000000000000040000000200000000000000011000000000000000000000000008000000008000010000200000000000000000000000000000080000000000000000000000000020000000080000000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000040000000000040000000000000000000000000000000000000004000000000", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "gasUsed": "0x18f80", + "effectiveGasPrice": "0x49e122f", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689647494, + "chain": 14, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/14/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/14/run-latest.json new file mode 100644 index 000000000..a7aefa808 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/14/run-latest.json @@ -0,0 +1,148 @@ +{ + "transactions": [ + { + "hash": "0x8b1a14ef5bf3fdc4ff3ae365c993a406b27520f1669c4a82c0fb1676d92f2990", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "gas": "0x12b9f", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xd", + "chainId": "0xe" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0xFbDa5F676cB37624f28265A144A48B0d6e87d3b6", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fecd", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000fbda5f676cb37624f28265a144a48b0d6e87d3b60000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xe", + "chainId": "0xe" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xe57c0", + "logs": [ + { + "address": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0x8b1a14ef5bf3fdc4ff3ae365c993a406b27520f1669c4a82c0fb1676d92f2990", + "transactionIndex": "0x13", + "logIndex": "0x8", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000001000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000004000000000", + "transactionHash": "0x8b1a14ef5bf3fdc4ff3ae365c993a406b27520f1669c4a82c0fb1676d92f2990", + "transactionIndex": "0x13", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "gasUsed": "0xd8ed", + "effectiveGasPrice": "0x49e122f", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xfe740", + "logs": [ + { + "address": "0xfbda5f676cb37624f28265a144a48b0d6e87d3b6", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "logIndex": "0x9", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000fbda5f676cb37624f28265a144a48b0d6e87d3b6" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "logIndex": "0xa", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000fbda5f676cb37624f28265a144a48b0d6e87d3b6" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "logIndex": "0xb", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000001000000000000000008000000000002000000000000000040000000200000000000000011000000000000000000000000008000000008000010000200000000000000000000000000000080000000000000000000000000020000000080000000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000040000000000040000000000000000000000000000000000000004000000000", + "transactionHash": "0xf27f101b5e1fd4686be7fbace6b5a83824e24d2f2ece876d0a8b69abceff239a", + "transactionIndex": "0x14", + "blockHash": "0x7918bcd972e25b6dd9f1430abd5a0b2dc2db249268e03cd0ae6edd02902bfdea", + "blockNumber": "0x3c3042c", + "gasUsed": "0x18f80", + "effectiveGasPrice": "0x49e122f", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689647494, + "chain": 14, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/480/run-1781689648238.json b/contracts/broadcast/DepositToNode.s.sol/480/run-1781689648238.json new file mode 100644 index 000000000..7b35a608d --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/480/run-1781689648238.json @@ -0,0 +1,168 @@ +{ + "transactions": [ + { + "hash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "gas": "0x12b1b", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x0", + "chainId": "0x1e0" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xf9ebc9e7fa2ba889b0a442ba1c4119ea69e2decd390291ad20cc8adb5beff779", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x79A02482A880bCE3F13e09Da970dC34db4CD24d1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fe16", + "value": "0x0", + "input": "0xb65b78d100000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x1", + "chainId": "0x1e0" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x520bcc", + "logs": [ + { + "address": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0xf9ebc9e7fa2ba889b0a442ba1c4119ea69e2decd390291ad20cc8adb5beff779", + "transactionIndex": "0x15", + "logIndex": "0x5c", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000080020000000400000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000080000000000000", + "transactionHash": "0xf9ebc9e7fa2ba889b0a442ba1c4119ea69e2decd390291ad20cc8adb5beff779", + "transactionIndex": "0x15", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "gasUsed": "0xd88d", + "effectiveGasPrice": "0x927c0", + "blobGasUsed": "0x9c40", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "contractAddress": null, + "l1GasPrice": "0x7346383", + "l1GasUsed": "0x640", + "l1Fee": "0x89bcc486", + "l1BaseFeeScalar": "0x21f9", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0xdd3ef", + "daFootprintGasScalar": "0x190" + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x537d18", + "logs": [ + { + "address": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "logIndex": "0x5d", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x00000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "logIndex": "0x5e", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x00000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "logIndex": "0x5f", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000008000000000000000000000000000040000000200000000000000000000000000000000000000000008000000008000010000200000000000000000000000000000080004400000000000000000000000000000080000000000400000000000000000000000004000000000000000000000000000000000002000000200000000000000000000000020000200000000000000000000040000000000040000000000000000000000000000000002080000000000000", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "gasUsed": "0x1714c", + "effectiveGasPrice": "0x927c0", + "blobGasUsed": "0x9c40", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null, + "l1GasPrice": "0x7346383", + "l1GasUsed": "0x640", + "l1Fee": "0x89bcc486", + "l1BaseFeeScalar": "0x21f9", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0xdd3ef", + "daFootprintGasScalar": "0x190" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689648238, + "chain": 480, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/480/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/480/run-latest.json new file mode 100644 index 000000000..7b35a608d --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/480/run-latest.json @@ -0,0 +1,168 @@ +{ + "transactions": [ + { + "hash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "gas": "0x12b1b", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x0", + "chainId": "0x1e0" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xf9ebc9e7fa2ba889b0a442ba1c4119ea69e2decd390291ad20cc8adb5beff779", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x79A02482A880bCE3F13e09Da970dC34db4CD24d1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fe16", + "value": "0x0", + "input": "0xb65b78d100000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0x1", + "chainId": "0x1e0" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x520bcc", + "logs": [ + { + "address": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0xf9ebc9e7fa2ba889b0a442ba1c4119ea69e2decd390291ad20cc8adb5beff779", + "transactionIndex": "0x15", + "logIndex": "0x5c", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000080020000000400000000000000000000000004000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000080000000000000", + "transactionHash": "0xf9ebc9e7fa2ba889b0a442ba1c4119ea69e2decd390291ad20cc8adb5beff779", + "transactionIndex": "0x15", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "gasUsed": "0xd88d", + "effectiveGasPrice": "0x927c0", + "blobGasUsed": "0x9c40", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "contractAddress": null, + "l1GasPrice": "0x7346383", + "l1GasUsed": "0x640", + "l1Fee": "0x89bcc486", + "l1BaseFeeScalar": "0x21f9", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0xdd3ef", + "daFootprintGasScalar": "0x190" + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x537d18", + "logs": [ + { + "address": "0x79a02482a880bce3f13e09da970dc34db4cd24d1", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "logIndex": "0x5d", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x00000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "logIndex": "0x5e", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x00000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "blockTimestamp": "0x6a326d2f", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "logIndex": "0x5f", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200000000000000000000000000008000000000000000000000000000040000000200000000000000000000000000000000000000000008000000008000010000200000000000000000000000000000080004400000000000000000000000000000080000000000400000000000000000000000004000000000000000000000000000000000002000000200000000000000000000000020000200000000000000000000040000000000040000000000000000000000000000000002080000000000000", + "transactionHash": "0x918f830da65a6dbded14b6db48072f0ea8c13b40609f3bd0275016c40428e19e", + "transactionIndex": "0x16", + "blockHash": "0x8723a5e7d8bdc37b6441c5069aa76998496f461529e5885921de0f7f2d95ebbf", + "blockNumber": "0x1dbb92c", + "gasUsed": "0x1714c", + "effectiveGasPrice": "0x927c0", + "blobGasUsed": "0x9c40", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null, + "l1GasPrice": "0x7346383", + "l1GasUsed": "0x640", + "l1Fee": "0x89bcc486", + "l1BaseFeeScalar": "0x21f9", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0xdd3ef", + "daFootprintGasScalar": "0x190" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689648238, + "chain": 480, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/56/run-1781689765538.json b/contracts/broadcast/DepositToNode.s.sol/56/run-1781689765538.json new file mode 100644 index 000000000..4a7015dfb --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/56/run-1781689765538.json @@ -0,0 +1,168 @@ +{ + "transactions": [ + { + "hash": "0x61c070257f85f7adae3fb23a25437247e45e15eaf53559138df6609837e0e513", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000000000000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "gas": "0x12065", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000056bc75e2d63100000", + "nonce": "0xa", + "chainId": "0x38" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "100000000000000000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x212fd", + "value": "0x0", + "input": "0xb65b78d10000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d0000000000000000000000000000000000000000000000056bc75e2d63100000", + "nonce": "0xb", + "chainId": "0x38" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x2c16cb", + "logs": [ + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0x61c070257f85f7adae3fb23a25437247e45e15eaf53559138df6609837e0e513", + "transactionIndex": "0xe", + "logIndex": "0x58", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000004040000000000000000000000000000000000000000000000000", + "transactionHash": "0x61c070257f85f7adae3fb23a25437247e45e15eaf53559138df6609837e0e513", + "transactionIndex": "0xe", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "gasUsed": "0xd0cc", + "effectiveGasPrice": "0x2faf080", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x2d81dd", + "logs": [ + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x59", + "removed": false + }, + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x5a", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x5b", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x5c", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000040000000000000000000000000000000000000000000000000000000000000002200080000000000000000000000008000000000000000000000000000040000000202000000000000000000000000000000000000000008000000008000010000200008000000000000000000000000080008000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000050000000004040000000000000000000000000000000000000000000000000", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "gasUsed": "0x16b12", + "effectiveGasPrice": "0x2faf080", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689765538, + "chain": 56, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/56/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/56/run-latest.json new file mode 100644 index 000000000..4a7015dfb --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/56/run-latest.json @@ -0,0 +1,168 @@ +{ + "transactions": [ + { + "hash": "0x61c070257f85f7adae3fb23a25437247e45e15eaf53559138df6609837e0e513", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000000000000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "gas": "0x12065", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000056bc75e2d63100000", + "nonce": "0xa", + "chainId": "0x38" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d", + "100000000000000000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x212fd", + "value": "0x0", + "input": "0xb65b78d10000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d0000000000000000000000000000000000000000000000056bc75e2d63100000", + "nonce": "0xb", + "chainId": "0x38" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x2c16cb", + "logs": [ + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0x61c070257f85f7adae3fb23a25437247e45e15eaf53559138df6609837e0e513", + "transactionIndex": "0xe", + "logIndex": "0x58", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000000000002200080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000004040000000000000000000000000000000000000000000000000", + "transactionHash": "0x61c070257f85f7adae3fb23a25437247e45e15eaf53559138df6609837e0e513", + "transactionIndex": "0xe", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "gasUsed": "0xd0cc", + "effectiveGasPrice": "0x2faf080", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x2d81dd", + "logs": [ + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x59", + "removed": false + }, + { + "address": "0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x5a", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x5b", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x0000000000000000000000008ac76a51cc950d9822d68b83fe1ad97b32cd580d" + ], + "data": "0x0000000000000000000000000000000000000000000000056bc75e2d63100000", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "blockTimestamp": "0x6a326da4", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "logIndex": "0x5c", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000040000000000000000000000000000000000000000000000000000000000000002200080000000000000000000000008000000000000000000000000000040000000202000000000000000000000000000000000000000008000000008000010000200008000000000000000000000000080008000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000050000000004040000000000000000000000000000000000000000000000000", + "transactionHash": "0xe98a1d375ba42ee089f9660b481b49355aec6a94c3ab531e61fd60d5c28cd299", + "transactionIndex": "0xf", + "blockHash": "0x9e4aa876deefb041524ae2f8eba3940ddedb583a4c45afaeb59f6ffc0117b55f", + "blockNumber": "0x63e28f0", + "gasUsed": "0x16b12", + "effectiveGasPrice": "0x2faf080", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689765538, + "chain": 56, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/59144/run-1781689648201.json b/contracts/broadcast/DepositToNode.s.sol/59144/run-1781689648201.json new file mode 100644 index 000000000..cc66e3af0 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/59144/run-1781689648201.json @@ -0,0 +1,152 @@ +{ + "transactions": [ + { + "hash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "gas": "0x155de", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xa", + "chainId": "0xe708" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdfff45573a2834dda40610e5258e9ae099de7293b638f32a58491e6ee89296fa", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x176211869cA2b568f2A7D4EE941E073a821EE1ff", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x210a9", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff0000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xb", + "chainId": "0xe708" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xe9c2", + "logs": [ + { + "address": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xdfff45573a2834dda40610e5258e9ae099de7293b638f32a58491e6ee89296fa", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000800000000000004000002200000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xdfff45573a2834dda40610e5258e9ae099de7293b638f32a58491e6ee89296fa", + "transactionIndex": "0x0", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "gasUsed": "0xe9c2", + "effectiveGasPrice": "0x2faf087", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x26881", + "logs": [ + { + "address": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "logIndex": "0x3", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000800000000000004000002200000000000000000000000080008020000000000000000000000000040000000200000000000000000000000000000000000800000008000000008000010000200000000000000000000000000000080000000000000000000000000000000000080000000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000200020000200000000000000000000040000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "gasUsed": "0x17ebf", + "effectiveGasPrice": "0x2faf087", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689648201, + "chain": 59144, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/59144/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/59144/run-latest.json new file mode 100644 index 000000000..cc66e3af0 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/59144/run-latest.json @@ -0,0 +1,152 @@ +{ + "transactions": [ + { + "hash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "gas": "0x155de", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d10000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xa", + "chainId": "0xe708" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0xdfff45573a2834dda40610e5258e9ae099de7293b638f32a58491e6ee89296fa", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x176211869cA2b568f2A7D4EE941E073a821EE1ff", + "100000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x210a9", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff0000000000000000000000000000000000000000000000000000000005f5e100", + "nonce": "0xb", + "chainId": "0xe708" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0xe9c2", + "logs": [ + { + "address": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xdfff45573a2834dda40610e5258e9ae099de7293b638f32a58491e6ee89296fa", + "transactionIndex": "0x0", + "logIndex": "0x0", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000800000000000004000002200000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xdfff45573a2834dda40610e5258e9ae099de7293b638f32a58491e6ee89296fa", + "transactionIndex": "0x0", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "gasUsed": "0xe9c2", + "effectiveGasPrice": "0x2faf087", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "contractAddress": null + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x26881", + "logs": [ + { + "address": "0x176211869ca2b568f2a7d4ee941e073a821ee1ff", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "logIndex": "0x1", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "logIndex": "0x2", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000176211869ca2b568f2a7d4ee941e073a821ee1ff" + ], + "data": "0x0000000000000000000000000000000000000000000000000000000005f5e100", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "blockTimestamp": "0x6a326d2d", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "logIndex": "0x3", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000800000000000004000002200000000000000000000000080008020000000000000000000000000040000000200000000000000000000000000000000000800000008000000008000010000200000000000000000000000000000080000000000000000000000000000000000080000000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000200020000200000000000000000000040000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xab62eba6329b5a6f89e86a0cd9c580cf478e3205553518f17f91994060355c37", + "transactionIndex": "0x1", + "blockHash": "0x03111a16f79196ed28c12747dcb57e211f3fee1efdfbe834d0f013591bae92a6", + "blockNumber": "0x1da009a", + "gasUsed": "0x17ebf", + "effectiveGasPrice": "0x2faf087", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689648201, + "chain": 59144, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/8453/run-1781689649262.json b/contracts/broadcast/DepositToNode.s.sol/8453/run-1781689649262.json new file mode 100644 index 000000000..bbb82f5a8 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/8453/run-1781689649262.json @@ -0,0 +1,168 @@ +{ + "transactions": [ + { + "hash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "gas": "0x12b1b", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0x19", + "chainId": "0x2105" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x5775b47ace6d265e6658c7c60ce1e37fa674d8890684629a8e8780bf165961c2", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fe16", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0x1a", + "chainId": "0x2105" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1282ef3", + "logs": [ + { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x02a21ee750fd1cdefed577bb344c7311d1b5e321f43a4f6a892171c3cbfa25b3", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0x5775b47ace6d265e6658c7c60ce1e37fa674d8890684629a8e8780bf165961c2", + "transactionIndex": "0x4f", + "logIndex": "0x202", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000100000000000000000000000000002200000000000000000000000000000000000000000000000080000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0x5775b47ace6d265e6658c7c60ce1e37fa674d8890684629a8e8780bf165961c2", + "transactionIndex": "0x4f", + "blockHash": "0x02a21ee750fd1cdefed577bb344c7311d1b5e321f43a4f6a892171c3cbfa25b3", + "blockNumber": "0x2d40827", + "gasUsed": "0xd88d", + "effectiveGasPrice": "0x51a276", + "blobGasUsed": "0x39d0", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "contractAddress": null, + "l1GasPrice": "0x6eabe47", + "l1GasUsed": "0x640", + "l1Fee": "0x44c7a92b", + "l1BaseFeeScalar": "0x8dd", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0x101c12", + "daFootprintGasScalar": "0x94" + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x129a03f", + "logs": [ + { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "logIndex": "0x203", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "logIndex": "0x204", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "logIndex": "0x205", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000100000000000000000000000000002200000000000000000040000000008000000000000000000080000000040000000200000800000000000000000000000000000010000008000000008000010000200000000000000000000400000000080000000000000000000000000000000000080000000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000040000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "gasUsed": "0x1714c", + "effectiveGasPrice": "0x51a276", + "blobGasUsed": "0x39d0", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null, + "l1GasPrice": "0x6eabe47", + "l1GasUsed": "0x640", + "l1Fee": "0x44c7a92b", + "l1BaseFeeScalar": "0x8dd", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0x101c12", + "daFootprintGasScalar": "0x94" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689649262, + "chain": 8453, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/contracts/broadcast/DepositToNode.s.sol/8453/run-latest.json b/contracts/broadcast/DepositToNode.s.sol/8453/run-latest.json new file mode 100644 index 000000000..bbb82f5a8 --- /dev/null +++ b/contracts/broadcast/DepositToNode.s.sol/8453/run-latest.json @@ -0,0 +1,168 @@ +{ + "transactions": [ + { + "hash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "function": "approve(address,uint256)", + "arguments": [ + "0x1a2f750170474D4c54f8d318D9d4343588b4C4D1", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "gas": "0x12b1b", + "value": "0x0", + "input": "0x095ea7b30000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0x19", + "chainId": "0x2105" + }, + "additionalContracts": [], + "isFixedGasLimit": false + }, + { + "hash": "0x5775b47ace6d265e6658c7c60ce1e37fa674d8890684629a8e8780bf165961c2", + "transactionType": "CALL", + "contractName": null, + "contractAddress": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "function": "depositToNode(address,uint256)", + "arguments": [ + "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "200000000" + ], + "transaction": { + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "gas": "0x1fe16", + "value": "0x0", + "input": "0xb65b78d1000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913000000000000000000000000000000000000000000000000000000000bebc200", + "nonce": "0x1a", + "chainId": "0x2105" + }, + "additionalContracts": [], + "isFixedGasLimit": false + } + ], + "receipts": [ + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x1282ef3", + "logs": [ + { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "topics": [ + "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x02a21ee750fd1cdefed577bb344c7311d1b5e321f43a4f6a892171c3cbfa25b3", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0x5775b47ace6d265e6658c7c60ce1e37fa674d8890684629a8e8780bf165961c2", + "transactionIndex": "0x4f", + "logIndex": "0x202", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000100000000000000000000000000002200000000000000000000000000000000000000000000000080000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000080020000000400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000010000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0x5775b47ace6d265e6658c7c60ce1e37fa674d8890684629a8e8780bf165961c2", + "transactionIndex": "0x4f", + "blockHash": "0x02a21ee750fd1cdefed577bb344c7311d1b5e321f43a4f6a892171c3cbfa25b3", + "blockNumber": "0x2d40827", + "gasUsed": "0xd88d", + "effectiveGasPrice": "0x51a276", + "blobGasUsed": "0x39d0", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "contractAddress": null, + "l1GasPrice": "0x6eabe47", + "l1GasUsed": "0x640", + "l1Fee": "0x44c7a92b", + "l1BaseFeeScalar": "0x8dd", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0x101c12", + "daFootprintGasScalar": "0x94" + }, + { + "type": "0x2", + "status": "0x1", + "cumulativeGasUsed": "0x129a03f", + "logs": [ + { + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x000000000000000000000000fadf8382db9468e9f303746edc3b95af83489c2a", + "0x0000000000000000000000001a2f750170474d4c54f8d318d9d4343588b4c4d1" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "logIndex": "0x203", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x2da466a7b24304f47e87fa2e1e5a81b9831ce54fec19055ce277ca2f39ba42c4", + "0x000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "logIndex": "0x204", + "removed": false + }, + { + "address": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "topics": [ + "0x05f47829691a1f710b0620aedd52749bb09d8abe4bb530d306db920a71b0d7ce", + "0x000000000000000000000000833589fcd6edb6e08f4c7c32d4f71b54bda02913" + ], + "data": "0x000000000000000000000000000000000000000000000000000000000bebc200", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "blockTimestamp": "0x6a326d31", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "logIndex": "0x205", + "removed": false + } + ], + "logsBloom": "0x00000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000100000000000000000000000000002200000000000000000040000000008000000000000000000080000000040000000200000800000000000000000000000000000010000008000000008000010000200000000000000000000400000000080000000000000000000000000000000000080000000000400000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000020000200000000000000000000040000000000040000000000000000000000000000000000000000000000000", + "transactionHash": "0xddba8a024c2a1ae224bbadf296b1c671422deb8e5bb36c9490d0ab308e1d4e50", + "transactionIndex": "0x50", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000000", + "blockNumber": "0x2d40827", + "gasUsed": "0x1714c", + "effectiveGasPrice": "0x51a276", + "blobGasUsed": "0x39d0", + "from": "0xfadf8382db9468e9f303746edc3b95af83489c2a", + "to": "0x1a2f750170474d4c54f8d318d9d4343588b4c4d1", + "contractAddress": null, + "l1GasPrice": "0x6eabe47", + "l1GasUsed": "0x640", + "l1Fee": "0x44c7a92b", + "l1BaseFeeScalar": "0x8dd", + "l1BlobBaseFee": "0x69e317", + "l1BlobBaseFeeScalar": "0x101c12", + "daFootprintGasScalar": "0x94" + } + ], + "libraries": [], + "pending": [], + "returns": {}, + "timestamp": 1781689649262, + "chain": 8453, + "commit": "f246c8b2" +} \ No newline at end of file diff --git a/nitronode/chart/config/prod-v1/assets.yaml b/nitronode/chart/config/prod-v1/assets.yaml index ce45ae948..84e192f34 100644 --- a/nitronode/chart/config/prod-v1/assets.yaml +++ b/nitronode/chart/config/prod-v1/assets.yaml @@ -17,6 +17,36 @@ assets: - blockchain_id: 59144 address: "0x0000000000000000000000000000000000000000" decimals: 18 + - name: "USD Coin" + symbol: "usdc" + decimals: 6 + suggested_blockchain_id: 1 + tokens: + - blockchain_id: 1 + address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48" + decimals: 6 + - blockchain_id: 14 + address: "0xFbDa5F676cB37624f28265A144A48B0d6e87d3b6" + decimals: 6 + - blockchain_id: 56 + address: "0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d" + decimals: 18 + - blockchain_id: 137 + address: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359" + decimals: 6 + - blockchain_id: 480 + address: "0x79A02482A880bCE3F13e09Da970dC34db4CD24d1" + decimals: 6 + - blockchain_id: 8453 + address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + decimals: 6 + - blockchain_id: 59144 + address: "0x176211869cA2b568f2A7D4EE941E073a821EE1ff" + decimals: 6 + # No liquidity: + # - blockchain_id: 1440000 + # address: "0xa16148c6Ac9EDe0D82f0c52899e22a575284f131" + # decimals: 6 - name: "Tether USD" symbol: "usdt" decimals: 6 From 6ef0996d04637923ac440eaddd094517e3f8b40f Mon Sep 17 00:00:00 2001 From: Danylo Patsora Date: Wed, 17 Jun 2026 17:41:19 +0300 Subject: [PATCH 05/11] [Snyk] Upgrade ws from 8.20.1 to 8.21.0 (#835) --- sdk/ts/examples/app_sessions/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/ts/examples/app_sessions/package.json b/sdk/ts/examples/app_sessions/package.json index c07f3e39e..a6f304c97 100644 --- a/sdk/ts/examples/app_sessions/package.json +++ b/sdk/ts/examples/app_sessions/package.json @@ -10,7 +10,7 @@ "@yellow-org/sdk": "file:../..", "decimal.js": "^10.4.3", "viem": "^2.50.4", - "ws": "^8.20.1" + "ws": "^8.21.0" }, "devDependencies": { "@types/node": "^22.10.2", From dbbe0e830edf8fdf322342d50439ecc7e537c43c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:42:25 +0200 Subject: [PATCH 06/11] chore(gomod): bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1 (#815) Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
Release notes

Sourced from github.com/quic-go/quic-go's releases.

v0.59.1

This patch release backports quic-go/quic-go#5642, which adds validation for HTTP/3 trailers.

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github.com/quic-go/quic-go&package-manager=go_modules&previous-version=0.59.0&new-version=0.59.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/layer-3/nitrolite/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 87472649f..9855491a5 100644 --- a/go.mod +++ b/go.mod @@ -59,7 +59,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/term v1.2.0-beta.2 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.0 // indirect + github.com/quic-go/quic-go v0.59.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index 59a04627b..dd49bf83f 100644 --- a/go.sum +++ b/go.sum @@ -355,8 +355,8 @@ github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEy github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= -github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= +github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= From c9e6275376e66fcad7ff07d4eedebfe93be73dc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:42:56 +0200 Subject: [PATCH 07/11] chore(gomod): bump the gomod-dependencies group across 1 directory with 5 updates (#838) --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 9855491a5..375e994cb 100644 --- a/go.mod +++ b/go.mod @@ -18,8 +18,8 @@ require ( github.com/testcontainers/testcontainers-go v0.42.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 go.yaml.in/yaml/v2 v2.4.4 - golang.org/x/term v0.43.0 - google.golang.org/api v0.281.0 + golang.org/x/term v0.44.0 + google.golang.org/api v0.284.0 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -72,7 +72,7 @@ require ( golang.org/x/time v0.15.0 // indirect google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/grpc v1.81.1 // indirect ) @@ -121,7 +121,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/mattn/go-sqlite3 v1.14.44 + github.com/mattn/go-sqlite3 v1.14.45 github.com/mfridman/interpolate v0.0.2 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect @@ -151,14 +151,14 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect - go.opentelemetry.io/otel v1.43.0 - go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/trace v1.43.0 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 golang.org/x/crypto v0.51.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index dd49bf83f..62f498dea 100644 --- a/go.sum +++ b/go.sum @@ -270,8 +270,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= -github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk= +github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mattn/go-tty v0.0.3 h1:5OfyWorkyO7xP52Mq7tB36ajHDG5OHrmBGIS/DtakQI= github.com/mattn/go-tty v0.0.3/go.mod h1:ihxohKRERHTVzN+aSVRwACLCeqIoZAWpoICkkvrWyR0= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -423,16 +423,16 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= @@ -456,8 +456,8 @@ golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -470,24 +470,24 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/api v0.281.0 h1:sohYgszGGg3zC0P6ncdV6J9IOBtN9LVfFKz8C9tTtJU= -google.golang.org/api v0.281.0/go.mod h1:6Wssta4c5n9qHq5CBhmlai5h/PUa1djdDAIhYEHyvcM= +google.golang.org/api v0.284.0 h1:i+cKTgeQRcRySkP7QTl5PDO7/pAm8EcMFIUMlNbk4Vc= +google.golang.org/api v0.284.0/go.mod h1:AU44fU+XVZOCcd8uLaBIa/ZgzgPf/0qqY3+m7lQaado= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0= google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 h1:PvEgGJf9C/1u5CHkInMg7UFYYUoiaQmW2LbtH0pjB78= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 5d1dfc90bbb21ac03ad2d2df9f78478ee91a29d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:43:18 +0200 Subject: [PATCH 08/11] chore(npm): bump the npm-dependencies group across 3 directories with 22 updates (#839) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the npm-dependencies group with 8 updates in the /sdk/ts directory: | Package | From | To | | --- | --- | --- | | [viem](https://github.com/wevm/viem) | `2.51.2` | `2.52.2` | | [@ethereumjs/blockchain](https://github.com/ethereumjs/ethereumjs-monorepo) | `10.1.1` | `10.1.2` | | [@ethereumjs/evm](https://github.com/ethereumjs/ethereumjs-monorepo) | `10.1.1` | `10.1.2` | | [@ethereumjs/vm](https://github.com/ethereumjs/ethereumjs-monorepo) | `10.1.1` | `10.1.2` | | [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.9.1` | `25.9.3` | | [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) | `8.60.0` | `8.61.0` | | [eslint](https://github.com/eslint/eslint) | `10.4.0` | `10.5.0` | | [prettier](https://github.com/prettier/prettier) | `3.8.3` | `3.8.4` | Bumps the npm-dependencies group with 1 update in the /sdk/ts/examples/app_sessions directory: [tsx](https://github.com/privatenumber/tsx). Bumps the npm-dependencies group with 9 updates in the /sdk/ts/examples/example-app directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-accordion](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/accordion) | `1.2.12` | `1.2.13` | | [@radix-ui/react-separator](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/separator) | `1.1.8` | `1.1.9` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.4` | `1.2.5` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.18.0` | | [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.6` | `19.2.7` | | [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) | `19.2.15` | `19.2.17` | | [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.6` | `19.2.7` | | [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.3.0` | `4.3.1` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.14` | `8.0.16` | Updates `viem` from 2.51.2 to 2.52.2
Release notes

Sourced from viem's releases.

viem@2.52.2

Patch Changes

viem@2.52.0

Minor Changes

viem@2.51.3

Patch Changes

Commits
  • 1b6a888 chore: version package (#4719)
  • 78a9a60 chore: version package (#4707)
  • 5fe74ca docs(tempo): use calls in multisig transaction examples (#4716)
  • 1969c01 feat(chains): set t5 hardfork on Tempo Testnet (Moderato) (#4718)
  • 394f1c5 chore: bump chains size-limit budgets (#4717)
  • 51c51ba Update multisig account support in viem/tempo
  • a74b05d chore: bump vitest to fix audit vulnerability (#4715)
  • f5ae20d feat(tempo): infer multisig config from account in prepareTransactionRequest ...
  • 25c2d6a feat(tempo): add native multisig account support (#4710)
  • cee9b68 chore: bump vocs to 2.0.11 (#4708)
  • Additional commits viewable in compare view

Updates `@ethereumjs/blockchain` from 10.1.1 to 10.1.2
Release notes

Sourced from @​ethereumjs/blockchain's releases.

@​ethereumjs/blockchain v10.1.2

Release round overview

Welcome to 10.1.2 — a coordinated release across all active @ethereumjs/* libraries on the 10.1.x line. If you have been following the upcoming Amsterdam hardfork, this is our first experimental preview ready to try out: a largely complete nine-EIP Hardfork.Amsterdam bundle, currently aligned with tests-bal@v7.1.0 and BAL devnet-7.

Amsterdam is still in flux — please do not use this in production yet — and we expect further 10.1.x releases as the spec and official tests evolve. The sections below cover this package only; for the full fork picture (EIP list, examples, release ↔ spec tracking), see the @​ethereumjs/vm Amsterdam overview. On Osaka or earlier hardforks? Nothing changes unless you explicitly select Hardfork.Amsterdam.

@ethereumjs/blockchain

@ethereumjs/blockchain implements a minimal chain backend (block insertion, validation hooks, reorg handling) on top of @ethereumjs/block and @ethereumjs/mpt. It is not on the hot path for Amsterdam EST testing — most integrators exercise Amsterdam via @ethereumjs/vm directly — but within the 10.1.2 round this package stays aligned with the dependency set so full-stack setups do not hit version skew. No package-specific API or consensus changes.

Commits

Updates `@ethereumjs/common` from 10.1.1 to 10.1.2
Release notes

Sourced from @​ethereumjs/common's releases.

@​ethereumjs/common v10.1.2

Release round overview

Welcome to 10.1.2 — a coordinated release across all active @ethereumjs/* libraries on the 10.1.x line. If you have been following the upcoming Amsterdam hardfork, this is our first experimental preview ready to try out: a largely complete nine-EIP Hardfork.Amsterdam bundle, currently aligned with tests-bal@v7.1.0 and BAL devnet-7.

Amsterdam is still in flux — please do not use this in production yet — and we expect further 10.1.x releases as the spec and official tests evolve. The sections below cover this package only; for the full fork picture (EIP list, examples, release ↔ spec tracking), see the @​ethereumjs/vm Amsterdam overview. On Osaka or earlier hardforks? Nothing changes unless you explicitly select Hardfork.Amsterdam.

@ethereumjs/common

@ethereumjs/common is the fork and parameter engine: it answers “which EIPs are active?”, “what is maxCodeSize?”, and “what gas schedule applies?” for every other library. Within the 10.1.2 round, Amsterdam lands here as a new Hardfork.Amsterdam entry that activates the full nine-EIP bundle together — the same bundling execution-spec-tests and devnets use, so you should not cherry-pick individual Amsterdam EIPs in isolation when reproducing fixtures.

For integrators, the practical effect is a single switch: construct your Common with hardfork: Hardfork.Amsterdam and all downstream packages (@ethereumjs/evm, @ethereumjs/vm, @ethereumjs/tx, …) inherit consistent activation and parameter values.

At a glance

  • Add experimental Hardfork.Amsterdam with EIPs 7708, 7843, 7778, 7928, 7954, 7976, 7981, 8024, and 8037.
  • Updated gasPrices, gasConfig, and vm parameters for Amsterdam (7954 size limits, 7976/7981 floor pricing constants, 8037 state-gas dimensions, …).

Amsterdam (experimental)

Behaviour may change in subsequent 10.1.x patch releases. Spec snapshot: tests-bal@v7.1.0 · Testnet: BAL devnet-7 Execution details: Amsterdam hardfork (experimental)

import { Common, Hardfork, Mainnet } from
'@ethereumjs/common'

const common = new Common({ chain: Mainnet, hardfork: Hardfork.Amsterdam })

// Amsterdam raises max code / initcode size (EIP-7954)
common.param('maxCodeSize') // 51200 (vs 24576 pre-Amsterdam)

// EIP active checks drive behaviour in EVM, VM, Tx, Block
common.isActivatedEIP(7928) // true — BAL accumulation in VM/EVM
common.isActivatedEIP(8037) // true — two-dimensional block gas

The Supported EIPs section lists every Amsterdam EIP with links to the package that implements execution semantics.

Changes

Commits

Updates `@ethereumjs/evm` from 10.1.1 to 10.1.2
Release notes

Sourced from @​ethereumjs/evm's releases.

@​ethereumjs/evm v10.1.2

Release round overview

Welcome to 10.1.2 — a coordinated release across all active @ethereumjs/* libraries on the 10.1.x line. If you have been following the upcoming Amsterdam hardfork, this is our first experimental preview ready to try out: a largely complete nine-EIP Hardfork.Amsterdam bundle, currently aligned with tests-bal@v7.1.0 and BAL devnet-7.

Amsterdam is still in flux — please do not use this in production yet — and we expect further 10.1.x releases as the spec and official tests evolve. The sections below cover this package only; for the full fork picture (EIP list, examples, release ↔ spec tracking), see the @​ethereumjs/vm Amsterdam overview. On Osaka or earlier hardforks? Nothing changes unless you explicitly select Hardfork.Amsterdam.

@ethereumjs/evm

@ethereumjs/evm is the low-level EVM interpreter: opcodes, precompiles, gas metering, and message-call semantics. Within the 10.1.2 round, Amsterdam changes land here first — new instructions, revised size limits, state-gas accounting inside the interpreter, and BAL footprint recording on evm.blockLevelAccessList. The VM wraps these details into runBlock() / runTx(); use the EVM directly when building tracers, custom runners, or runCall()-style tools.

At a glance

  • EIP-8024 stack opcodes DUPN, SWAPN, EXCHANGE with immediate validation at decode time.
  • EIP-7954 raised max contract code and initcode size (via common.param('maxCodeSize')).
  • EIP-8037 state-gas reservoir on the EVM instance — state-touching ops draw from evm.stateGasReservoir before spilling into regular gas.
  • EIP-7708 synthetic Transfer / Burn logs on value-moving paths; EIP-7843 SLOTNUM opcode.
  • EIP-7928 automatic state-access recording on evm.blockLevelAccessList when active.
  • Custom precompile API improvements (PrefixedHexString, getPrecompile, exported types), see PR #4261.

Amsterdam (experimental)

Behaviour may change in subsequent 10.1.x patch releases. Spec snapshot: tests-bal@v7.1.0 · Testnet: BAL devnet-7 Fork overview: Amsterdam hardfork (experimental)

EIP-8024 adds three stack-manipulation opcodes, each with a single-byte immediate (validated at decode — invalid immediates trap):

import { EVM } from '@ethereumjs/evm'
import { Common, Hardfork, Mainnet } from '@ethereumjs/common'

const common = new Common({ chain: Mainnet, hardfork: Hardfork.Amsterdam })
const evm = await EVM.create({ common })

// DUPN/SWAPN/EXCHANGE behave like extended DUP/SWAP variants;
// gas: dupnGas / swapnGas / exchangeGas (default 3 each)

When EIP-7928 is active, every state-touching operation appends to evm.blockLevelAccessList during runCall() / internal message execution. The VM reads this object after each transaction to build the block-level list. For BAL builder/validator flows see @​ethereumjs/vm.

Further Amsterdam notes: EIP-8024, EIP-7954, EIP-8037 / EIP-7708.

Changes

  • EIP-8024 stack opcodes, see PR #4248, #4302
  • EIP-7954 max contract and initcode size, see PR #4299
  • EIP-8037 state-gas accounting and reservoir logic, see PR #4285, #4293, #4301, #4304
  • EIP-7708 / EIP-7843 execution changes, see PR #4239, #4251, #4263, #4301
  • EIP-7928 BAL accumulation during message execution, see PR #4233, #4304
Commits

Updates `@ethereumjs/statemanager` from 10.1.1 to 10.1.2
Release notes

Sourced from @​ethereumjs/statemanager's releases.

@​ethereumjs/statemanager v10.1.2

Release round overview

Welcome to 10.1.2 — a coordinated release across all active @ethereumjs/* libraries on the 10.1.x line. If you have been following the upcoming Amsterdam hardfork, this is our first experimental preview ready to try out: a largely complete nine-EIP Hardfork.Amsterdam bundle, currently aligned with tests-bal@v7.1.0 and BAL devnet-7.

Amsterdam is still in flux — please do not use this in production yet — and we expect further 10.1.x releases as the spec and official tests evolve. The sections below cover this package only; for the full fork picture (EIP list, examples, release ↔ spec tracking), see the @​ethereumjs/vm Amsterdam overview. On Osaka or earlier hardforks? Nothing changes unless you explicitly select Hardfork.Amsterdam.

@ethereumjs/statemanager

@ethereumjs/statemanager is the state persistence abstraction the VM uses for account/storage reads and writes — whether backed by an in-memory trie (MerkleStateManager), a cached RPC view, or other implementations. Amsterdam increases how much state metadata is tracked during execution (BAL footprints, state-gas touches), but that tracking happens inside the EVM/VM; the state manager interface itself is unchanged in the 10.1.2 round. Debug logging across packages now uses @ethereumjs/util's isDebugEnabled() internally, see PR #4265.

Commits

Updates `@ethereumjs/util` from 10.1.1 to 10.1.2
Release notes

Sourced from @​ethereumjs/util's releases.

@​ethereumjs/util v10.1.2

Release round overview

Welcome to 10.1.2 — a coordinated release across all active @ethereumjs/* libraries on the 10.1.x line. If you have been following the upcoming Amsterdam hardfork, this is our first experimental preview ready to try out: a largely complete nine-EIP Hardfork.Amsterdam bundle, currently aligned with tests-bal@v7.1.0 and BAL devnet-7.

Amsterdam is still in flux — please do not use this in production yet — and we expect further 10.1.x releases as the spec and official tests evolve. The sections below cover this package only; for the full fork picture (EIP list, examples, release ↔ spec tracking), see the @​ethereumjs/vm Amsterdam overview. On Osaka or earlier hardforks? Nothing changes unless you explicitly select Hardfork.Amsterdam.

@ethereumjs/util

@ethereumjs/util is the shared toolbox the whole monorepo builds on — bytes, accounts, addresses, and fork-specific helpers that higher layers import rather than reimplement. Within the 10.1.2 round, the headline addition is a dedicated bal module for working with EIP-7928 Block Level Access Lists outside of live execution: parsing fixtures, validating structure, computing the header hash, and writing test tooling. The VM performs accumulation during runBlock(); Util gives you the portable data model and canonical encoding.

This package also picks up two cross-environment fixes: Account.isEmpty() for partial accounts, and a new isDebugEnabled() helper used across the monorepo for safe DEBUG checks in browsers and Web Workers.

At a glance

  • New bal module: BlockLevelAccessList, JSON/RLP factories, validation helpers, and hash() for offline BAL work.
  • Fix Account.isEmpty() when called on partial accounts (e.g. state trie reads), see PR #4268.
  • Add isDebugEnabled(namespace) — avoids ReferenceError in workers where process is undeclared, see PR #4265.

Amsterdam (experimental)

Behaviour may change in subsequent 10.1.x patch releases. Spec snapshot: tests-bal@v7.1.0 · Testnet: BAL devnet-7 Fork overview: Amsterdam hardfork (experimental)

Typical Util workflow: ingest a BAL JSON fixture (from a test vector or RPC payload), validate structure and gas-limit footprint, and verify the hash against an expected blockAccessListHash. Example from packages/util/examples/bal.ts:

import {
  bytesToHex,
  createBlockLevelAccessListFromJSON,
  validateBlockAccessListHashFromJSON,
  validateBlockAccessListStructure,
} from '@ethereumjs/util'

const balJson = [ { address: '0x0000000000000000000000000000000000000001', storageChanges: [], storageReads: [], balanceChanges: [{ blockAccessIndex: '0x01', postBalance: '0x03e8' }], nonceChanges: [], codeChanges: [], }, ]

const bal = createBlockLevelAccessListFromJSON(balJson) validateBlockAccessListStructure(bal) validateBlockAccessListHashFromJSON(balJson, bal.hash())

</tr></table>

... (truncated)

Commits

Updates `@ethereumjs/vm` from 10.1.1 to 10.1.2
Release notes

Sourced from @​ethereumjs/vm's releases.

@​ethereumjs/vm v10.1.2

Release round overview

Welcome to 10.1.2 — a coordinated release across all active @ethereumjs/* libraries on the 10.1.x line. If you have been following the upcoming Amsterdam hardfork, this is our first experimental preview ready to try out: a largely complete nine-EIP Hardfork.Amsterdam bundle, currently aligned with tests-bal@v7.1.0 and BAL devnet-7.

Amsterdam is still in flux — please do not use this in production yet — and we expect further 10.1.x releases as the spec and official tests evolve. The sections below cover this package only; for the full fork picture (EIP list, examples, release ↔ spec tracking), see the @​ethereumjs/vm Amsterdam overview. On Osaka or earlier hardforks? Nothing changes unless you explicitly select Hardfork.Amsterdam.

@ethereumjs/vm

@ethereumjs/vm is the block-and-transaction execution orchestrator: it wires state, the EVM, consensus checks, and receipts into the high-level APIs most integrators call (runBlock(), runTx(), createVM()). Within the 10.1.2 round, this package carries the bulk of Amsterdam execution logic — BAL builder/validator flows, two-dimensional block gas, and receipt-level fork behaviour — along with spec-alignment fixes and an internal runTx() refactor since 10.1.1.

At a glance

  • End-to-end Amsterdam block execution on Hardfork.Amsterdam: nine EIPs active together, matching how execution-spec-tests and devnets exercise the fork.
  • EIP-7928 Block Level Access Lists (BAL): generate during runBlock({ generate: true }), validate with RunBlockOpts.blockAccessList, read back via RunBlockResult.blockLevelAccessList.
  • EIP-8037 / EIP-7778 two-dimensional block gas and refund-aware header accounting — surfaced on RunTxResult without extra VM flags.
  • Passes the v700 mixed EST slice for tests-bal@v7.1.0.

Amsterdam (experimental)

Do not use in production. Spec alignment and public APIs may change in subsequent 10.1.x patch releases.

Spec snapshot tests-bal@v7.1.0
Testnet BAL devnet-7
Full overview Amsterdam hardfork (experimental)

When EIP-7928 is active the VM accumulates state accesses automatically during execution — no separate opt-in flag. For block building, run the block with generate: true and read the BAL from the result; the afterBlock event delivers a block whose header already carries the matching blockAccessListHash. For validation, pass a known BAL (JSON, RLP, or BlockLevelAccessList) via blockAccessList; the VM checks structure and hash before execution and compares against the list produced afterward.

Full walkthrough: packages/vm/examples/runBlockBalGenerate.ts and runBlockBalValidate.ts. The validator example below shows the round-trip pattern:

import { createBlock } from '@ethereumjs/block'
import { Common, Hardfork, Mainnet } from '@ethereumjs/common'
import { createLegacyTx } from '@ethereumjs/tx'
import { Account, createAddressFromPrivateKey, createZeroAddress,
hexToBytes } from '@ethereumjs/util'
import { createVM, runBlock } from '@ethereumjs/vm'

const common = new Common({ chain: Mainnet, hardfork: Hardfork.Amsterdam })
const vm = await createVM({ common })
// … fund sender, build block …

const generated = await runBlock(vm, { block, generate: true, skipBlockValidation: true })
const balJson = generated.blockLevelAccessList!.toJSON()

// Validator node: replay with the provided BAL
await runBlock(vm2, { block: sealedBlock, blockAccessList: balJson, skipBlockValidation: true })

... (truncated)

Commits

Updates `@types/node` from 25.9.1 to 25.9.3
Commits

Updates `@typescript-eslint/eslint-plugin` from 8.60.0 to 8.61.0
Release notes

Sourced from @​typescript-eslint/eslint-plugin's releases.

v8.61.0

8.61.0 (2026-06-08)

🚀 Features

  • ast-spec: change type of UnaryExpression.prefix to always true (#12372)
  • ast-spec: tighten types of ArrowFunction, YieldExpression, TSTypePredicate (#12373)

🩹 Fixes

  • rule-schema-to-typescript-types: respect ECMAScript line terminators (#12374)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

v8.60.1

8.60.1 (2026-06-01)

🩹 Fixes

  • eslint-plugin: respect ECMAScript line terminators in ts-comment rules (#12352)
  • eslint-plugin: [no-shadow] correct rule to match ESLint v10 handling (#12182)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Changelog

Sourced from @​typescript-eslint/eslint-plugin's changelog.

8.61.0 (2026-06-08)

🚀 Features

  • ast-spec: change type of UnaryExpression.prefix to always true (#12372)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

8.60.1 (2026-06-01)

🩹 Fixes

  • eslint-plugin: [no-shadow] correct rule to match ESLint v10 handling (#12182)
  • eslint-plugin: respect ECMAScript line terminators in ts-comment rules (#12352)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Commits
  • 16a5b24 chore(release): publish 8.61.0
  • ef1fd28 feat(ast-spec): change type of UnaryExpression.prefix to always true (#12...
  • 4f84a69 chore(release): publish 8.60.1
  • 598af56 docs(eslint-plugin): clarify no-redeclare type-value collision not covered by...
  • 1849b53 chore: typecheck using tsgo (#12139)
  • 5341d59 chore: fix lint issues (#12369)
  • f525814 fix(eslint-plugin): [no-shadow] correct rule to match ESLint v10 handling (#1...
  • 2df540c chore(eslint-plugin): defer type checks to improve rules performance (#12296)
  • 1ab4284 fix(eslint-plugin): respect ECMAScript line terminators in ts-comment rules (...
  • 2f49df5 docs: update references to @stylistic/eslint-plugin rules in documentation ...
  • See full diff in compare view

Updates `@typescript-eslint/parser` from 8.60.0 to 8.61.0
Release notes

Sourced from @​typescript-eslint/parser's releases.

v8.61.0

8.61.0 (2026-06-08)

🚀 Features

  • ast-spec: change type of UnaryExpression.prefix to always true (#12372)
  • ast-spec: tighten types of ArrowFunction, YieldExpression, TSTypePredicate (#12373)

🩹 Fixes

  • rule-schema-to-typescript-types: respect ECMAScript line terminators (#12374)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

v8.60.1

8.60.1 (2026-06-01)

🩹 Fixes

  • eslint-plugin: respect ECMAScript line terminators in ts-comment rules (#12352)
  • eslint-plugin: [no-shadow] correct rule to match ESLint v10 handling (#12182)

❤️ Thank You

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Changelog

Sourced from @​typescript-eslint/parser's changelog.

8.61.0 (2026-06-08)

This was a version bump only for parser to align it with other projects, there were no code changes.

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

8.60.1 (2026-06-01)

This was a version bump only for parser to align it with other projects, there were no code changes.

See GitHub Releases for more information.

You can read about our versioning strategy and releases on our website.

Commits

Updates `eslint` from 10.4.0 to 10.5.0
Release notes

Sourced from eslint's releases.

v10.5.0

Features

  • 5ca8c52 feat: correct stack tracking in max-nested-callbacks (#20973) (Pixel998)
  • b565783 feat: report no-with violations at the with keyword (#20971) (Pixel998)
  • 2ce032f feat: report max-lines-per-function violations at function head (#20966) (Pixel998)
  • 732cb3e feat: report max-nested-callbacks violations at function head (#20967) (Pixel998)
  • f9c138a feat: report max-depth violations on keywords (#20943) (Pixel998)
  • bdb496c feat... _Description has been truncated_ Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .../examples/app_sessions/package-lock.json | 12 +- sdk/ts/examples/app_sessions/package.json | 2 +- sdk/ts/examples/example-app/package-lock.json | 418 ++++++++---------- sdk/ts/examples/example-app/package.json | 18 +- sdk/ts/package-lock.json | 297 +++++++------ sdk/ts/package.json | 2 +- 6 files changed, 346 insertions(+), 403 deletions(-) diff --git a/sdk/ts/examples/app_sessions/package-lock.json b/sdk/ts/examples/app_sessions/package-lock.json index 672d38bdf..353cb4dc6 100644 --- a/sdk/ts/examples/app_sessions/package-lock.json +++ b/sdk/ts/examples/app_sessions/package-lock.json @@ -16,13 +16,13 @@ "devDependencies": { "@types/node": "^22.10.2", "@types/ws": "^8.5.13", - "tsx": "^4.22.3", + "tsx": "^4.22.4", "typescript": "^5.7.2" } }, "../..": { "name": "@yellow-org/sdk", - "version": "1.2.2", + "version": "1.3.1", "license": "MIT", "dependencies": { "abitype": "^1.2.3", @@ -51,7 +51,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "typescript": "^6.0.3", - "ws": "8.20.1" + "ws": "8.21.0" }, "engines": { "node": ">=20.0.0" @@ -740,9 +740,9 @@ } }, "node_modules/tsx": { - "version": "4.22.3", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", - "integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/sdk/ts/examples/app_sessions/package.json b/sdk/ts/examples/app_sessions/package.json index a6f304c97..4fac4c7fe 100644 --- a/sdk/ts/examples/app_sessions/package.json +++ b/sdk/ts/examples/app_sessions/package.json @@ -15,7 +15,7 @@ "devDependencies": { "@types/node": "^22.10.2", "@types/ws": "^8.5.13", - "tsx": "^4.22.3", + "tsx": "^4.22.4", "typescript": "^5.7.2" } } diff --git a/sdk/ts/examples/example-app/package-lock.json b/sdk/ts/examples/example-app/package-lock.json index 89949d795..456f84a86 100644 --- a/sdk/ts/examples/example-app/package-lock.json +++ b/sdk/ts/examples/example-app/package-lock.json @@ -8,33 +8,33 @@ "name": "nitrolite-example-app", "version": "1.0.0", "dependencies": { - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slot": "^1.2.5", "@yellow-org/sdk": "file:../..", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "decimal.js": "^10.4.3", - "lucide-react": "^1.16.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "lucide-react": "^1.18.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", "viem": "^2.50.4" }, "devDependencies": { - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.0", "postcss": "^8.5.15", - "tailwindcss": "^4.3.0", + "tailwindcss": "^4.3.1", "typescript": "^5.3.0", - "vite": "^8.0.14" + "vite": "^8.0.16" } }, "../..": { "name": "@yellow-org/sdk", - "version": "1.2.2", + "version": "1.3.1", "license": "MIT", "dependencies": { "abitype": "^1.2.3", @@ -110,14 +110,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -168,9 +168,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -178,26 +178,26 @@ } }, "node_modules/@radix-ui/primitive": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", - "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", "license": "MIT" }, "node_modules/@radix-ui/react-accordion": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", - "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.13.tgz", + "integrity": "sha512-xITxBB2p5m5tAe7M0F95kb4uAh7jSIKGlExMEm93HlW+XxZHV2eXFbPWLktd4JhRiwcnXNbO7iekcrbZy6ZCvA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collapsible": "1.1.12", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collapsible": "1.1.13", + "@radix-ui/react-collection": "1.1.9", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -215,19 +215,19 @@ } }, "node_modules/@radix-ui/react-collapsible": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", - "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.13.tgz", + "integrity": "sha512-F0s8+p2XNpfc3k02zBfB0jPWbkHVG162+p7BdUMyJ2308QMqZ+oaclX+FAzKFovgL5OqRU+Rvy6f/vbdlJVaqA==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-presence": "1.1.5", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -245,15 +245,15 @@ } }, "node_modules/@radix-ui/react-collection": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.9.tgz", + "integrity": "sha512-zuSVi7ziP7uQRqc+yGxsKJfNkdyHv3ZKDaHe0gzg4dRgws96TPKWIiz84tVHP4GEcEl8bC0mdt17NkcxaJHmaQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.5", + "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -270,28 +270,10 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -304,9 +286,9 @@ } }, "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -319,9 +301,9 @@ } }, "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -334,12 +316,12 @@ } }, "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -352,13 +334,12 @@ } }, "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -376,12 +357,12 @@ } }, "node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.5.tgz", + "integrity": "sha512-zifXeB8Y88qCYx8PLZ5oQb32KwZub+s925mMoZsBBq9KUQqWKkREubTfs6ASjRPPBe7Jt9O8OHH89+95VG+grA==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-slot": "1.2.5" }, "peerDependencies": { "@types/react": "*", @@ -398,54 +379,13 @@ } } }, - "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.9.tgz", + "integrity": "sha512-gvgW+JV/Mbjj6darztTetnmElpQEzZrXpJvfj+dOxNAxiyHEAyUvEjjl4zxblvmjmKmi3jfPoy7ZdxzCuUBJSA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", - "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-primitive": "2.1.5" }, "peerDependencies": { "@types/react": "*", @@ -463,12 +403,12 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.5.tgz", + "integrity": "sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-compose-refs": "1.1.3" }, "peerDependencies": { "@types/react": "*", @@ -481,13 +421,13 @@ } }, "node_modules/@radix-ui/react-use-controllable-state": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", - "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-effect-event": "0.0.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -500,12 +440,12 @@ } }, "node_modules/@radix-ui/react-use-effect-event": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", - "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", "license": "MIT", "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-layout-effect": "1.1.2" }, "peerDependencies": { "@types/react": "*", @@ -518,9 +458,9 @@ } }, "node_modules/@radix-ui/react-use-layout-effect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", - "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -533,9 +473,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -550,9 +490,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -567,9 +507,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -584,9 +524,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -601,9 +541,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -618,9 +558,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -635,9 +575,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -652,9 +592,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -669,9 +609,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -686,9 +626,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -703,9 +643,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -720,9 +660,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -737,9 +677,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -756,9 +696,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -773,9 +713,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -844,9 +784,9 @@ } }, "node_modules/@types/react": { - "version": "19.2.15", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", - "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1410,9 +1350,9 @@ } }, "node_modules/lucide-react": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", - "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.18.0.tgz", + "integrity": "sha512-LZDb7H/0YfM+RJncD0hDQRCAu+vSGODqpe35TuVI8EuXaRjkczbsx7p8dY4J87F/MUSj6bpYqeI8nw8qXaAdmA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1531,34 +1471,34 @@ "license": "MIT" }, "node_modules/react": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", - "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", - "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.6" + "react": "^19.2.7" } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1568,21 +1508,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/scheduler": { @@ -1612,16 +1552,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", - "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", "dev": true, "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -1719,17 +1659,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/sdk/ts/examples/example-app/package.json b/sdk/ts/examples/example-app/package.json index 961443e95..e811dbd92 100644 --- a/sdk/ts/examples/example-app/package.json +++ b/sdk/ts/examples/example-app/package.json @@ -11,26 +11,26 @@ }, "dependencies": { "@yellow-org/sdk": "file:../..", - "@radix-ui/react-accordion": "^1.2.12", - "@radix-ui/react-separator": "^1.1.8", - "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-accordion": "^1.2.13", + "@radix-ui/react-separator": "^1.1.9", + "@radix-ui/react-slot": "^1.2.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "decimal.js": "^10.4.3", - "lucide-react": "^1.16.0", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "lucide-react": "^1.18.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", "tailwind-merge": "^3.6.0", "viem": "^2.50.4" }, "devDependencies": { - "@types/react": "^19.2.15", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.5.0", "postcss": "^8.5.15", - "tailwindcss": "^4.3.0", + "tailwindcss": "^4.3.1", "typescript": "^5.3.0", - "vite": "^8.0.14" + "vite": "^8.0.16" } } diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index 9d0999187..04d32c61c 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -30,7 +30,7 @@ "ethers": "6.16.0", "glob": "^13.0.3", "jest": "^30.2.0", - "prettier": "3.8.3", + "prettier": "3.8.4", "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", @@ -741,9 +741,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", - "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -755,14 +755,14 @@ } }, "node_modules/@ethereumjs/binarytree": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/binarytree/-/binarytree-10.1.1.tgz", - "integrity": "sha512-vcU+cDgWJ0YNrJb5gWcKWfMpYJg4W+8benAds7QQo2ayTYZbuMqju3Fq7m5LNG+xqkdF0a901QDbT5xaB7rJLQ==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/binarytree/-/binarytree-10.1.2.tgz", + "integrity": "sha512-szugxzYrQHRvlhbu0BrcW/Kw7tg3K0b1HbKCdwRrFhvz80RbGfML2wqDDZhO5NyvHqWpS+Rt9q+qV9LxOHCP6w==", "dev": true, "license": "MIT", "dependencies": { - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@noble/hashes": "^2.0.1", "debug": "^4.4.0", "lru-cache": "11.0.2" @@ -782,17 +782,17 @@ } }, "node_modules/@ethereumjs/block": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/block/-/block-10.1.1.tgz", - "integrity": "sha512-nzQ07KGdujKQPxnPlZ86ppJ/u1t3N/PQ4RpGoPb/LI9DoqulyMr90j5NzkjQNtnvBQc8WbhPffi44yC3Yso+rQ==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/block/-/block-10.1.2.tgz", + "integrity": "sha512-VNH6sRKsHacsOFiizeY98L6eQhMw7bRavbacUUwcFIXe6IgeKLT0y3QCcS55dJGJDGtb/TsJnas1dt411K2Vzw==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/common": "^10.1.1", - "@ethereumjs/mpt": "^10.1.1", - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/tx": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/mpt": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/tx": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1" }, @@ -817,17 +817,17 @@ } }, "node_modules/@ethereumjs/blockchain": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/blockchain/-/blockchain-10.1.1.tgz", - "integrity": "sha512-+zZtO8NDm9mx8PrZN+bWphc+mGW0doeHjUhhJNHfXyLWZAe4uo8J8NsRDeCOgxLHtlu7hjgIX1Gcnt3t3/VSJA==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/blockchain/-/blockchain-10.1.2.tgz", + "integrity": "sha512-fyLGOekCWYaYYT9Zf0SYuNwz9zRhxgHbCU0c+Vr1QqBt7VMn8GsHYg5s5GVPBYYqqbpBoJOzqBfE/fnMjBmRcQ==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/block": "^10.1.1", - "@ethereumjs/common": "^10.1.1", - "@ethereumjs/mpt": "^10.1.1", - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/block": "^10.1.2", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/mpt": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "debug": "^4.4.0", "eventemitter3": "^5.0.1", "lru-cache": "11.0.2" @@ -847,27 +847,27 @@ } }, "node_modules/@ethereumjs/common": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-10.1.1.tgz", - "integrity": "sha512-NefPzPlrJ9w+NWVe06P+sHZQU98E1AEU9vhiHJEVT2wEcNBC1YX6hON9+smrfbn86C4U1pb2zbvjhkF+n/LKBw==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-10.1.2.tgz", + "integrity": "sha512-whWnhqAxwpDy4zWkM6rqMzb8nioZJpiys01N57+HDyanvK5IRzodV5tdMRDt66PD5vDjl2c9K5UcB039gU2Oyw==", "dev": true, "license": "MIT", "dependencies": { - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/util": "^10.1.2", "eventemitter3": "^5.0.1" } }, "node_modules/@ethereumjs/evm": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/evm/-/evm-10.1.1.tgz", - "integrity": "sha512-L6mVKbEe1bvvxWilUWfzkkYb+5bCWIlq32Lzuv4MJf4MNzZZ++P72/WIsKoLntbxWEuZDcYXGwHhmM9pfbu1BA==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/evm/-/evm-10.1.2.tgz", + "integrity": "sha512-yIslek2pcsUQwBJFeeamN529hbNYBtrSuneSCMn4AM71WAx8vntgDbzYoAJsG/whyB9wOVvzHXubju1JXmaEag==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/binarytree": "^10.1.1", - "@ethereumjs/common": "^10.1.1", - "@ethereumjs/statemanager": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/binarytree": "^10.1.2", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/statemanager": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "debug": "^4.4.0", @@ -894,14 +894,14 @@ } }, "node_modules/@ethereumjs/mpt": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/mpt/-/mpt-10.1.1.tgz", - "integrity": "sha512-Kh1vNhUwSIyuehOJk9Hqxzgu8D5B2L3jidKVaH4VB/dSo/6mVA0IKQ312c0ophXY8uU4f8NOTW602FJe6Ezaag==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/mpt/-/mpt-10.1.2.tgz", + "integrity": "sha512-dBlXpkP1ssp+AcUxsJUrY72LZuE1JQEp1AZn5mgmbBcd2Gwkpyi57q4zATlZbxrZUxp/K9UIidgqxQWcOCbo5g==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@noble/hashes": "^2.0.1", "debug": "^4.4.0", "lru-cache": "11.0.2" @@ -921,9 +921,9 @@ } }, "node_modules/@ethereumjs/rlp": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-10.1.1.tgz", - "integrity": "sha512-jbnWTEwcpoY+gE0r+wxfDG9zgiu54DcTcwnc9sX3DsqKR4l5K7x2V8mQL3Et6hURa4DuT9g7z6ukwpBLFchszg==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-10.1.2.tgz", + "integrity": "sha512-T5Zt6C2pd02Wd88Q9A5/UX+He1Q2Y1LntHxz/038tfbUMiqby4fYSSTLEDx+TEfJqw1BsJSBY/TSu6goUzlk+w==", "dev": true, "license": "MPL-2.0", "bin": { @@ -934,17 +934,17 @@ } }, "node_modules/@ethereumjs/statemanager": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/statemanager/-/statemanager-10.1.1.tgz", - "integrity": "sha512-V7BZhRP8lcP9A1cVclTPWvqyVUmGDcISPYXBFyLVvQQnzTydG8MdSRB+uUuGA0gAyB+jfSJIqkbYNR1U8R7t9Q==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/statemanager/-/statemanager-10.1.2.tgz", + "integrity": "sha512-Pkm9VHF19wxT5c0jB+IDiQBv4W42o0wYSS1NLXeRea0D6CDBu0CLo9ihhnQgM368zXPl6JRbLS+YiG8tJkz4aA==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/binarytree": "^10.1.1", - "@ethereumjs/common": "^10.1.1", - "@ethereumjs/mpt": "^10.1.1", - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/binarytree": "^10.1.2", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/mpt": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@js-sdsl/ordered-map": "^4.4.2", "@noble/hashes": "^2.0.1", "debug": "^4.4.0", @@ -962,15 +962,15 @@ } }, "node_modules/@ethereumjs/tx": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/tx/-/tx-10.1.1.tgz", - "integrity": "sha512-Kz8GWIKQjEQB60ko9hsYDX3rZMHZZOTcmm6OFl855Lu3padVnf5ZactUKM6nmWPsumHED5bWDjO32novZd1zyw==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/tx/-/tx-10.1.2.tgz", + "integrity": "sha512-bAYK3YaYkk+auzxGfZSRVDbLHvboJNTx8/tV6jaqgPVlrA1QKEEADDEp/EGz+KI4NQmTGxEtXZ8tV/WjniRNww==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/common": "^10.1.1", - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1" }, @@ -995,13 +995,13 @@ } }, "node_modules/@ethereumjs/util": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-10.1.1.tgz", - "integrity": "sha512-r2EhaeEmLZXVs1dT2HJFQysAkr63ZWATu/9tgYSp1IlvjvwyC++DLg5kCDwMM49HBq3sOAhrPnXkoqf9DV2gbw==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-10.1.2.tgz", + "integrity": "sha512-UPBgXtHHfQugoXOSAoeG3jdmPbl37cwV9y3XqTPAnw8tJj8np14TPV2uc5lOs7C2LMF9Ubn66zyaiYxgwGppng==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/rlp": "^10.1.1", + "@ethereumjs/rlp": "^10.1.2", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1" }, @@ -1026,20 +1026,20 @@ } }, "node_modules/@ethereumjs/vm": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@ethereumjs/vm/-/vm-10.1.1.tgz", - "integrity": "sha512-OFr7+3pHgkzTAIXTBXr5UwerKJhrwcQEV/vHrd9jtnPVSdkZQKCttTWXwA/lqEcOGlNVenTLcWNYasdmXOU9lg==", + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/@ethereumjs/vm/-/vm-10.1.2.tgz", + "integrity": "sha512-7GwDX2Sq8szLDwb1Y8zDFNBJrekdVMuS/8VYcxf1DCAPO2yp677vIoip8+Ge/J5uK3dq0eTGb8gTPvZvOdRZ/Q==", "dev": true, "license": "MPL-2.0", "dependencies": { - "@ethereumjs/block": "^10.1.1", - "@ethereumjs/common": "^10.1.1", - "@ethereumjs/evm": "^10.1.1", - "@ethereumjs/mpt": "^10.1.1", - "@ethereumjs/rlp": "^10.1.1", - "@ethereumjs/statemanager": "^10.1.1", - "@ethereumjs/tx": "^10.1.1", - "@ethereumjs/util": "^10.1.1", + "@ethereumjs/block": "^10.1.2", + "@ethereumjs/common": "^10.1.2", + "@ethereumjs/evm": "^10.1.2", + "@ethereumjs/mpt": "^10.1.2", + "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/statemanager": "^10.1.2", + "@ethereumjs/tx": "^10.1.2", + "@ethereumjs/util": "^10.1.2", "@noble/hashes": "^2.0.1", "debug": "^4.4.0", "eventemitter3": "^5.0.1" @@ -2068,9 +2068,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.9.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", - "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -2099,17 +2099,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", - "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", + "integrity": "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/type-utils": "8.60.0", - "@typescript-eslint/utils": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/type-utils": "8.61.0", + "@typescript-eslint/utils": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -2122,7 +2122,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.60.0", + "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -2138,16 +2138,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", - "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.0.tgz", + "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3" }, "engines": { @@ -2163,14 +2163,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", - "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.0.tgz", + "integrity": "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.60.0", - "@typescript-eslint/types": "^8.60.0", + "@typescript-eslint/tsconfig-utils": "^8.61.0", + "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "engines": { @@ -2185,14 +2185,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", - "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz", + "integrity": "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0" + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2203,9 +2203,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", - "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.0.tgz", + "integrity": "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==", "dev": true, "license": "MIT", "engines": { @@ -2220,15 +2220,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", - "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz", + "integrity": "sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0", - "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0", + "@typescript-eslint/utils": "8.61.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -2245,9 +2245,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", - "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.0.tgz", + "integrity": "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==", "dev": true, "license": "MIT", "engines": { @@ -2259,16 +2259,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", - "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz", + "integrity": "sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.60.0", - "@typescript-eslint/tsconfig-utils": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/visitor-keys": "8.60.0", + "@typescript-eslint/project-service": "8.61.0", + "@typescript-eslint/tsconfig-utils": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/visitor-keys": "8.61.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -2326,16 +2326,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", - "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.0.tgz", + "integrity": "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.60.0", - "@typescript-eslint/types": "8.60.0", - "@typescript-eslint/typescript-estree": "8.60.0" + "@typescript-eslint/scope-manager": "8.61.0", + "@typescript-eslint/types": "8.61.0", + "@typescript-eslint/typescript-estree": "8.61.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2350,13 +2350,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.60.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", - "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "version": "8.61.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz", + "integrity": "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -3377,18 +3377,21 @@ } }, "node_modules/eslint": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", - "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", - "@eslint/plugin-kit": "^0.7.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -5333,9 +5336,9 @@ } }, "node_modules/ox": { - "version": "0.14.25", - "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.25.tgz", - "integrity": "sha512-8DoibKtxE8yw63Y2jjMhlbjaURev6WCx4QR4MWLusl2/qIaeTzMJMBIYIDl1KOF45+8H1Ur6eLTdPlUoO8PlRw==", + "version": "0.14.29", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.29.tgz", + "integrity": "sha512-M5j87Ec4V99MQdRct/g09eWXW60g6zhHTUs1lr4deUtrPDnezBdCJTgKd7pxqTpSZBFveV0ALi9jMMuT1qKyNg==", "funding": [ { "type": "github", @@ -5620,9 +5623,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -6035,9 +6038,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -6360,9 +6363,9 @@ } }, "node_modules/viem": { - "version": "2.51.2", - "resolved": "https://registry.npmjs.org/viem/-/viem-2.51.2.tgz", - "integrity": "sha512-2x4YAtr3PUPIW++Ov96clnWtRsyqMfpFfooQRIxCpAMsTgxioJTdIQ0ywbjhlHDCUJEGM6M8q8ILOeaPRViH9w==", + "version": "2.52.2", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.52.2.tgz", + "integrity": "sha512-HSU12p5aD/kAPZfrlbCUqdiP4P/c6hQ9AhfTS51VbLUQIjkWd1d5EjrCx/SCxZ0zhZVRn4Iv5X5WDqXPG8Ubew==", "funding": [ { "type": "github", @@ -6377,7 +6380,7 @@ "@scure/bip39": "1.6.0", "abitype": "1.2.3", "isows": "1.0.7", - "ox": "0.14.25", + "ox": "0.14.29", "ws": "8.20.1" }, "peerDependencies": { diff --git a/sdk/ts/package.json b/sdk/ts/package.json index 360c37427..b56b78dad 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -82,7 +82,7 @@ "ethers": "6.16.0", "glob": "^13.0.3", "jest": "^30.2.0", - "prettier": "3.8.3", + "prettier": "3.8.4", "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", From deed7ae95f033ae664c2ec246537e9ce6bfd2ff9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:44:58 +0200 Subject: [PATCH 09/11] chore(deps): bump the npm_and_yarn group across 2 directories with 2 updates (#847) --- playground/package-lock.json | 184 +++++++++++++----------- playground/package.json | 2 +- sdk/ts-compat/package-lock.json | 246 ++++++++++++++------------------ 3 files changed, 213 insertions(+), 219 deletions(-) diff --git a/playground/package-lock.json b/playground/package-lock.json index da43c0f70..496141453 100644 --- a/playground/package-lock.json +++ b/playground/package-lock.json @@ -24,18 +24,18 @@ "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.6.0", - "vite": "^8.0.14" + "vite": "^8.0.16" } }, "../sdk/ts": { "name": "@yellow-org/sdk", - "version": "1.2.2", + "version": "1.3.1", "license": "MIT", "dependencies": { "abitype": "^1.2.3", "decimal.js": "^10.4.3", "jest-util": "^30.3.0", - "viem": "^2.46.1", + "viem": "^2.50.4", "zod": "^4.3.6" }, "devDependencies": { @@ -58,7 +58,7 @@ "ts-jest": "^29.1.2", "ts-node": "^10.9.2", "typescript": "^6.0.3", - "ws": "8.18.3" + "ws": "8.21.0" }, "engines": { "node": ">=20.0.0" @@ -157,14 +157,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -253,9 +253,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.132.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", - "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -263,9 +263,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", - "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -280,9 +280,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", - "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -297,9 +297,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", - "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -314,9 +314,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", - "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -331,9 +331,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", - "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -348,13 +348,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", - "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -365,13 +368,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", - "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -382,13 +388,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", - "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -399,13 +408,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", - "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -416,13 +428,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", - "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -433,13 +448,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", - "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -450,9 +468,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", - "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -467,9 +485,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", - "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -486,9 +504,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", - "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -503,9 +521,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", - "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1873,13 +1891,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", - "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.132.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -1889,21 +1907,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.2", - "@rolldown/binding-darwin-arm64": "1.0.2", - "@rolldown/binding-darwin-x64": "1.0.2", - "@rolldown/binding-freebsd-x64": "1.0.2", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", - "@rolldown/binding-linux-arm64-gnu": "1.0.2", - "@rolldown/binding-linux-arm64-musl": "1.0.2", - "@rolldown/binding-linux-ppc64-gnu": "1.0.2", - "@rolldown/binding-linux-s390x-gnu": "1.0.2", - "@rolldown/binding-linux-x64-gnu": "1.0.2", - "@rolldown/binding-linux-x64-musl": "1.0.2", - "@rolldown/binding-openharmony-arm64": "1.0.2", - "@rolldown/binding-wasm32-wasi": "1.0.2", - "@rolldown/binding-win32-arm64-msvc": "1.0.2", - "@rolldown/binding-win32-x64-msvc": "1.0.2" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/run-parallel": { @@ -2054,9 +2072,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -2212,17 +2230,17 @@ } }, "node_modules/vite": { - "version": "8.0.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", - "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", - "rolldown": "1.0.2", - "tinyglobby": "^0.2.16" + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/playground/package.json b/playground/package.json index 78a0b9849..18a5d4f0f 100644 --- a/playground/package.json +++ b/playground/package.json @@ -27,6 +27,6 @@ "postcss": "^8.4.49", "tailwindcss": "^3.4.17", "typescript": "^5.6.0", - "vite": "^8.0.14" + "vite": "^8.0.16" } } diff --git a/sdk/ts-compat/package-lock.json b/sdk/ts-compat/package-lock.json index 8786ea81e..7ea722496 100644 --- a/sdk/ts-compat/package-lock.json +++ b/sdk/ts-compat/package-lock.json @@ -628,9 +628,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", "cpu": [ "ppc64" ], @@ -645,9 +645,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", "cpu": [ "arm" ], @@ -662,9 +662,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", "cpu": [ "arm64" ], @@ -679,9 +679,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", "cpu": [ "x64" ], @@ -696,9 +696,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", "cpu": [ "arm64" ], @@ -713,9 +713,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", "cpu": [ "x64" ], @@ -730,9 +730,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", "cpu": [ "arm64" ], @@ -747,9 +747,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", "cpu": [ "x64" ], @@ -764,9 +764,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", "cpu": [ "arm" ], @@ -781,9 +781,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", "cpu": [ "arm64" ], @@ -798,9 +798,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", "cpu": [ "ia32" ], @@ -815,9 +815,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", "cpu": [ "loong64" ], @@ -832,9 +832,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", "cpu": [ "mips64el" ], @@ -849,9 +849,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", "cpu": [ "ppc64" ], @@ -866,9 +866,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", "cpu": [ "riscv64" ], @@ -883,9 +883,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", "cpu": [ "s390x" ], @@ -900,9 +900,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", "cpu": [ "x64" ], @@ -917,9 +917,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", "cpu": [ "arm64" ], @@ -934,9 +934,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", "cpu": [ "x64" ], @@ -951,9 +951,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", "cpu": [ "arm64" ], @@ -968,9 +968,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", "cpu": [ "x64" ], @@ -985,9 +985,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", "cpu": [ "arm64" ], @@ -1002,9 +1002,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", "cpu": [ "x64" ], @@ -1019,9 +1019,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", "cpu": [ "arm64" ], @@ -1036,9 +1036,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", "cpu": [ "ia32" ], @@ -1053,9 +1053,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", "cpu": [ "x64" ], @@ -3217,9 +3217,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3230,32 +3230,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" } }, "node_modules/escalade": { @@ -3731,19 +3731,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.13.6", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", - "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -5389,16 +5376,6 @@ "node": ">=8" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -5892,14 +5869,13 @@ "optional": true }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" From 37124a6ccfdb11469331f2ef7bcdff0fdb669a4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:53:09 +0200 Subject: [PATCH 10/11] chore(npm): bump vite from 8.0.14 to 8.0.16 in /sdk/ts/examples/example-app (#841) --- sdk/ts/examples/example-app/package-lock.json | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/sdk/ts/examples/example-app/package-lock.json b/sdk/ts/examples/example-app/package-lock.json index 456f84a86..65761f691 100644 --- a/sdk/ts/examples/example-app/package-lock.json +++ b/sdk/ts/examples/example-app/package-lock.json @@ -565,6 +565,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -582,6 +585,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -599,6 +605,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -616,6 +625,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -633,6 +645,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -650,6 +665,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ From b158c80dfd9ba94113515d483f7b26e5ced8fb71 Mon Sep 17 00:00:00 2001 From: Anton Filonenko Date: Thu, 18 Jun 2026 11:57:46 +0300 Subject: [PATCH 11/11] chore(release): bump version to v1.4.0 Bump npm package versions (sdk/ts, sdk/ts-compat, sdk/mcp), MCP server.json, and Helm chart appVersions (nitronode, playground, faucet-app) to 1.4.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- faucet-app/chart/Chart.yaml | 2 +- nitronode/chart/Chart.yaml | 2 +- playground/chart/Chart.yaml | 2 +- sdk/mcp/package-lock.json | 4 ++-- sdk/mcp/package.json | 2 +- sdk/mcp/server.json | 4 ++-- sdk/ts-compat/package-lock.json | 8 ++++---- sdk/ts-compat/package.json | 2 +- sdk/ts/package-lock.json | 4 ++-- sdk/ts/package.json | 2 +- 10 files changed, 16 insertions(+), 16 deletions(-) diff --git a/faucet-app/chart/Chart.yaml b/faucet-app/chart/Chart.yaml index b2a6968d7..e3c0421d4 100644 --- a/faucet-app/chart/Chart.yaml +++ b/faucet-app/chart/Chart.yaml @@ -3,4 +3,4 @@ apiVersion: v2 description: Faucet App Helm chart name: faucet-app version: 1.0.0 -appVersion: "1.3.0" +appVersion: "1.4.0" diff --git a/nitronode/chart/Chart.yaml b/nitronode/chart/Chart.yaml index af080548a..6ce400eeb 100644 --- a/nitronode/chart/Chart.yaml +++ b/nitronode/chart/Chart.yaml @@ -3,4 +3,4 @@ apiVersion: v2 description: Nitronode Helm chart name: nitronode version: 1.0.0 -appVersion: "1.3.0" +appVersion: "1.4.0" diff --git a/playground/chart/Chart.yaml b/playground/chart/Chart.yaml index 70416ba96..7a3276b7d 100644 --- a/playground/chart/Chart.yaml +++ b/playground/chart/Chart.yaml @@ -3,4 +3,4 @@ apiVersion: v2 description: Nitrolite Playground Helm chart name: playground version: 1.0.0 -appVersion: "1.3.0" +appVersion: "1.4.0" diff --git a/sdk/mcp/package-lock.json b/sdk/mcp/package-lock.json index 0cfb24bb3..7ed47a12f 100644 --- a/sdk/mcp/package-lock.json +++ b/sdk/mcp/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yellow-org/sdk-mcp", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yellow-org/sdk-mcp", - "version": "1.3.1", + "version": "1.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/sdk/mcp/package.json b/sdk/mcp/package.json index ec51b6d13..d6fc6be84 100644 --- a/sdk/mcp/package.json +++ b/sdk/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@yellow-org/sdk-mcp", - "version": "1.3.1", + "version": "1.4.0", "description": "Unified MCP server for Yellow SDK and Nitrolite protocol context for AI agents and IDEs", "type": "module", "mcpName": "io.github.layer-3/yellow-sdk-mcp", diff --git a/sdk/mcp/server.json b/sdk/mcp/server.json index 56f23a7d6..9b2379386 100644 --- a/sdk/mcp/server.json +++ b/sdk/mcp/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.layer-3/yellow-sdk-mcp", "description": "MCP server exposing Yellow SDK and Nitrolite protocol reference material to AI agents and IDEs.", - "version": "1.3.1", + "version": "1.4.0", "repository": { "url": "https://github.com/layer-3/nitrolite", "source": "github" @@ -11,7 +11,7 @@ { "registryType": "npm", "identifier": "@yellow-org/sdk-mcp", - "version": "1.3.1", + "version": "1.4.0", "transport": { "type": "stdio" } diff --git a/sdk/ts-compat/package-lock.json b/sdk/ts-compat/package-lock.json index 7ea722496..7960cda31 100644 --- a/sdk/ts-compat/package-lock.json +++ b/sdk/ts-compat/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yellow-org/sdk-compat", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yellow-org/sdk-compat", - "version": "1.3.1", + "version": "1.4.0", "license": "MIT", "dependencies": { "decimal.js": "^10.4.3" @@ -34,7 +34,7 @@ }, "../ts": { "name": "@yellow-org/sdk", - "version": "1.3.1", + "version": "1.4.0", "dev": true, "license": "MIT", "dependencies": { @@ -59,7 +59,7 @@ "ethers": "6.16.0", "glob": "^13.0.3", "jest": "^30.2.0", - "prettier": "3.8.3", + "prettier": "3.8.4", "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", diff --git a/sdk/ts-compat/package.json b/sdk/ts-compat/package.json index 64a12efaa..dae04c4d4 100644 --- a/sdk/ts-compat/package.json +++ b/sdk/ts-compat/package.json @@ -1,6 +1,6 @@ { "name": "@yellow-org/sdk-compat", - "version": "1.3.1", + "version": "1.4.0", "description": "Curated migration layer preserving selected Nitrolite SDK v0.5.3 app-facing APIs over the v1 runtime.", "type": "module", "sideEffects": false, diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index 04d32c61c..18cd9a4ee 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -1,12 +1,12 @@ { "name": "@yellow-org/sdk", - "version": "1.3.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@yellow-org/sdk", - "version": "1.3.1", + "version": "1.4.0", "license": "MIT", "dependencies": { "abitype": "^1.2.3", diff --git a/sdk/ts/package.json b/sdk/ts/package.json index b56b78dad..803eed3ca 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -1,6 +1,6 @@ { "name": "@yellow-org/sdk", - "version": "1.3.1", + "version": "1.4.0", "description": "The Yellow SDK empowers developers to build high-performance, scalable web3 applications using state channels. It's designed to provide near-instant transactions and significantly improved user experiences by minimizing direct blockchain interactions.", "type": "module", "main": "dist/index.js",