From be5aec7a75278406a6334f6f72e653f21eef5627 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 17:04:37 +0530 Subject: [PATCH 01/11] Add TS SDK protocol drift guards --- .github/workflows/main-pr.yml | 32 ++++ .github/workflows/main-push.yml | 32 ++++ scripts/check-protocol-drift.sh | 55 ++++++ sdk/PROTOCOL_DRIFT_GUARDS.md | 62 +++++++ sdk/ts-compat/package.json | 1 + .../public-api-drift.test.ts.snap | 60 ++++++ sdk/ts-compat/test/unit/client.test.ts | 9 + .../test/unit/public-api-drift.test.ts | 14 ++ sdk/ts/package.json | 1 + sdk/ts/src/blockchain/evm/channel_hub_abi.ts | 11 +- sdk/ts/src/utils.ts | 2 +- .../public-api-drift.test.ts.snap | 171 +++++++++++++++++ sdk/ts/test/unit/abi-drift.test.ts | 98 ++++++++++ sdk/ts/test/unit/app-signing-drift.test.ts | 89 +++++++++ sdk/ts/test/unit/public-api-drift.test.ts | 14 ++ sdk/ts/test/unit/rpc-drift.test.ts | 85 +++++++++ sdk/ts/test/unit/rpc-dto-drift.test.ts | 173 ++++++++++++++++++ sdk/ts/test/unit/transform-drift.test.ts | 144 +++++++++++++++ 18 files changed, 1049 insertions(+), 4 deletions(-) create mode 100755 scripts/check-protocol-drift.sh create mode 100644 sdk/PROTOCOL_DRIFT_GUARDS.md create mode 100644 sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap create mode 100644 sdk/ts-compat/test/unit/public-api-drift.test.ts create mode 100644 sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap create mode 100644 sdk/ts/test/unit/abi-drift.test.ts create mode 100644 sdk/ts/test/unit/app-signing-drift.test.ts create mode 100644 sdk/ts/test/unit/public-api-drift.test.ts create mode 100644 sdk/ts/test/unit/rpc-dto-drift.test.ts create mode 100644 sdk/ts/test/unit/transform-drift.test.ts diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml index e6aa01213..ad882bc9a 100644 --- a/.github/workflows/main-pr.yml +++ b/.github/workflows/main-pr.yml @@ -31,6 +31,38 @@ jobs: project-name: 'TS SDK Compat' bootstrap-project-path: 'sdk/ts' + test-protocol-drift-static: + name: Test (Protocol Drift Static) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run static protocol drift checks + run: ./scripts/check-protocol-drift.sh --static + build-and-publish-nitronode: name: Build and Publish (Nitronode) needs: test-nitronode diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml index d084702f0..034ec7126 100644 --- a/.github/workflows/main-push.yml +++ b/.github/workflows/main-push.yml @@ -31,6 +31,38 @@ jobs: project-name: 'TS SDK Compat' bootstrap-project-path: 'sdk/ts' + test-protocol-drift-static: + name: Test (Protocol Drift Static) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run static protocol drift checks + run: ./scripts/check-protocol-drift.sh --static + # build-and-publish-sdk: # needs: [test-sdk-ts, test-sdk-compat] # name: Build and Publish (SDK) diff --git a/scripts/check-protocol-drift.sh b/scripts/check-protocol-drift.sh new file mode 100755 index 000000000..e78b75a2e --- /dev/null +++ b/scripts/check-protocol-drift.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'USAGE' +Usage: scripts/check-protocol-drift.sh [--static|--runtime] + + --static Run deterministic protocol/SDK/compat drift checks. + --runtime Run runtime smoke checks against an ephemeral/local Clearnode. + +Runtime smoke is intentionally not implemented in the static runner yet; CI will +wire it once the ephemeral Clearnode harness lands. +USAGE +} + +run_package() { + local package_path="$1" + local command_name="$2" + local full_path="$ROOT/$package_path" + + if [[ ! -d "$full_path" ]]; then + echo "::error::drift check package path does not exist: $package_path" >&2 + return 1 + fi + + echo + echo "==> $package_path: npm run $command_name" + ( + cd "$full_path" + npm run "$command_name" + ) +} + +mode="${1:---static}" + +case "$mode" in + --static) + echo "==> Running deterministic Nitrolite protocol drift checks" + run_package "sdk/ts" "drift:check" + run_package "sdk/ts-compat" "drift:check" + ;; + --runtime) + echo "::error::runtime protocol drift smoke is not implemented yet; use --static for deterministic checks" >&2 + exit 2 + ;; + -h|--help) + usage + ;; + *) + usage >&2 + exit 2 + ;; +esac diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md new file mode 100644 index 000000000..ac4c8a492 --- /dev/null +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -0,0 +1,62 @@ +# Protocol Drift Guards + +This repo has deterministic drift checks for protocol, `@yellow-org/sdk`, and `@yellow-org/sdk-compat` surfaces that demo apps depend on. + +## Commands + +Run all implemented static checks from the repo root: + +```bash +./scripts/check-protocol-drift.sh --static +``` + +Run package checks directly: + +```bash +cd sdk/ts && npm run drift:check +cd sdk/ts-compat && npm run drift:check +``` + +`./scripts/check-protocol-drift.sh --runtime` is reserved for the future ephemeral Clearnode smoke harness. It intentionally fails until that harness exists. + +## Guard Layers + +- RPC method drift: compares Go RPC method literals, Clearnode 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 runtime API drift: snapshots root runtime exports for `@yellow-org/sdk` and `@yellow-org/sdk-compat`. +- ABI drift: compares SDK-consumed `ChannelHub` ABI functions against the current Foundry artifact. +- Signing drift: compares TS app-session packers against Go-generated canonical vectors. +- Transform drift: checks raw Clearnode 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()`. + +## Intentional Updates + +For intentional public runtime API changes, update snapshots with: + +```bash +cd sdk/ts && npm run drift:check -- -u +cd sdk/ts-compat && npm run drift:check -- -u +``` + +For intentional ABI changes, regenerate artifacts and SDK ABI files before running drift checks: + +```bash +cd contracts && forge build +cd ../sdk/ts && npm run codegen-abi +``` + +For a new RPC method, update all applicable surfaces in the same PR: Go method constants, router registration, TS method constants, and the public TS client wrapper unless the method is intentionally raw-only. + +For a new DTO field, update the Go JSON-tagged struct and TS request/response interface together. Optionality must match unless a small, named override is added to the drift test. + +For a new response transform, add a raw fixture and expected behavior test. Unsupported wire shapes should fail clearly instead of silently producing partial data. + +## Adversarial Proof + +Each guard includes at least one negative test or mutation-style check that proves the guard would fail if the relevant surface drifted. These checks must use fixtures, temp copies, or local in-test mutations. They must not leave tracked files dirty. + +## CI Policy + +`Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Clearnode deployments. + +Shared stress Clearnode checks are manual/nightly only. `wss://clearnode-stress.yellow.org/v1/ws` can be newer than sandbox while audit remediations roll out, so it is useful for release confidence but must not be a default PR blocker. diff --git a/sdk/ts-compat/package.json b/sdk/ts-compat/package.json index 75b03499c..78d687d40 100644 --- a/sdk/ts-compat/package.json +++ b/sdk/ts-compat/package.json @@ -15,6 +15,7 @@ "build:ci": "tsc", "build:prod": "tsc -p tsconfig.prod.json", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", + "drift:check": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs --runTestsByPath test/unit/client.test.ts test/unit/config.test.ts test/unit/public-api-drift.test.ts", "lint": "eslint src test", "typecheck": "tsc --noEmit", "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"" diff --git a/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap new file mode 100644 index 000000000..57c310b2b --- /dev/null +++ b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`compat public runtime API drift guard keeps root runtime exports intentional 1`] = ` +[ + "AllowanceError", + "CompatError", + "EIP712AuthTypes", + "EventPoller", + "InsufficientFundsError", + "NitroliteClient", + "NitroliteRPC", + "NotInitializedError", + "OngoingStateTransitionError", + "RPCAppStateIntent", + "RPCChannelStatus", + "RPCMethod", + "RPCProtocolVersion", + "RPCTxType", + "UserRejectedError", + "WalletStateSigner", + "blockchainRPCsFromEnv", + "buildClientOptions", + "convertRPCToClientChannel", + "convertRPCToClientState", + "createAppSessionMessage", + "createAuthRequestMessage", + "createAuthVerifyMessage", + "createAuthVerifyMessageWithJWT", + "createCloseAppSessionMessage", + "createCloseChannelMessage", + "createCreateChannelMessage", + "createECDSAMessageSigner", + "createEIP712AuthMessageSigner", + "createGetAppDefinitionMessage", + "createGetAppSessionsMessage", + "createGetChannelsMessage", + "createGetLedgerBalancesMessage", + "createPingMessage", + "createResizeChannelMessage", + "createSubmitAppStateMessage", + "createTransferMessage", + "getUserFacingMessage", + "packCreateAppSessionHash", + "packSubmitAppStateHash", + "parseAnyRPCResponse", + "parseCloseAppSessionResponse", + "parseCloseChannelResponse", + "parseCreateAppSessionResponse", + "parseCreateChannelResponse", + "parseGetAppDefinitionResponse", + "parseGetAppSessionsResponse", + "parseGetChannelsResponse", + "parseGetLedgerBalancesResponse", + "parseGetLedgerEntriesResponse", + "parseResizeChannelResponse", + "parseSubmitAppStateResponse", + "toSessionKeyQuorumSignature", + "toWalletQuorumSignature", +] +`; diff --git a/sdk/ts-compat/test/unit/client.test.ts b/sdk/ts-compat/test/unit/client.test.ts index 4d875a87c..cfda32217 100644 --- a/sdk/ts-compat/test/unit/client.test.ts +++ b/sdk/ts-compat/test/unit/client.test.ts @@ -119,4 +119,13 @@ describe('NitroliteClient getAppSessionsList compat mapping', () => { }, ]); }); + + it('maps an empty app session list without requiring legacy fields', async () => { + const client = makeClient([]); + + await expect(client.getAppSessionsList()).resolves.toEqual([]); + expect(client.innerClient.getAppSessions).toHaveBeenCalledWith({ + wallet: wallet.toLowerCase(), + }); + }); }); diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts new file mode 100644 index 000000000..8644059d6 --- /dev/null +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -0,0 +1,14 @@ +import * as publicApi from '../../src/index.js'; + +describe('compat public runtime API drift guard', () => { + it('keeps root runtime exports intentional', () => { + expect(Object.keys(publicApi).sort()).toMatchSnapshot(); + }); + + it('proves adversarial public export removal is observable', () => { + const exports = new Set(Object.keys(publicApi)); + exports.delete('NitroliteClient'); + + expect(exports.has('NitroliteClient')).toBe(false); + }); +}); diff --git a/sdk/ts/package.json b/sdk/ts/package.json index 146deff77..f798a6107 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -22,6 +22,7 @@ "lint": "eslint src test --ext .ts", "typecheck": "tsc --noEmit", "test": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs", + "drift:check": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.cjs --runTestsByPath test/unit/rpc-drift.test.ts test/unit/rpc-dto-drift.test.ts test/unit/public-api-drift.test.ts test/unit/abi-drift.test.ts test/unit/app-signing-drift.test.ts test/unit/transform-drift.test.ts", "test:types": "npm run validate", "dev": "npm run watch", "dev:docs": "npm run docs:tutorials && npm run watch", diff --git a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts index c77ec44c2..b6d6c06d7 100644 --- a/sdk/ts/src/blockchain/evm/channel_hub_abi.ts +++ b/sdk/ts/src/blockchain/evm/channel_hub_abi.ts @@ -438,7 +438,7 @@ export const ChannelHubAbi = [ } ], outputs: [], - stateMutability: 'payable' + stateMutability: 'nonpayable' }, { type: 'function', @@ -585,7 +585,7 @@ export const ChannelHubAbi = [ } ], outputs: [], - stateMutability: 'payable' + stateMutability: 'nonpayable' }, { type: 'function', @@ -2585,7 +2585,7 @@ export const ChannelHubAbi = [ } ], outputs: [], - stateMutability: 'payable' + stateMutability: 'nonpayable' }, { type: 'function', @@ -5533,6 +5533,11 @@ export const ChannelHubAbi = [ name: 'IncorrectChannelStatus', inputs: [] }, + { + type: 'error', + name: 'IncorrectMsgSender', + inputs: [] + }, { type: 'error', name: 'IncorrectNode', diff --git a/sdk/ts/src/utils.ts b/sdk/ts/src/utils.ts index 387dd0536..cb6be4f51 100644 --- a/sdk/ts/src/utils.ts +++ b/sdk/ts/src/utils.ts @@ -374,7 +374,7 @@ export function transformAppSessionInfo(raw: any): AppSessionInfoV1 { * The server returns snake_case JSON that needs conversion to SDK types. */ export function transformAppDefinitionFromRPC(raw: any): AppDefinitionV1 { - if (!raw.application_id || raw.nonce === undefined || raw.nonce === null) { + if (!raw || !raw.application_id || raw.nonce === undefined || raw.nonce === null) { throw new Error('Invalid app definition: missing required fields (application_id, nonce)'); } return { diff --git a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap new file mode 100644 index 000000000..a6be5c389 --- /dev/null +++ b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`SDK public runtime API drift guard keeps root runtime exports intentional 1`] = ` +[ + "AppSessionKeySignerV1", + "AppSessionStatus", + "AppSessionWalletSignerV1", + "AppSessionsV1CreateAppSessionMethod", + "AppSessionsV1GetAppDefinitionMethod", + "AppSessionsV1GetAppSessionsMethod", + "AppSessionsV1GetLastKeyStatesMethod", + "AppSessionsV1Group", + "AppSessionsV1RebalanceAppSessionsMethod", + "AppSessionsV1SubmitAppStateMethod", + "AppSessionsV1SubmitDepositStateMethod", + "AppSessionsV1SubmitSessionKeyStateMethod", + "AppStateUpdateIntent", + "AppsV1GetAppsMethod", + "AppsV1Group", + "AppsV1SubmitAppVersionMethod", + "CHANNEL_HUB_VERSION", + "ChannelDefaultSigner", + "ChannelParticipant", + "ChannelSessionKeyStateSigner", + "ChannelSignerType", + "ChannelStatus", + "ChannelType", + "ChannelV1Group", + "ChannelsV1GetChannelsMethod", + "ChannelsV1GetEscrowChannelMethod", + "ChannelsV1GetHomeChannelMethod", + "ChannelsV1GetLastKeyStatesMethod", + "ChannelsV1GetLatestStateMethod", + "ChannelsV1RequestCreationMethod", + "ChannelsV1SubmitSessionKeyStateMethod", + "ChannelsV1SubmitStateMethod", + "Client", + "ClientAssetStore", + "DEFAULT_CHALLENGE_PERIOD", + "DefaultConfig", + "DefaultWebsocketDialerConfig", + "ERROR_PARAM_KEY", + "ErrAlreadyConnected", + "ErrConnectionTimeout", + "ErrDialingWebsocket", + "ErrInvalidRequestMethod", + "ErrMarshalingRequest", + "ErrNilRequest", + "ErrNoResponse", + "ErrNotConnected", + "ErrReadingMessage", + "ErrSendingPing", + "ErrSendingRequest", + "EthereumMsgSigner", + "EthereumRawSigner", + "INTENT_CLOSE", + "INTENT_DEPOSIT", + "INTENT_FINALIZE_ESCROW_DEPOSIT", + "INTENT_FINALIZE_ESCROW_WITHDRAWAL", + "INTENT_FINALIZE_MIGRATION", + "INTENT_INITIATE_ESCROW_DEPOSIT", + "INTENT_INITIATE_ESCROW_WITHDRAWAL", + "INTENT_INITIATE_MIGRATION", + "INTENT_OPERATE", + "INTENT_WITHDRAW", + "MsgType", + "NodeV1GetAssetsMethod", + "NodeV1GetConfigMethod", + "NodeV1Group", + "NodeV1PingMethod", + "RPCClient", + "RPCError", + "StateAdvancerV1", + "StatePackerV1", + "TransactionType", + "TransitionType", + "UserV1GetActionAllowancesMethod", + "UserV1GetBalancesMethod", + "UserV1GetTransactionsMethod", + "UserV1Group", + "WebsocketDialer", + "appSessionStatusToString", + "appStateUpdateIntentToString", + "applyAcknowledgementTransition", + "applyChannelCreation", + "applyCommitTransition", + "applyEscrowDepositTransition", + "applyEscrowLockTransition", + "applyEscrowWithdrawTransition", + "applyFinalizeTransition", + "applyHomeDepositTransition", + "applyHomeWithdrawalTransition", + "applyMigrateTransition", + "applyMutualLockTransition", + "applyReceiverTransitions", + "applyReleaseTransition", + "applyTransferReceiveTransition", + "applyTransferSendTransition", + "createSigners", + "decimalToBigInt", + "errorf", + "evm", + "generateAppSessionIDV1", + "generateChannelMetadata", + "generateNonce", + "generateRebalanceBatchIDV1", + "generateRebalanceTransactionIDV1", + "getChannelSessionKeyAuthMetadataHashV1", + "getEscrowChannelId", + "getHomeChannelId", + "getLastTransition", + "getOffsetAndLimit", + "getReceiverTransactionId", + "getSenderTransactionId", + "getStateId", + "getStateTransitionHash", + "isFinal", + "ledgerEqual", + "marshalMessage", + "messageError", + "newChannel", + "newErrorPayload", + "newErrorResponse", + "newEvent", + "newMessage", + "newPayload", + "newRPCClient", + "newRequest", + "newResponse", + "newStatePackerV1", + "newTransaction", + "newTransition", + "newVoidState", + "newWebsocketDialer", + "nextState", + "packAppSessionKeyStateV1", + "packAppStateUpdateV1", + "packAppV1", + "packChallengeState", + "packChannelKeyStateV1", + "packCreateAppSessionRequestV1", + "packState", + "payloadError", + "transformActionAllowance", + "transformAppDefinitionFromRPC", + "transformAppDefinitionToRPC", + "transformAppSessionInfo", + "transformAppStateUpdateToRPC", + "transformAssets", + "transformBalances", + "transformChannel", + "transformLedger", + "transformNodeConfig", + "transformPaginationMetadata", + "transformSignedAppStateUpdateToRPC", + "transformState", + "transformTransaction", + "transformTransition", + "transitionRequiresOpenChannel", + "transitionToIntent", + "transitionToString", + "transitionsEqual", + "translatePayload", + "unmarshalMessage", + "validateDecimalPrecision", + "validateLedger", + "withBlockchainRPC", + "withErrorHandler", + "withHandshakeTimeout", +] +`; diff --git a/sdk/ts/test/unit/abi-drift.test.ts b/sdk/ts/test/unit/abi-drift.test.ts new file mode 100644 index 000000000..b9b7096c1 --- /dev/null +++ b/sdk/ts/test/unit/abi-drift.test.ts @@ -0,0 +1,98 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { ChannelHubAbi } from '../../src/blockchain/evm/channel_hub_abi.js'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../../..'); + +type AbiEntry = { + type: string; + name?: string; + inputs?: Array<{ type: string }>; + outputs?: Array<{ type: string }>; + stateMutability?: string; +}; + +function signature(entry: AbiEntry): string { + const inputs = (entry.inputs ?? []).map((input) => input.type).join(','); + const outputs = (entry.outputs ?? []).map((output) => output.type).join(','); + return `${entry.name}(${inputs}) -> (${outputs}) ${entry.stateMutability ?? ''}`; +} + +function functionSignatures(abi: readonly AbiEntry[]) { + return new Map( + abi + .filter((entry) => entry.type === 'function' && entry.name) + .map((entry) => [entry.name as string, signature(entry)]) + ); +} + +describe('contract ABI drift guards', () => { + it('keeps checked-in ChannelHub ABI aligned with Foundry artifact for SDK-consumed functions', () => { + const artifact = JSON.parse( + fs.readFileSync( + path.join(repoRoot, 'contracts/out/ChannelHub.sol/ChannelHub.json'), + 'utf8' + ) + ); + const artifactSigs = functionSignatures(artifact.abi); + const sdkSigs = functionSignatures(ChannelHubAbi as readonly AbiEntry[]); + + const consumedFunctions = [ + 'VERSION', + 'createChannel', + 'depositToChannel', + 'withdrawFromChannel', + 'checkpointChannel', + 'closeChannel', + 'getChannelData', + 'getNodeValidator', + 'isNodeValidatorActive', + 'registerSessionKey', + 'unregisterSessionKey', + ].filter((name) => artifactSigs.has(name) || sdkSigs.has(name)); + + const diffs = consumedFunctions + .map((name) => ({ + name, + artifact: artifactSigs.get(name), + sdk: sdkSigs.get(name), + })) + .filter(({ artifact: artifactSig, sdk: sdkSig }) => artifactSig !== sdkSig); + + expect(diffs).toEqual([]); + }); + + it('reports adversarial function signature changes with function names', () => { + const artifactSigs = functionSignatures([ + { + type: 'function', + name: 'getNodeValidator', + inputs: [{ type: 'address' }, { type: 'uint8' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, + ]); + const sdkSigs = functionSignatures([ + { + type: 'function', + name: 'getNodeValidator', + inputs: [{ type: 'uint8' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, + ]); + + expect({ + name: 'getNodeValidator', + artifact: artifactSigs.get('getNodeValidator'), + sdk: sdkSigs.get('getNodeValidator'), + }).toEqual({ + name: 'getNodeValidator', + artifact: 'getNodeValidator(address,uint8) -> (address) view', + sdk: 'getNodeValidator(uint8) -> (address) view', + }); + }); +}); diff --git a/sdk/ts/test/unit/app-signing-drift.test.ts b/sdk/ts/test/unit/app-signing-drift.test.ts new file mode 100644 index 000000000..1c1e2f24f --- /dev/null +++ b/sdk/ts/test/unit/app-signing-drift.test.ts @@ -0,0 +1,89 @@ +import { Decimal } from 'decimal.js'; + +import { + AppStateUpdateIntent, + generateAppSessionIDV1, + packAppStateUpdateV1, + packCreateAppSessionRequestV1, + type AppDefinitionV1, +} from '../../src/app/index.js'; + +const user = '0x1111111111111111111111111111111111111111'; +const app = '0x2222222222222222222222222222222222222222'; + +const definition: AppDefinitionV1 = { + applicationId: 'store-v1', + participants: [ + { walletAddress: user, signatureWeight: 1 }, + { walletAddress: app, signatureWeight: 1 }, + ], + quorum: 2, + nonce: 123456789n, +}; + +describe('Go/TS app signing drift vectors', () => { + it('matches Go PackCreateAppSessionRequestV1 and GenerateAppSessionIDV1 vectors', () => { + expect(packCreateAppSessionRequestV1(definition, '{"cart":"demo"}')).toBe( + '0x405d15a85c16ac1e555b3319de58acf7b4b86ebe2ccaf6af802d61e450b88632' + ); + expect(generateAppSessionIDV1(definition)).toBe( + '0x9b88181fc2ee0bc03abad5c4c9ea421c6748919882d4053204d95fbc79a175eb' + ); + }); + + it('matches Go PackAppStateUpdateV1 deposit and operate vectors', () => { + const appSessionId = generateAppSessionIDV1(definition); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 2n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.25') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit"}', + }) + ).toBe('0x65e0856b8de315f40db44b9cc4165fa7e590169b3325e500a03aa380954c393d'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Operate, + version: 3n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.35') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"purchase","item_id":1,"item_price":"0.90"}', + }) + ).toBe('0xe44d77fa3eda431b1bc088e6f89e114b2191ef5ce03cc6851c702d01bdbf3457'); + }); + + it('proves adversarial allocation ordering changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 2n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.25') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit"}', + }); + const mutated = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 2n, + allocations: [ + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + { participant: user, asset: 'YUSD', amount: new Decimal('1.25') }, + ], + sessionData: '{"intent":"deposit"}', + }); + + expect(mutated).not.toBe(canonical); + }); +}); diff --git a/sdk/ts/test/unit/public-api-drift.test.ts b/sdk/ts/test/unit/public-api-drift.test.ts new file mode 100644 index 000000000..eca3bd9ce --- /dev/null +++ b/sdk/ts/test/unit/public-api-drift.test.ts @@ -0,0 +1,14 @@ +import * as publicApi from '../../src/index.js'; + +describe('SDK public runtime API drift guard', () => { + it('keeps root runtime exports intentional', () => { + expect(Object.keys(publicApi).sort()).toMatchSnapshot(); + }); + + it('proves adversarial public export removal is observable', () => { + const exports = new Set(Object.keys(publicApi)); + exports.delete('Client'); + + expect(exports.has('Client')).toBe(false); + }); +}); diff --git a/sdk/ts/test/unit/rpc-drift.test.ts b/sdk/ts/test/unit/rpc-drift.test.ts index 79124bb93..6f8a72a89 100644 --- a/sdk/ts/test/unit/rpc-drift.test.ts +++ b/sdk/ts/test/unit/rpc-drift.test.ts @@ -46,6 +46,11 @@ function extractRouterHandlers(source: string): Set { return new Set(methodNames.map((name) => namedLiterals.get(name) as string)); } +function extractTsClientMethods(source: string): Set { + const matches = source.matchAll(/^\s{2}(?:static\s+)?(?:async\s+)?([A-Za-z0-9_]+)\(/gm); + return new Set(Array.from(matches, ([, method]) => method)); +} + function sorted(values: Set): string[] { return Array.from(values).sort(); } @@ -58,6 +63,36 @@ function diff(left: Set, right: Set): { missing: string[]; extra } describe('TS RPC drift guards', () => { + const publicClientMethodsByRPCMethod = new Map([ + ['node.v1.ping', 'ping'], + ['node.v1.get_config', 'getConfig'], + ['node.v1.get_assets', 'getAssets'], + ['user.v1.get_balances', 'getBalances'], + ['user.v1.get_transactions', 'getTransactions'], + ['user.v1.get_action_allowances', 'getActionAllowances'], + ['channels.v1.get_home_channel', 'getHomeChannel'], + ['channels.v1.get_escrow_channel', 'getEscrowChannel'], + ['channels.v1.get_channels', 'getChannels'], + ['channels.v1.get_latest_state', 'getLatestState'], + ['channels.v1.submit_session_key_state', 'submitChannelSessionKeyState'], + ['channels.v1.get_last_key_states', 'getLastChannelKeyStates'], + ['app_sessions.v1.submit_deposit_state', 'submitAppSessionDeposit'], + ['app_sessions.v1.submit_app_state', 'submitAppState'], + ['app_sessions.v1.rebalance_app_sessions', 'rebalanceAppSessions'], + ['app_sessions.v1.get_app_definition', 'getAppDefinition'], + ['app_sessions.v1.get_app_sessions', 'getAppSessions'], + ['app_sessions.v1.create_app_session', 'createAppSession'], + ['app_sessions.v1.submit_session_key_state', 'submitSessionKeyState'], + ['app_sessions.v1.get_last_key_states', 'getLastKeyStates'], + ['apps.v1.get_apps', 'getApps'], + ['apps.v1.submit_app_version', 'registerApp'], + ]); + + const intentionallyRawOnlyMethods = new Set([ + 'channels.v1.request_creation', + 'channels.v1.submit_state', + ]); + it('keeps the TS raw RPC method surface aligned with pkg/rpc', () => { const goMethods = extractGoMethodLiterals( fs.readFileSync(path.join(repoRoot, 'pkg/rpc/methods.go'), 'utf8') @@ -83,4 +118,54 @@ describe('TS RPC drift guards', () => { expect({ missing, extra }).toEqual({ missing: [], extra: [] }); }); + + it('keeps public Client wrappers aligned with public RPC methods', () => { + const routerMethods = extractRouterHandlers( + fs.readFileSync(path.join(repoRoot, 'nitronode/api/rpc_router.go'), 'utf8') + ); + const clientMethods = extractTsClientMethods( + fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/client.ts'), 'utf8') + ); + + const coveredMethods = new Set([ + ...publicClientMethodsByRPCMethod.keys(), + ...intentionallyRawOnlyMethods, + ]); + const uncoveredRouterMethods = sorted( + new Set(Array.from(routerMethods).filter((method) => !coveredMethods.has(method))) + ); + const missingClientMethods = Array.from(publicClientMethodsByRPCMethod) + .filter(([method]) => routerMethods.has(method)) + .filter(([, clientMethod]) => !clientMethods.has(clientMethod)) + .map(([method, clientMethod]) => `${method} -> Client.${clientMethod}()`) + .sort(); + + expect({ uncoveredRouterMethods, missingClientMethods }).toEqual({ + uncoveredRouterMethods: [], + missingClientMethods: [], + }); + }); + + it('reports adversarial method additions as missing TS methods', () => { + const tsMethods = new Set(['node.v1.ping']); + const goMethods = new Set(['node.v1.ping', 'node.v1.fake_method']); + + expect(diff(tsMethods, goMethods)).toEqual({ + missing: ['node.v1.fake_method'], + extra: [], + }); + }); + + it('reports adversarial TS method removals as missing public wrappers', () => { + const routerMethods = new Set(['node.v1.ping']); + const clientMethods = new Set(); + const mapping = new Map([['node.v1.ping', 'ping']]); + + const missingClientMethods = Array.from(mapping) + .filter(([method]) => routerMethods.has(method)) + .filter(([, clientMethod]) => !clientMethods.has(clientMethod)) + .map(([method, clientMethod]) => `${method} -> Client.${clientMethod}()`); + + expect(missingClientMethods).toEqual(['node.v1.ping -> Client.ping()']); + }); }); diff --git a/sdk/ts/test/unit/rpc-dto-drift.test.ts b/sdk/ts/test/unit/rpc-dto-drift.test.ts new file mode 100644 index 000000000..3eb5833a9 --- /dev/null +++ b/sdk/ts/test/unit/rpc-dto-drift.test.ts @@ -0,0 +1,173 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(testDir, '../../../..'); + +type FieldShape = { + optional: boolean; + container: 'array' | 'scalar'; +}; + +type DTOShape = Record>; + +function normalizeGoContainer(typeText: string): FieldShape['container'] { + return typeText.replace(/^\*/, '').trim().startsWith('[]') ? 'array' : 'scalar'; +} + +function normalizeTsContainer(typeText: string): FieldShape['container'] { + return typeText.trim().endsWith('[]') || typeText.includes('Array<') ? 'array' : 'scalar'; +} + +function extractGoDTOShapes(source: string): DTOShape { + const shapes: DTOShape = {}; + const emptyStructs = source.matchAll( + /^type\s+([A-Za-z0-9]+(?:Request|Response))\s+struct\s*\{\s*\}$/gm + ); + for (const [, typeName] of emptyStructs) { + shapes[typeName] = {}; + } + + const structs = source.matchAll( + /^type\s+([A-Za-z0-9]+(?:Request|Response))\s+struct\s*\{\n([\s\S]*?)^\}/gm + ); + + for (const [, typeName, body] of structs) { + const fields: Record = {}; + for (const line of body.split('\n')) { + const tag = line.match(/`json:"([^"]+)"`/); + if (!tag) continue; + + const [wireName, ...tagOptions] = tag[1].split(','); + if (wireName === '-') continue; + + const beforeTag = line.slice(0, line.indexOf('`')).trim(); + const parts = beforeTag.split(/\s+/); + const typeText = parts.slice(1).join(' '); + + fields[wireName] = { + optional: typeText.startsWith('*') || tagOptions.includes('omitempty'), + container: normalizeGoContainer(typeText), + }; + } + shapes[typeName] = fields; + } + + return shapes; +} + +function extractTsDTOShapes(source: string): DTOShape { + const shapes: DTOShape = {}; + const emptyInterfaces = source.matchAll( + /^export\s+interface\s+([A-Za-z0-9]+(?:Request|Response))\s*\{\s*\}$/gm + ); + for (const [, typeName] of emptyInterfaces) { + shapes[typeName] = {}; + } + + const interfaces = source.matchAll( + /^export\s+interface\s+([A-Za-z0-9]+(?:Request|Response))\s*\{\n([\s\S]*?)^\}/gm + ); + + for (const [, typeName, body] of interfaces) { + const fields: Record = {}; + for (const line of body.split('\n')) { + const field = line.match(/^\s*([A-Za-z0-9_]+)(\?)?:\s*([^;]+);/); + if (!field) continue; + + const [, wireName, optionalMarker, typeText] = field; + fields[wireName] = { + optional: optionalMarker === '?', + container: normalizeTsContainer(typeText), + }; + } + shapes[typeName] = fields; + } + + return shapes; +} + +function diffDTOShapes(tsShapes: DTOShape, goShapes: DTOShape) { + const missingTypes = Object.keys(goShapes).filter((typeName) => !(typeName in tsShapes)).sort(); + const extraTypes = Object.keys(tsShapes).filter((typeName) => !(typeName in goShapes)).sort(); + const fieldDiffs: string[] = []; + + for (const typeName of Object.keys(goShapes).filter((name) => name in tsShapes).sort()) { + const goFields = goShapes[typeName]; + const tsFields = tsShapes[typeName]; + for (const fieldName of Object.keys(goFields).sort()) { + if (!(fieldName in tsFields)) { + fieldDiffs.push(`${typeName}.${fieldName}: missing in TS`); + continue; + } + if (goFields[fieldName].optional !== tsFields[fieldName].optional) { + fieldDiffs.push( + `${typeName}.${fieldName}: optionality Go=${goFields[fieldName].optional} TS=${tsFields[fieldName].optional}` + ); + } + if (goFields[fieldName].container !== tsFields[fieldName].container) { + fieldDiffs.push( + `${typeName}.${fieldName}: container Go=${goFields[fieldName].container} TS=${tsFields[fieldName].container}` + ); + } + } + for (const fieldName of Object.keys(tsFields).sort()) { + if (!(fieldName in goFields)) { + fieldDiffs.push(`${typeName}.${fieldName}: extra in TS`); + } + } + } + + return { missingTypes, extraTypes, fieldDiffs }; +} + +describe('RPC DTO drift guards', () => { + it('keeps Go RPC JSON DTO fields aligned with TS RPC interfaces', () => { + const goShapes = extractGoDTOShapes( + fs.readFileSync(path.join(repoRoot, 'pkg/rpc/api.go'), 'utf8') + ); + const tsShapes = extractTsDTOShapes( + fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/rpc/api.ts'), 'utf8') + ); + + expect(diffDTOShapes(tsShapes, goShapes)).toEqual({ + missingTypes: [], + extraTypes: [], + fieldDiffs: [], + }); + }); + + it('reports adversarial Go-only required fields with field-level paths', () => { + const goShapes = extractGoDTOShapes(` +type NodeV1PingRequest struct { + Required string \`json:"required"\` +} +`); + const tsShapes = extractTsDTOShapes(` +export interface NodeV1PingRequest {} +`); + + expect(diffDTOShapes(tsShapes, goShapes).fieldDiffs).toEqual([ + 'NodeV1PingRequest.required: missing in TS', + ]); + }); + + it('reports adversarial optionality and array/scalar drift', () => { + const goShapes = extractGoDTOShapes(` +type NodeV1GetAssetsResponse struct { + Assets []AssetV1 \`json:"assets,omitempty"\` +} +`); + const tsShapes = extractTsDTOShapes(` +export interface NodeV1GetAssetsResponse { + assets: AssetV1; +} +`); + + expect(diffDTOShapes(tsShapes, goShapes).fieldDiffs).toEqual([ + 'NodeV1GetAssetsResponse.assets: optionality Go=true TS=false', + 'NodeV1GetAssetsResponse.assets: container Go=array TS=scalar', + ]); + }); +}); diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts new file mode 100644 index 000000000..60f5e2b8c --- /dev/null +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -0,0 +1,144 @@ +import { Decimal } from 'decimal.js'; + +import { transformAppSessionInfo, transformAssets, transformNodeConfig } from '../../src/utils.js'; + +describe('Clearnode response transform drift guards', () => { + it('maps current get_app_sessions app_definition shape to SDK appDefinition', () => { + const session = transformAppSessionInfo({ + app_session_id: '0xsession', + app_definition: { + application_id: 'store-v1', + participants: [ + { + wallet_address: '0x1111111111111111111111111111111111111111', + signature_weight: 1, + }, + { + wallet_address: '0x2222222222222222222222222222222222222222', + signature_weight: 1, + }, + ], + quorum: 2, + nonce: '123', + }, + status: 'open', + session_data: '{"intent":"purchase"}', + version: '4', + allocations: [ + { + participant: '0x1111111111111111111111111111111111111111', + asset: 'YUSD', + amount: '1.25', + }, + ], + }); + + expect(session).toEqual({ + appSessionId: '0xsession', + appDefinition: { + applicationId: 'store-v1', + participants: [ + { + walletAddress: '0x1111111111111111111111111111111111111111', + signatureWeight: 1, + }, + { + walletAddress: '0x2222222222222222222222222222222222222222', + signatureWeight: 1, + }, + ], + quorum: 2, + nonce: 123n, + }, + isClosed: false, + sessionData: '{"intent":"purchase"}', + version: 4n, + allocations: [ + { + participant: '0x1111111111111111111111111111111111111111', + asset: 'YUSD', + amount: new Decimal('1.25'), + }, + ], + }); + }); + + it('rejects app sessions missing the required app_definition payload', () => { + expect(() => + transformAppSessionInfo({ + app_session_id: '0xsession', + status: 'open', + session_data: '', + version: '1', + allocations: [], + }) + ).toThrow('Invalid app definition: missing required fields'); + }); + + it('maps get_config supported_sig_validators from array and base64 forms', () => { + const base = { + node_address: '0x1111111111111111111111111111111111111111' as const, + node_version: 'test', + blockchains: [ + { + name: 'Sepolia', + blockchain_id: '11155111', + channel_hub_address: '0x2222222222222222222222222222222222222222', + locking_contract_address: '0x3333333333333333333333333333333333333333', + }, + ], + }; + + expect( + transformNodeConfig({ + ...base, + supported_sig_validators: [0, 1], + }).supportedSigValidators + ).toEqual([0, 1]); + + expect( + transformNodeConfig({ + ...base, + supported_sig_validators: 'AAE=', + } as any).supportedSigValidators + ).toEqual([0, 1]); + }); + + it('maps get_assets symbols, decimals, suggested chain, and token chains', () => { + expect( + transformAssets([ + { + name: 'Yellow USD', + symbol: 'YUSD', + decimals: 6, + suggested_blockchain_id: '11155111', + tokens: [ + { + name: 'Yellow USD', + symbol: 'YUSD', + address: '0x4444444444444444444444444444444444444444', + blockchain_id: '11155111', + decimals: 6, + }, + ], + }, + ]) + ).toEqual([ + { + name: 'Yellow USD', + symbol: 'YUSD', + decimals: 6, + suggestedBlockchainId: 11155111n, + tokens: [ + { + name: 'Yellow USD', + symbol: 'YUSD', + address: '0x4444444444444444444444444444444444444444', + blockchainId: 11155111n, + decimals: 6, + }, + ], + }, + ]); + }); +}); From 09a3506f642f175c6c56e70fdc9118677be7b781 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:11:31 +0530 Subject: [PATCH 02/11] Add protocol runtime drift smoke --- .github/workflows/main-pr.yml | 50 +++++ .github/workflows/main-push.yml | 50 +++++ nitronode/store/database/database.go | 45 +++- scripts/check-protocol-drift.sh | 10 +- scripts/drift/runtime-smoke.mjs | 324 +++++++++++++++++++++++++++ sdk/PROTOCOL_DRIFT_GUARDS.md | 17 +- 6 files changed, 481 insertions(+), 15 deletions(-) create mode 100644 scripts/drift/runtime-smoke.mjs diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml index ad882bc9a..37c875bdf 100644 --- a/.github/workflows/main-pr.yml +++ b/.github/workflows/main-pr.yml @@ -63,6 +63,56 @@ jobs: - name: Run static protocol drift checks run: ./scripts/check-protocol-drift.sh --static + test-protocol-drift-runtime: + name: Test (Protocol Drift Runtime) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run runtime protocol drift smoke + run: ./scripts/check-protocol-drift.sh --runtime + env: + NITRONODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + + - name: Upload runtime smoke logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: protocol-drift-runtime-smoke-logs + path: runtime-smoke-logs + if-no-files-found: ignore + build-and-publish-nitronode: name: Build and Publish (Nitronode) needs: test-nitronode diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml index 034ec7126..71c66be3f 100644 --- a/.github/workflows/main-push.yml +++ b/.github/workflows/main-push.yml @@ -63,6 +63,56 @@ jobs: - name: Run static protocol drift checks run: ./scripts/check-protocol-drift.sh --static + test-protocol-drift-runtime: + name: Test (Protocol Drift Runtime) + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + cache-dependency-path: go.sum + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run runtime protocol drift smoke + run: ./scripts/check-protocol-drift.sh --runtime + env: + CLEARNODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + + - name: Upload runtime smoke logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: protocol-drift-runtime-smoke-logs + path: runtime-smoke-logs + if-no-files-found: ignore + # build-and-publish-sdk: # needs: [test-sdk-ts, test-sdk-compat] # name: Build and Publish (SDK) diff --git a/nitronode/store/database/database.go b/nitronode/store/database/database.go index f58601d90..d6789e0ba 100644 --- a/nitronode/store/database/database.go +++ b/nitronode/store/database/database.go @@ -1,9 +1,11 @@ package database import ( + "database/sql" "embed" "fmt" "log" + "strings" "time" "github.com/jmoiron/sqlx" @@ -120,7 +122,9 @@ func connectToSqlite(cnf DatabaseConfig) (*gorm.DB, error) { } // Migrate sqlite - migrateSqlite(db) + if err := migrateSqlite(db); err != nil { + return nil, fmt.Errorf("failed to auto-migrate sqlite: %w", err) + } log.Println("Successfully auto-migrated") @@ -165,17 +169,15 @@ func ensurePostgresqlSchema(cnf DatabaseConfig) error { return err } - queryDbCheck := fmt.Sprintf("SELECT 1 FROM information_schema.schemata WHERE schema_name='%s'", cnf.Schema) - if res, err := db.Exec(queryDbCheck); err != nil { - return fmt.Errorf("error while checking schema existance: %s", err.Error()) - } else if rows, err := res.RowsAffected(); err != nil { + var exists int + if err := db.QueryRow("SELECT 1 FROM information_schema.schemata WHERE schema_name=$1", cnf.Schema).Scan(&exists); err != nil && err != sql.ErrNoRows { return fmt.Errorf("error while checking schema existance: %s", err.Error()) - } else if rows > 0 { + } else if err == nil { log.Printf("Schema already exists: %s\n", cnf.Schema) return nil } - if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", cnf.Schema)); err != nil { + if _, err = db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quotePostgresIdentifier(cnf.Schema))); err != nil { return fmt.Errorf("error while creating schema: %s", err.Error()) } @@ -197,7 +199,7 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { if cnf.Schema != "" { switch cnf.Driver { case "postgres": - if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s", cnf.Schema)); err != nil { + if _, err := db.Exec(fmt.Sprintf("SET search_path TO %s", quotePostgresIdentifier(cnf.Schema))); err != nil { return fmt.Errorf("failed to set search path: %v", err) } } @@ -206,7 +208,7 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { log.Println("Applying database migrations") goose.SetBaseFS(embedMigrations) if err := goose.Up(db, "config/migrations/"+cnf.Driver); err != nil { - panic(err) + return fmt.Errorf("goose migration failed: %w", err) } log.Println("Applied migrations") @@ -214,8 +216,31 @@ func migratePostgres(cnf DatabaseConfig, embedMigrations embed.FS) error { } func migrateSqlite(db *gorm.DB) error { - if err := db.AutoMigrate(&AppV1{}, &AppLedgerEntryV1{}, &Channel{}, &AppSessionV1{}, &ContractEvent{}, &State{}, &Transaction{}, &BlockchainAction{}, &AppSessionKeyStateV1{}, &AppSessionKeyApplicationV1{}, &AppSessionKeyAppSessionIDV1{}, &UserBalance{}, &UserStakedV1{}, &ActionLogEntryV1{}, &LifespanMetric{}); err != nil { + if err := db.AutoMigrate( + &AppV1{}, + &AppLedgerEntryV1{}, + &Channel{}, + &AppSessionV1{}, + &AppParticipantV1{}, + &ContractEvent{}, + &State{}, + &Transaction{}, + &BlockchainAction{}, + &AppSessionKeyStateV1{}, + &AppSessionKeyApplicationV1{}, + &AppSessionKeyAppSessionIDV1{}, + &ChannelSessionKeyStateV1{}, + &ChannelSessionKeyAssetV1{}, + &UserBalance{}, + &UserStakedV1{}, + &ActionLogEntryV1{}, + &LifespanMetric{}, + ); err != nil { return err } return nil } + +func quotePostgresIdentifier(identifier string) string { + return `"` + strings.ReplaceAll(identifier, `"`, `""`) + `"` +} diff --git a/scripts/check-protocol-drift.sh b/scripts/check-protocol-drift.sh index e78b75a2e..261162514 100755 --- a/scripts/check-protocol-drift.sh +++ b/scripts/check-protocol-drift.sh @@ -10,8 +10,8 @@ Usage: scripts/check-protocol-drift.sh [--static|--runtime] --static Run deterministic protocol/SDK/compat drift checks. --runtime Run runtime smoke checks against an ephemeral/local Clearnode. -Runtime smoke is intentionally not implemented in the static runner yet; CI will -wire it once the ephemeral Clearnode harness lands. +Runtime smoke starts an isolated local Clearnode with a temporary config. It is a +lightweight compatibility smoke, not a load or stress test. USAGE } @@ -42,8 +42,10 @@ case "$mode" in run_package "sdk/ts-compat" "drift:check" ;; --runtime) - echo "::error::runtime protocol drift smoke is not implemented yet; use --static for deterministic checks" >&2 - exit 2 + echo "==> Running Nitrolite protocol runtime smoke checks" + run_package "sdk/ts" "build:ci" + run_package "sdk/ts-compat" "build:ci" + node "$ROOT/scripts/drift/runtime-smoke.mjs" ;; -h|--help) usage diff --git a/scripts/drift/runtime-smoke.mjs b/scripts/drift/runtime-smoke.mjs new file mode 100644 index 000000000..410d99c7c --- /dev/null +++ b/scripts/drift/runtime-smoke.mjs @@ -0,0 +1,324 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as sleep } from 'node:timers/promises'; + +import { Client, createSigners, withErrorHandler } from '../../sdk/ts/dist/index.js'; +import { NitroliteClient } from '../../sdk/ts-compat/dist/index.js'; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(scriptDir, '../..'); +const wsURL = process.env.CLEARNODE_RUNTIME_SMOKE_WS_URL ?? 'ws://127.0.0.1:7824/ws'; +const readyTimeoutMs = Number(process.env.CLEARNODE_RUNTIME_SMOKE_READY_TIMEOUT_MS ?? 15000); +const adversarialMode = process.env.CLEARNODE_RUNTIME_SMOKE_ADVERSARIAL ?? ''; +const externalLogDirInput = process.env.CLEARNODE_RUNTIME_SMOKE_LOG_DIR ?? ''; +const externalLogDir = externalLogDirInput ? path.resolve(repoRoot, externalLogDirInput) : ''; +const privateKey = + '0x59c6995e998f97a5a0044966f094538f0d0921e301baca6a9ae52cd7834c90b9'; + +class SmokeError extends Error { + constructor(category, message, cause) { + super(`[${category}] ${message}${cause ? `: ${cause.message ?? cause}` : ''}`); + this.name = 'SmokeError'; + this.category = category; + this.cause = cause; + } +} + +function assertSmoke(condition, category, message) { + if (!condition) { + throw new SmokeError(category, message); + } +} + +function logStep(message) { + console.log(`[runtime-smoke] ${message}`); +} + +async function withTimeout(label, promise, timeoutMs = 5000) { + const timeout = sleep(timeoutMs).then(() => { + throw new SmokeError('timeout', `${label} timed out after ${timeoutMs}ms`); + }); + return Promise.race([promise, timeout]); +} + +function openWebSocket(url, timeoutMs = 500) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url); + let settled = false; + + const finish = (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { + ws.close(); + } catch { + // Ignore close errors while probing readiness. + } + if (err) reject(err); + else resolve(); + }; + + const timer = setTimeout(() => finish(new Error('WebSocket connect timeout')), timeoutMs); + ws.onopen = () => finish(); + ws.onerror = () => finish(new Error('WebSocket connection error')); + ws.onclose = () => finish(new Error('WebSocket closed before open')); + }); +} + +async function waitForWebSocket(url, child, timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + let lastError = null; + + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new SmokeError( + 'startup', + `Clearnode exited before readiness with code ${child.exitCode}` + ); + } + + try { + await openWebSocket(url); + return; + } catch (err) { + lastError = err; + await sleep(250); + } + } + + throw new SmokeError( + 'connection', + `Clearnode did not accept WebSocket connections at ${url}`, + lastError + ); +} + +async function stopProcess(child) { + if (child.exitCode !== null || child.signalCode !== null) return; + + child.kill('SIGTERM'); + const exited = await Promise.race([ + once(child, 'exit').then(() => true), + sleep(5000).then(() => false), + ]); + if (exited) return; + + child.kill('SIGKILL'); +} + +async function runCommand(command, args, options, category) { + return new Promise((resolve, reject) => { + let stderr = ''; + const child = spawn(command, args, options); + child.stderr?.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.on('error', (err) => reject(new SmokeError(category, `${command} failed to start`, err))); + child.on('exit', (code, signal) => { + if (code === 0) { + resolve(); + return; + } + reject( + new SmokeError( + category, + `${command} ${args.join(' ')} exited with ${signal ?? code}${stderr ? `\n${stderr}` : ''}` + ) + ); + }); + }); +} + +async function writeConfig(configDir) { + await writeFile( + path.join(configDir, '.env'), + [ + 'CLEARNODE_DATABASE_DRIVER=sqlite', + 'CLEARNODE_SIGNER_TYPE=key', + `CLEARNODE_SIGNER_KEY=${privateKey}`, + 'CLEARNODE_LOG_LEVEL=error', + '', + ].join('\n') + ); + + if (adversarialMode === 'bad-config') { + await writeFile(path.join(configDir, 'blockchains.yaml'), 'blockchains:\n - name: BAD_NAME\n'); + await writeFile(path.join(configDir, 'assets.yaml'), 'assets: []\n'); + return; + } + + await writeFile(path.join(configDir, 'blockchains.yaml'), 'blockchains: []\n'); + await writeFile(path.join(configDir, 'assets.yaml'), 'assets: []\n'); +} + +async function writeFailureLogs(paths, stdout, stderr, summary) { + await writeFile(paths.stdoutPath, stdout); + await writeFile(paths.stderrPath, stderr); + + if (!externalLogDir) return; + + await mkdir(externalLogDir, { recursive: true }); + await writeFile(path.join(externalLogDir, 'summary.txt'), summary); + await writeFile(path.join(externalLogDir, 'clearnode.stdout.log'), stdout); + await writeFile(path.join(externalLogDir, 'clearnode.stderr.log'), stderr); +} + +async function runSmoke() { + const configDir = await mkdtemp(path.join(tmpdir(), 'nitrolite-runtime-smoke-')); + const binaryPath = path.join(configDir, 'clearnode-smoke'); + const stdoutPath = path.join(configDir, 'clearnode.stdout.log'); + const stderrPath = path.join(configDir, 'clearnode.stderr.log'); + let stdout = ''; + let stderr = ''; + let client = null; + let child = null; + + const logs = () => [ + `stdout (${stdoutPath}):`, + stdout.trim() || '', + `stderr (${stderrPath}):`, + stderr.trim() || '', + ].join('\n'); + + try { + logStep(`writing isolated config in ${configDir}`); + await writeConfig(configDir); + logStep('building temporary Clearnode binary'); + await runCommand('go', ['build', '-o', binaryPath, './clearnode'], { cwd: repoRoot }, 'setup'); + + logStep(`starting Clearnode and waiting for ${wsURL}`); + child = spawn(binaryPath, { + cwd: repoRoot, + env: { + ...process.env, + CLEARNODE_CONFIG_DIR_PATH: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + await waitForWebSocket(wsURL, child, readyTimeoutMs); + + const { stateSigner, txSigner } = createSigners(privateKey); + const wallet = stateSigner.getAddress(); + logStep(`creating TS SDK client for wallet ${wallet}`); + client = await withTimeout( + 'Client.create', + Client.create(wsURL, stateSigner, txSigner, withErrorHandler(() => {})) + ); + + logStep('calling ping'); + await withTimeout('client.ping', client.ping()); + + logStep('calling getConfig'); + const config = await withTimeout('client.getConfig', client.getConfig()); + assertSmoke( + config.nodeAddress.toLowerCase() === wallet.toLowerCase(), + 'transform', + `expected node address ${wallet}, got ${config.nodeAddress}` + ); + assertSmoke(Array.isArray(config.blockchains), 'transform', 'node config blockchains is not an array'); + assertSmoke(config.blockchains.length === 0, 'transform', 'runtime smoke config should expose no blockchains'); + assertSmoke( + Array.isArray(config.supportedSigValidators), + 'transform', + 'node config supportedSigValidators is not an array' + ); + + logStep('calling getAssets'); + const assets = await withTimeout('client.getAssets', client.getAssets()); + assertSmoke(Array.isArray(assets), 'transform', 'assets response is not an array'); + assertSmoke(assets.length === 0, 'transform', 'runtime smoke config should expose no assets'); + + logStep('calling getAppSessions'); + const appSessions = await withTimeout( + 'client.getAppSessions', + client.getAppSessions({ wallet }) + ); + assertSmoke(Array.isArray(appSessions.sessions), 'transform', 'app sessions is not an array'); + assertSmoke(appSessions.sessions.length === 0, 'transform', 'expected no app sessions for smoke wallet'); + + logStep('calling getLastChannelKeyStates'); + const channelKeyStates = await withTimeout( + 'client.getLastChannelKeyStates', + client.getLastChannelKeyStates(wallet) + ); + assertSmoke(Array.isArray(channelKeyStates), 'transform', 'channel key states is not an array'); + + logStep('calling getLastKeyStates'); + const appSessionKeyStates = await withTimeout( + 'client.getLastKeyStates', + client.getLastKeyStates(wallet) + ); + assertSmoke(Array.isArray(appSessionKeyStates), 'transform', 'app session key states is not an array'); + + logStep('validating compat getAppSessionsList mapping'); + const compatClient = Object.create(NitroliteClient.prototype); + compatClient.userAddress = wallet; + compatClient.innerClient = client; + compatClient.assetsBySymbol = new Map(); + compatClient._lastAppSessionsListError = null; + compatClient._lastAppSessionsListErrorLogged = null; + + const originalInfo = console.info; + const originalWarn = console.warn; + let compatSessions; + try { + console.info = () => {}; + console.warn = () => {}; + compatSessions = await withTimeout( + 'compat.getAppSessionsList', + compatClient.getAppSessionsList() + ); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + assertSmoke(Array.isArray(compatSessions), 'compat mapping', 'compat sessions is not an array'); + assertSmoke(compatSessions.length === 0, 'compat mapping', 'expected no compat app sessions'); + assertSmoke( + compatClient.getLastAppSessionsListError() === null, + 'compat mapping', + `compat mapping reported ${compatClient.getLastAppSessionsListError()}` + ); + + logStep('runtime smoke passed'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await writeFailureLogs({ stdoutPath, stderrPath }, stdout, stderr, message); + console.error(message); + if (err instanceof SmokeError) { + console.error(logs()); + } + process.exitCode = 1; + } finally { + try { + if (client) await client.close(); + } finally { + if (child) { + logStep('stopping Clearnode'); + await stopProcess(child); + } + if (process.exitCode) { + console.error(`runtime smoke logs preserved at ${configDir}`); + } else { + await rm(configDir, { recursive: true, force: true }); + } + } + } +} + +await runSmoke(); diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index ac4c8a492..791a85460 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -17,7 +17,15 @@ cd sdk/ts && npm run drift:check cd sdk/ts-compat && npm run drift:check ``` -`./scripts/check-protocol-drift.sh --runtime` is reserved for the future ephemeral Clearnode smoke harness. It intentionally fails until that harness exists. +Run the lightweight runtime smoke from the repo root: + +```bash +./scripts/check-protocol-drift.sh --runtime +``` + +The runtime smoke builds the TS SDK, builds TS compat, builds a temporary local Clearnode binary, starts it with isolated SQLite config, and connects to `ws://127.0.0.1:7824/ws`. It checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping over the live SDK app-session result. + +This is not a load test. It uses empty local `blockchains` and `assets` config so PR CI does not depend on external RPC endpoints, wallets, or shared Clearnode deployments. ## Guard Layers @@ -28,6 +36,7 @@ cd sdk/ts-compat && npm run drift:check - Signing drift: compares TS app-session packers against Go-generated canonical vectors. - Transform drift: checks raw Clearnode response fixtures for app sessions, node config, assets, and strict failure on unsupported required shapes. - Compat drift: checks current v1 app-session shape, legacy flat fallback shape, and asset decimal conversion in `NitroliteClient.getAppSessionsList()`. +- Runtime smoke drift: starts an isolated local Clearnode and verifies live SDK/compat calls against the current runtime response shape. ## Intentional Updates @@ -59,4 +68,10 @@ Each guard includes at least one negative test or mutation-style check that prov `Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Clearnode deployments. +`Test (Protocol Drift Runtime)` also runs on PRs and main pushes. It starts an isolated local Clearnode inside the GitHub Actions job and does not use shared stress or sandbox endpoints. + +If runtime smoke fails in CI, inspect the `protocol-drift-runtime-smoke-logs` artifact. The smoke command categorizes failures as setup, startup, connection, timeout, transform, or compat mapping failures. + +The runtime job uses read-only repository permissions and no secrets. It builds Clearnode locally instead of pulling or publishing an image, so ordinary PRs do not need package-write permissions. If organization policy restricts forked PR workflows, a maintainer can rerun the same command locally or through an allowed CI rerun. + Shared stress Clearnode checks are manual/nightly only. `wss://clearnode-stress.yellow.org/v1/ws` can be newer than sandbox while audit remediations roll out, so it is useful for release confidence but must not be a default PR blocker. From bf0a2b68138392f7745894d0b68031f93e706795 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:17:07 +0530 Subject: [PATCH 03/11] Expand SDK ABI drift coverage --- sdk/PROTOCOL_DRIFT_GUARDS.md | 2 +- sdk/ts/test/unit/abi-drift.test.ts | 271 +++++++++++++++++++++++------ 2 files changed, 222 insertions(+), 51 deletions(-) diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index 791a85460..c621a885d 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -32,7 +32,7 @@ This is not a load test. It uses empty local `blockchains` and `assets` config s - RPC method drift: compares Go RPC method literals, Clearnode 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 runtime API drift: snapshots root runtime exports for `@yellow-org/sdk` and `@yellow-org/sdk-compat`. -- ABI drift: compares SDK-consumed `ChannelHub` ABI functions against the current Foundry artifact. +- ABI drift: compares checked-in `ChannelHub` functions against the current Foundry artifact, checks SDK-consumed ERC20 functions against the ERC20 artifact, and guards the manually checked-in AppRegistry ABI against an explicit consumed-function manifest until that contract artifact exists in this repo. - Signing drift: compares TS app-session packers against Go-generated canonical vectors. - Transform drift: checks raw Clearnode 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()`. diff --git a/sdk/ts/test/unit/abi-drift.test.ts b/sdk/ts/test/unit/abi-drift.test.ts index b9b7096c1..84c36b2f5 100644 --- a/sdk/ts/test/unit/abi-drift.test.ts +++ b/sdk/ts/test/unit/abi-drift.test.ts @@ -2,7 +2,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { AppRegistryAbi } from '../../src/blockchain/evm/app_registry_abi.js'; import { ChannelHubAbi } from '../../src/blockchain/evm/channel_hub_abi.js'; +import { Erc20Abi } from '../../src/blockchain/evm/erc20_abi.js'; const testDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(testDir, '../../../..'); @@ -10,89 +12,258 @@ const repoRoot = path.resolve(testDir, '../../../..'); type AbiEntry = { type: string; name?: string; - inputs?: Array<{ type: string }>; - outputs?: Array<{ type: string }>; + inputs?: AbiParam[]; + outputs?: AbiParam[]; stateMutability?: string; }; +type AbiParam = { + type: string; + components?: AbiParam[]; +}; + +type FunctionDiff = { + contract: string; + name: string; + artifact?: string; + sdk?: string; +}; + +function canonicalType(param: AbiParam): string { + if (!param.components?.length) return param.type; + return `${param.type}<${param.components.map(canonicalType).join(',')}>`; +} + function signature(entry: AbiEntry): string { - const inputs = (entry.inputs ?? []).map((input) => input.type).join(','); - const outputs = (entry.outputs ?? []).map((output) => output.type).join(','); + const inputs = (entry.inputs ?? []).map(canonicalType).join(','); + const outputs = (entry.outputs ?? []).map(canonicalType).join(','); return `${entry.name}(${inputs}) -> (${outputs}) ${entry.stateMutability ?? ''}`; } -function functionSignatures(abi: readonly AbiEntry[]) { +function functionSignatures(abi: readonly AbiEntry[]): Map { + const signaturesByName = new Map(); + + for (const entry of abi) { + if (entry.type !== 'function' || !entry.name) continue; + + const signatures = signaturesByName.get(entry.name) ?? []; + signatures.push(signature(entry)); + signaturesByName.set(entry.name, signatures); + } + return new Map( - abi - .filter((entry) => entry.type === 'function' && entry.name) - .map((entry) => [entry.name as string, signature(entry)]) + [...signaturesByName].map(([name, signatures]) => [name, signatures.sort().join('\n')]) ); } +function loadArtifact(relativePath: string): readonly AbiEntry[] { + const artifact = JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); + return artifact.abi; +} + +function diffConsumedFunctions( + contract: string, + artifactAbi: readonly AbiEntry[], + sdkAbi: readonly AbiEntry[], + consumedFunctions: readonly string[] +): FunctionDiff[] { + const artifactSigs = functionSignatures(artifactAbi); + const sdkSigs = functionSignatures(sdkAbi); + + return consumedFunctions + .map((name) => ({ + contract, + name, + artifact: artifactSigs.get(name), + sdk: sdkSigs.get(name), + })) + .filter( + ({ artifact: artifactSig, sdk: sdkSig }) => + artifactSig !== sdkSig || artifactSig === undefined || sdkSig === undefined + ); +} + +function diffSdkSubsetAgainstManifest( + contract: string, + expectedSignatures: ReadonlyMap, + sdkAbi: readonly AbiEntry[] +): FunctionDiff[] { + const sdkSigs = functionSignatures(sdkAbi); + + return [...expectedSignatures] + .map(([name, expected]) => ({ + contract, + name, + artifact: expected, + sdk: sdkSigs.get(name), + })) + .filter(({ artifact: expected, sdk }) => expected !== sdk); +} + describe('contract ABI drift guards', () => { - it('keeps checked-in ChannelHub ABI aligned with Foundry artifact for SDK-consumed functions', () => { - const artifact = JSON.parse( - fs.readFileSync( - path.join(repoRoot, 'contracts/out/ChannelHub.sol/ChannelHub.json'), - 'utf8' - ) + it('keeps checked-in ChannelHub ABI aligned with Foundry artifact for every artifact function', () => { + const artifactSigs = functionSignatures( + loadArtifact('contracts/out/ChannelHub.sol/ChannelHub.json') ); - const artifactSigs = functionSignatures(artifact.abi); const sdkSigs = functionSignatures(ChannelHubAbi as readonly AbiEntry[]); + expect([...sdkSigs]).toEqual([...artifactSigs]); + }); + + it('keeps SDK-consumed ChannelHub functions aligned with Foundry artifact', () => { const consumedFunctions = [ 'VERSION', 'createChannel', + 'depositToNode', + 'withdrawFromNode', 'depositToChannel', 'withdrawFromChannel', 'checkpointChannel', + 'challengeChannel', 'closeChannel', 'getChannelData', + 'getNodeBalance', 'getNodeValidator', - 'isNodeValidatorActive', - 'registerSessionKey', - 'unregisterSessionKey', - ].filter((name) => artifactSigs.has(name) || sdkSigs.has(name)); - - const diffs = consumedFunctions - .map((name) => ({ - name, - artifact: artifactSigs.get(name), - sdk: sdkSigs.get(name), - })) - .filter(({ artifact: artifactSig, sdk: sdkSig }) => artifactSig !== sdkSig); - - expect(diffs).toEqual([]); + 'getOpenChannels', + ]; + + expect( + diffConsumedFunctions( + 'ChannelHub', + loadArtifact('contracts/out/ChannelHub.sol/ChannelHub.json'), + ChannelHubAbi as readonly AbiEntry[], + consumedFunctions + ) + ).toEqual([]); + }); + + it('keeps checked-in ERC20 ABI aligned with the Foundry artifact for SDK-consumed functions', () => { + const consumedFunctions = [ + 'allowance', + 'approve', + 'balanceOf', + 'decimals', + 'name', + 'symbol', + 'totalSupply', + 'transfer', + 'transferFrom', + ]; + + expect( + diffConsumedFunctions( + 'ERC20', + loadArtifact('contracts/out/ERC20.sol/ERC20.json'), + Erc20Abi as readonly AbiEntry[], + consumedFunctions + ) + ).toEqual([]); + }); + + it('keeps manually checked-in AppRegistry ABI aligned with SDK-consumed function manifest', () => { + // There is currently no AppRegistry/NonSlashableAppRegistry Foundry artifact in this repo. + // Until that source/artifact exists, guard the SDK-consumed ABI surface explicitly. + const expected = new Map([ + ['UNLOCK_PERIOD', 'UNLOCK_PERIOD() -> (uint256) view'], + ['asset', 'asset() -> (address) view'], + ['balanceOf', 'balanceOf(address) -> (uint256) view'], + ['lock', 'lock(address,uint256) -> () nonpayable'], + ['lockStateOf', 'lockStateOf(address) -> (uint8) view'], + ['relock', 'relock() -> () nonpayable'], + ['unlock', 'unlock() -> () nonpayable'], + ['unlockTimestampOf', 'unlockTimestampOf(address) -> (uint256) view'], + ['withdraw', 'withdraw(address) -> () nonpayable'], + ]); + + expect( + diffSdkSubsetAgainstManifest( + 'AppRegistry', + expected, + AppRegistryAbi as readonly AbiEntry[] + ) + ).toEqual([]); }); - it('reports adversarial function signature changes with function names', () => { - const artifactSigs = functionSignatures([ + it('reports adversarial ChannelHub function signature changes with contract and function names', () => { + expect( + diffConsumedFunctions( + 'ChannelHub', + [ + { + type: 'function', + name: 'getNodeValidator', + inputs: [{ type: 'address' }, { type: 'uint8' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, + ], + [ + { + type: 'function', + name: 'getNodeValidator', + inputs: [{ type: 'uint8' }], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, + ], + ['getNodeValidator'] + ) + ).toEqual([ { - type: 'function', + contract: 'ChannelHub', name: 'getNodeValidator', - inputs: [{ type: 'address' }, { type: 'uint8' }], - outputs: [{ type: 'address' }], - stateMutability: 'view', + artifact: 'getNodeValidator(address,uint8) -> (address) view', + sdk: 'getNodeValidator(uint8) -> (address) view', }, ]); - const sdkSigs = functionSignatures([ + }); + + it('reports adversarial ERC20 missing consumed functions', () => { + expect( + diffConsumedFunctions( + 'ERC20', + [ + { + type: 'function', + name: 'approve', + inputs: [{ type: 'address' }, { type: 'uint256' }], + outputs: [{ type: 'bool' }], + stateMutability: 'nonpayable', + }, + ], + [], + ['approve'] + ) + ).toEqual([ { - type: 'function', - name: 'getNodeValidator', - inputs: [{ type: 'uint8' }], - outputs: [{ type: 'address' }], - stateMutability: 'view', + contract: 'ERC20', + name: 'approve', + artifact: 'approve(address,uint256) -> (bool) nonpayable', + sdk: undefined, }, ]); + }); + + it('reports adversarial AppRegistry manifest signature changes', () => { + const expected = new Map([['lock', 'lock(address,uint256) -> () nonpayable']]); - expect({ - name: 'getNodeValidator', - artifact: artifactSigs.get('getNodeValidator'), - sdk: sdkSigs.get('getNodeValidator'), - }).toEqual({ - name: 'getNodeValidator', - artifact: 'getNodeValidator(address,uint8) -> (address) view', - sdk: 'getNodeValidator(uint8) -> (address) view', - }); + 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', + }, + ]); }); }); From bf21067f9926881bdef0d9b8d043612ad0bce2e4 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:25:32 +0530 Subject: [PATCH 04/11] Add public API signature drift snapshots --- sdk/PROTOCOL_DRIFT_GUARDS.md | 2 +- .../public-api-drift.test.ts.snap | 1031 +++++++ .../test/unit/public-api-drift.test.ts | 236 ++ .../public-api-drift.test.ts.snap | 2463 +++++++++++++++++ sdk/ts/test/unit/public-api-drift.test.ts | 236 ++ 5 files changed, 3967 insertions(+), 1 deletion(-) diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index c621a885d..d9f94f3e4 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -31,7 +31,7 @@ This is not a load test. It uses empty local `blockchains` and `assets` config s - RPC method drift: compares Go RPC method literals, Clearnode 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 runtime API drift: snapshots root runtime exports for `@yellow-org/sdk` and `@yellow-org/sdk-compat`. +- Public API drift: snapshots root runtime exports and compiler-derived TypeScript signatures for `@yellow-org/sdk` and `@yellow-org/sdk-compat`, including type-only exports, interfaces, functions, classes, public class methods, enums, constants, and type aliases. - ABI drift: compares checked-in `ChannelHub` functions against the current Foundry artifact, checks SDK-consumed ERC20 functions against the ERC20 artifact, and guards the manually checked-in AppRegistry ABI against an explicit consumed-function manifest until that contract artifact exists in this repo. - Signing drift: compares TS app-session packers against Go-generated canonical vectors. - Transform drift: checks raw Clearnode response fixtures for app sessions, node config, assets, and strict failure on unsupported required shapes. 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 57c310b2b..3d73f8792 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 @@ -1,5 +1,1036 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`compat public runtime API drift guard keeps root TypeScript public API signatures intentional 1`] = ` +[ + { + "declaration": "string", + "kind": "type", + "name": "AccountID", + "type": "string", + }, + { + "kind": "interface", + "name": "AccountInfo", + "properties": [ + "balances: LedgerBalance[]", + "channelCount: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Allocation", + "properties": [ + "amount: bigint", + "destination: Address | string", + "token: Address | string", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string): AllowanceError", + ], + "kind": "class", + "name": "AllowanceError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "interface", + "name": "AppLogic", + "properties": [ + "decode: (encoded: Hex): T", + "encode: (data: T): Hex", + "getAdjudicatorAddress: (): Address", + "getAdjudicatorType: (): string", + "isFinal: (state: T): boolean", + "provideProofs: (channel: Channel, state: T, previousStates: State[]): State[]", + "validateTransition: (channel: Channel, prevState: T, nextState: T): boolean", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSession", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: string", + "nonce: number", + "participants: string[]", + "protocol: string", + "quorum: number", + "sessionData: string", + "status: string", + "version: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AuthChallengeResponse", + "properties": [ + "method: RPCMethod.AuthChallenge", + "params: { challengeMessage: string; }", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AuthRequestParams", + "properties": [ + "address: string", + "allowances: { asset: string; amount: string }[]", + "application: string", + "expires_at: bigint", + "scope: string", + "session_key: string", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "blockchainRPCsFromEnv", + "signatures": [ + "(): Record", + ], + }, + { + "kind": "function", + "name": "buildClientOptions", + "signatures": [ + "(config: CompatClientConfig): Option[]", + ], + }, + { + "kind": "interface", + "name": "Channel", + "properties": [ + "adjudicator: Address", + "challenge: number", + "channelId: string", + "nonce: bigint", + "participants: Address[]", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelData", + "properties": [ + "lastValidState: any", + "stateData: Hex", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ClearNodeAsset", + "properties": [ + "chainId: number", + "decimals: number", + "symbol: string", + "token: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CloseAppSessionRequestParams", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: string", + "quorum_sigs: string[]", + "session_data: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CloseChannelResponseParams", + "properties": [ + "channelId: string", + "serverSignature: string", + "state: any", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CompatClientConfig", + "properties": [ + "blockchainRPCs: Record", + "wsURL: string", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string, code: string): CompatError", + ], + "kind": "class", + "name": "CompatError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "interface", + "name": "ContractAddresses", + "properties": [ + "adjudicator: Address | string", + "custody: Address | string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "convertRPCToClientChannel", + "type": "(ch: any) => any", + }, + { + "kind": "const", + "name": "convertRPCToClientState", + "type": "(st: any, _sig?: string) => any", + }, + { + "kind": "interface", + "name": "CreateAppSessionHashParams", + "properties": [ + "application: string", + "nonce: bigint | number", + "participants: CreateAppSessionHashParticipant[]", + "quorum: number", + "sessionData: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "CreateAppSessionHashParticipant", + "properties": [ + "signatureWeight: number", + "walletAddress: Address | Hex", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "createAppSessionMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "interface", + "name": "CreateAppSessionRequestParams", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "definition: RPCAppDefinition", + "owner_sig: string", + "quorum_sigs: string[]", + "session_data: string", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "createAuthRequestMessage", + "signatures": [ + "(params: AuthRequestParams, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createAuthVerifyMessage", + "signatures": [ + "(signer: MessageSigner, challenge: { params: { challengeMessage: string; }; }, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "function", + "name": "createAuthVerifyMessageWithJWT", + "signatures": [ + "(jwtToken: string, requestId?: number, timestamp?: number): Promise", + ], + }, + { + "kind": "interface", + "name": "CreateChannelResponseParams", + "properties": [ + "channel: any", + "serverSignature: string", + "state: any", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "createCloseAppSessionMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createCloseChannelMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createCreateChannelMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "function", + "name": "createECDSAMessageSigner", + "signatures": [ + "(privateKey: Hex): MessageSigner", + ], + }, + { + "kind": "function", + "name": "createEIP712AuthMessageSigner", + "signatures": [ + "(walletClient: any, partialMessage: { scope: string; session_key: \`0x\${string}\`; expires_at: bigint; allowances: { asset: string; amount: string; }[]; }, domain: { name: string; }): MessageSigner", + ], + }, + { + "kind": "const", + "name": "createGetAppDefinitionMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createGetAppSessionsMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createGetChannelsMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createGetLedgerBalancesMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createPingMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createResizeChannelMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createSubmitAppStateMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "createTransferMessage", + "type": "(..._args: any[]) => Promise", + }, + { + "kind": "const", + "name": "EIP712AuthTypes", + "type": "{ readonly Policy: readonly [{ readonly name: 'challenge'; readonly type: 'string'; }, { readonly name: 'scope'; readonly type: 'string'; }, { readonly name: 'wallet'; readonly type: 'address'; }, { readonly name: 'session_key'; readonly type: 'address'; }, { readonly name: 'expires_at'; readonly type: 'uint64'; }, { readonly name: 'allowances'; readonly type: 'Allowance[]'; }]; readonly Allowance: readonly [{ readonly name: 'asset'; readonly type: 'string'; }, { readonly name: 'amount'; readonly type: 'string'; }]; }", + }, + { + "constructors": [ + "(client: NitroliteClient, callbacks: EventPollerCallbacks, intervalMs?: number): EventPoller", + ], + "kind": "class", + "name": "EventPoller", + "properties": [ + "setInterval: (ms: number): void", + "start: (): void", + "stop: (): void", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "EventPollerCallbacks", + "properties": [ + "onAssetsUpdate: (assets: ClearNodeAsset[]) => void", + "onBalanceUpdate: (balances: LedgerBalance[]) => void", + "onChannelUpdate: (channels: LedgerChannel[]) => void", + "onError: (error: Error) => void", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "FinalState", + "properties": [ + "allocations: [Allocation, Allocation]", + "channelId: string", + "data: Hex", + "intent: number", + "serverSignature: string", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "GetAppDefinitionResponseParams", + "properties": [ + "challenge: number", + "nonce: number", + "participants: Address[]", + "protocol: string", + "quorum: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "getUserFacingMessage", + "signatures": [ + "(error: unknown): string", + ], + }, + { + "constructors": [ + "(message: string): InsufficientFundsError", + ], + "kind": "class", + "name": "InsufficientFundsError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "declaration": "Address | \`0x\${string}\`", + "kind": "type", + "name": "LedgerAccountType", + "type": "\`0x\${string}\`", + }, + { + "kind": "interface", + "name": "LedgerBalance", + "properties": [ + "amount: string", + "asset: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "LedgerChannel", + "properties": [ + "adjudicator: string", + "amount: bigint", + "chain_id: number", + "challenge: number", + "channel_id: string", + "created_at: string", + "nonce: number", + "participant: string", + "status: string", + "token: string", + "updated_at: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "LedgerEntry", + "properties": [ + "account_id: string", + "account_type: number", + "asset: string", + "created_at: string", + "credit: string", + "debit: string", + "id: number", + "participant: string", + ], + "signatures": [], + }, + { + "declaration": "(payload: MessageSignerPayload) => Promise", + "kind": "type", + "name": "MessageSigner", + "type": "MessageSigner", + }, + { + "constructors": [ + "(client: Client, userAddress: Address, chainId: number, blockchainRPCs?: Record): NitroliteClient", + ], + "kind": "class", + "name": "NitroliteClient", + "properties": [ + "acknowledge: (tokenAddress: Address): Promise", + "approveSecurityToken: (chainId: number, amount: bigint): Promise", + "cancelSecurityTokensWithdrawal: (chainId: number): Promise", + "challengeChannel: (params: { state: any; }): Promise", + "checkTokenAllowance: (chainId: number, tokenAddress: Address): Promise", + "close: (): Promise", + "closeAppSession: (appSessionIdOrParams: string | CloseAppSessionRequestParams, allocations?: RPCAppSessionAllocation[], quorumSigs?: string[]): Promise<{ appSessionId: string; }>", + "closeChannel: (params?: { tokenAddress?: Address | string; } | any): Promise", + "createAppSession: (definitionOrParams: RPCAppDefinition | CreateAppSessionRequestParams, allocations?: RPCAppSessionAllocation[], quorumSigs?: string[], opts?: { ownerSig?: string; }): Promise<{ appSessionId: string; version: string; status: string; }>", + "createChannel: (_respParams?: any): Promise", + "deposit: (tokenAddress: Address, amount: bigint): Promise", + "depositAndCreateChannel: (tokenAddress: Address, amount: bigint, _respParams?: any): Promise", + "findOpenChannel: (tokenAddress: Address | string, chainId?: number): LedgerChannel | null", + "formatAmount: (tokenAddress: Address | string, rawAmount: bigint): Promise", + "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", + "getChannelData: (_channelId: string): Promise", + "getChannels: (): Promise", + "getConfig: (): Promise", + "getEscrowChannel: (escrowChannelId: string): Promise", + "getLastAppSessionsListError: (): string | null", + "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLedgerEntries: (wallet?: Address): Promise", + "getLockedBalance: (chainId: number, wallet?: Address): Promise", + "getTokenDecimals: (tokenAddress: Address | string): Promise", + "initiateSecurityTokensWithdrawal: (chainId: number): Promise", + "innerClient: Client", + "lockSecurityTokens: (targetWallet: Address, chainId: number, amount: bigint): Promise", + "parseAmount: (tokenAddress: Address | string, humanAmount: string): Promise", + "ping: (): Promise", + "refreshAssets: (): Promise", + "registerApp: (appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise", + "resizeChannel: (params: { allocate_amount: bigint; token: Address; }): Promise", + "resolveAsset: (symbol: string): Promise", + "resolveAssetDisplay: (tokenAddress: Address | string, _chainId?: number): Promise<{ symbol: string; decimals: number; } | null>", + "resolveToken: (tokenAddress: Address | string): Promise", + "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "signSessionKeyState: (state: AppSessionKeyStateV1): Promise", + "submitAppState: (params: SubmitAppStateRequestParams): Promise<{ appSessionId: string; version: number; status: string; }>", + "submitChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "submitSessionKeyState: (state: AppSessionKeyStateV1): Promise", + "transfer: (destination: Address, allocations: TransferAllocation[]): Promise", + "userAddress: Address", + "waitForClose: (): Promise", + "withdrawSecurityTokens: (chainId: number, destination: Address): Promise", + "withdrawal: (tokenAddress: Address, amount: bigint): Promise", + ], + "staticProperties": [ + "classifyError: (error: unknown): Error", + "create: (config: NitroliteClientConfig): Promise", + ], + }, + { + "kind": "interface", + "name": "NitroliteClientConfig", + "properties": [ + "addresses: ContractAddresses", + "blockchainRPCs: Record", + "chainId: number", + "challengeDuration: bigint", + "channelSessionKeySigner: { sessionKeyPrivateKey: Hex; walletAddress: Address; metadataHash: Hex; authSig: Hex; }", + "walletClient: WalletClient", + "wsURL: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NitroliteRPC", + "type": "{ createRequest(opts: { requestId: number; method: string; params: any; timestamp: number; }): NitroliteRPCMessage; signRequestMessage(msg: NitroliteRPCMessage, signer: MessageSigner): Promise; }", + }, + { + "kind": "interface", + "name": "NitroliteRPCMessage", + "properties": [ + "req: NitroliteRPCRequest", + "sig: string", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string): NotInitializedError", + ], + "kind": "class", + "name": "NotInitializedError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "constructors": [ + "(message: string): OngoingStateTransitionError", + ], + "kind": "class", + "name": "OngoingStateTransitionError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "function", + "name": "packCreateAppSessionHash", + "signatures": [ + "(params: CreateAppSessionHashParams): Hex", + ], + }, + { + "kind": "function", + "name": "packSubmitAppStateHash", + "signatures": [ + "(params: SubmitAppStateHashParams): Hex", + ], + }, + { + "kind": "function", + "name": "parseAnyRPCResponse", + "signatures": [ + "(raw: string): RPCResponse", + ], + }, + { + "kind": "const", + "name": "parseCloseAppSessionResponse", + "type": "(raw: string) => { params: { appSessionId: any; }; }", + }, + { + "kind": "const", + "name": "parseCloseChannelResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseCreateAppSessionResponse", + "type": "(raw: string) => { params: { appSessionId: any; }; }", + }, + { + "kind": "const", + "name": "parseCreateChannelResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseGetAppDefinitionResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseGetAppSessionsResponse", + "type": "(raw: string) => { params: { appSessions: any; }; }", + }, + { + "kind": "const", + "name": "parseGetChannelsResponse", + "type": "(raw: string) => { params: { channels: any; }; }", + }, + { + "kind": "const", + "name": "parseGetLedgerBalancesResponse", + "type": "(raw: string) => { params: { ledgerBalances: any; }; }", + }, + { + "kind": "const", + "name": "parseGetLedgerEntriesResponse", + "type": "(raw: string) => { params: { ledgerEntries: any; }; }", + }, + { + "kind": "const", + "name": "parseResizeChannelResponse", + "type": "(raw: string) => { params: any; }", + }, + { + "kind": "const", + "name": "parseSubmitAppStateResponse", + "type": "(raw: string) => { params: { appSessionId: any; version: any; status: any; }; }", + }, + { + "declaration": "number", + "kind": "type", + "name": "RequestID", + "type": "number", + }, + { + "kind": "interface", + "name": "ResizeChannelRequestParams", + "properties": [ + "allocate_amount: bigint", + "channel_id: string", + "funds_destination: Address | string", + "resize_amount: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCAppDefinition", + "properties": [ + "application: string", + "challenge: number", + "nonce: number", + "participants: Hex[]", + "protocol: RPCProtocolVersion", + "quorum: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCAppSession", + "properties": [ + "appSessionId: Hex", + "application: string", + "challenge: number", + "createdAt: Date", + "nonce: number", + "participants: Address[]", + "protocol: RPCProtocolVersion", + "quorum: number", + "sessionData: string", + "status: RPCChannelStatus", + "updatedAt: Date", + "version: number", + "weights: number[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCAppSessionAllocation", + "properties": [ + "amount: string", + "asset: string", + "participant: Address", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Operate = 'operate'", + "Deposit = 'deposit'", + "Withdraw = 'withdraw'", + ], + "name": "RPCAppStateIntent", + }, + { + "kind": "interface", + "name": "RPCAsset", + "properties": [ + "chainId: number", + "decimals: number", + "symbol: string", + "token: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCBalance", + "properties": [ + "amount: string", + "asset: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Open = 'open'", + "Closed = 'closed'", + "Resizing = 'resizing'", + "Challenged = 'challenged'", + ], + "name": "RPCChannelStatus", + }, + { + "kind": "interface", + "name": "RPCChannelUpdate", + "properties": [ + "adjudicator: string", + "amount: bigint", + "chainId: number", + "challenge: number", + "channelId: string", + "createdAt: string", + "nonce: number", + "participant: string", + "status: string", + "token: string", + "updatedAt: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCLedgerEntry", + "properties": [ + "account_id: string", + "account_type: number", + "asset: string", + "created_at: string", + "credit: string", + "debit: string", + "id: number", + "participant: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Ping = 'ping'", + "GetConfig = 'get_config'", + "GetChannels = 'get_channels'", + "ChannelsUpdate = 'channels_update'", + "ChannelUpdate = 'channel_update'", + "BalanceUpdate = 'balance_update'", + "GetAssets = 'get_assets'", + "Assets = 'assets'", + "GetLedgerBalances = 'get_ledger_balances'", + "GetLedgerEntries = 'get_ledger_entries'", + "GetAppSessions = 'get_app_sessions'", + "CreateChannel = 'create_channel'", + "CloseChannel = 'close_channel'", + "ResizeChannel = 'resize_channel'", + "Transfer = 'transfer'", + "CreateAppSession = 'create_app_session'", + "CloseAppSession = 'close_app_session'", + "SubmitAppState = 'submit_app_state'", + "GetAppDefinition = 'get_app_definition'", + "AuthRequest = 'auth_request'", + "AuthChallenge = 'auth_challenge'", + "AuthVerify = 'auth_verify'", + "Error = 'error'", + "GetLedgerTransactions = 'get_ledger_transactions'", + "TransferNotification = 'tr'", + ], + "name": "RPCMethod", + }, + { + "kind": "enum", + "members": [ + "NitroRPC_0_2 = 'NitroRPC/0.2'", + "NitroRPC_0_4 = 'NitroRPC/0.4'", + ], + "name": "RPCProtocolVersion", + }, + { + "kind": "interface", + "name": "RPCResponse", + "properties": [ + "method: string", + "params: any", + "requestId: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "RPCTransaction", + "properties": [ + "amount: string", + "asset: string", + "createdAt: Date", + "fromAccount: LedgerAccountType", + "fromAccountTag: string", + "id: number", + "toAccount: LedgerAccountType", + "toAccountTag: string", + "txType: RPCTxType", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Transfer = 'transfer'", + "Deposit = 'deposit'", + "Withdrawal = 'withdrawal'", + "AppDeposit = 'app_deposit'", + "AppWithdrawal = 'app_withdrawal'", + "EscrowLock = 'escrow_lock'", + "EscrowUnlock = 'escrow_unlock'", + ], + "name": "RPCTxType", + }, + { + "kind": "interface", + "name": "State", + "properties": [ + "allocations: Allocation[]", + "channelId: string", + "data: Hex", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SubmitAppStateHashAllocation", + "properties": [ + "amount: string", + "asset: string", + "participant: Address | Hex", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SubmitAppStateHashParams", + "properties": [ + "allocations: SubmitAppStateHashAllocation[]", + "appSessionId: Hex | string", + "intent: RPCAppStateIntent | 'close' | number", + "sessionData: string", + "version: bigint | number", + ], + "signatures": [], + }, + { + "declaration": "SubmitAppStateRequestParamsV02 | SubmitAppStateRequestParamsV04", + "kind": "type", + "name": "SubmitAppStateRequestParams", + "type": "SubmitAppStateRequestParams", + }, + { + "kind": "interface", + "name": "SubmitAppStateRequestParamsV02", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: Hex", + "quorum_sigs: string[]", + "session_data: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SubmitAppStateRequestParamsV04", + "properties": [ + "allocations: RPCAppSessionAllocation[]", + "app_session_id: Hex", + "intent: RPCAppStateIntent", + "quorum_sigs: string[]", + "session_data: string", + "version: number", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "toSessionKeyQuorumSignature", + "signatures": [ + "(signature: Hex | string): Hex", + ], + }, + { + "kind": "function", + "name": "toWalletQuorumSignature", + "signatures": [ + "(signature: Hex | string): Hex", + ], + }, + { + "kind": "interface", + "name": "TransferAllocation", + "properties": [ + "amount: string", + "asset: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "TransferNotificationResponseParams", + "properties": [ + "transactions: RPCTransaction[]", + ], + "signatures": [], + }, + { + "constructors": [ + "(message: string): UserRejectedError", + ], + "kind": "class", + "name": "UserRejectedError", + "properties": [ + "code: string", + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "constructors": [ + "(walletClient: any): WalletStateSigner", + ], + "kind": "class", + "name": "WalletStateSigner", + "properties": [ + "address: Hex", + "sign: (data: Uint8Array): Promise", + ], + "staticProperties": [], + }, +] +`; + exports[`compat public runtime API drift guard keeps root runtime exports intentional 1`] = ` [ "AllowanceError", diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts index 8644059d6..4abdd7398 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -1,14 +1,250 @@ import * as publicApi from '../../src/index.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(testDir, '../..'); + +const FORMAT_FLAGS = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType | + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope; + +type PublicApiMember = { + name: string; + kind: string; + signatures?: string[]; + constructors?: string[]; + properties?: string[]; + staticProperties?: string[]; + members?: string[]; + type?: string; + declaration?: string; +}; + +function normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function createPackageProgram() { + const configPath = ts.findConfigFile(packageRoot, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) throw new Error(`tsconfig.json not found under ${packageRoot}`); + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')); + } + + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageRoot); + return ts.createProgram(parsed.fileNames, parsed.options); +} + +function declarationKind(declaration: ts.Declaration): string { + if (ts.isClassDeclaration(declaration)) return 'class'; + if (ts.isInterfaceDeclaration(declaration)) return 'interface'; + if (ts.isFunctionDeclaration(declaration)) return 'function'; + if (ts.isEnumDeclaration(declaration)) return 'enum'; + if (ts.isTypeAliasDeclaration(declaration)) return 'type'; + if (ts.isVariableDeclaration(declaration)) return 'const'; + return ts.SyntaxKind[declaration.kind] ?? 'unknown'; +} + +function signaturesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return type + .getCallSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); +} + +function isPrivateOrProtected(declaration: ts.Declaration): boolean { + const flags = ts.getCombinedModifierFlags(declaration as ts.Declaration); + return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); +} + +function propertiesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return checker + .getPropertiesOfType(type) + .flatMap((property) => { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] ?? declaration; + if (isPrivateOrProtected(propertyDeclaration)) return []; + + const propertyType = checker.getTypeOfSymbolAtLocation(property, propertyDeclaration); + const signatures = signaturesForType(checker, propertyType, propertyDeclaration); + if (signatures.length > 0) { + return [`${property.getName()}: ${signatures.join(' | ')}`]; + } + if ( + (ts.isPropertySignature(propertyDeclaration) || + ts.isPropertyDeclaration(propertyDeclaration)) && + propertyDeclaration.type + ) { + return [`${property.getName()}: ${normalizeText(propertyDeclaration.type.getText())}`]; + } + return [ + `${property.getName()}: ${checker.typeToString(propertyType, propertyDeclaration, FORMAT_FLAGS)}`, + ]; + }) + .sort(); +} + +function enumMembers(declaration: ts.EnumDeclaration): string[] { + return declaration.members.map((member) => { + const initializer = member.initializer ? normalizeText(member.initializer.getText()) : ''; + return `${member.name.getText()} = ${initializer}`; + }); +} + +function serializePublicApi(): PublicApiMember[] { + const program = createPackageProgram(); + const checker = program.getTypeChecker(); + const entrypoint = program.getSourceFile(path.join(packageRoot, 'src/index.ts')); + if (!entrypoint) throw new Error('src/index.ts not found in program'); + + const moduleSymbol = checker.getSymbolAtLocation(entrypoint); + if (!moduleSymbol) throw new Error('src/index.ts module symbol not found'); + + return checker + .getExportsOfModule(moduleSymbol) + .filter((symbol) => symbol.getName() !== '__esModule') + .map((exportedSymbol) => { + const symbol = + exportedSymbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(exportedSymbol) + : exportedSymbol; + const declaration = symbol.getDeclarations()?.[0]; + if (!declaration) { + return { + name: exportedSymbol.getName(), + kind: 'unknown', + }; + } + + const kind = declarationKind(declaration); + const member: PublicApiMember = { + name: exportedSymbol.getName(), + kind, + }; + + if (ts.isClassDeclaration(declaration)) { + const staticType = checker.getTypeOfSymbolAtLocation(symbol, declaration); + const instanceType = checker.getDeclaredTypeOfSymbol(symbol); + member.constructors = staticType + .getConstructSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); + member.properties = propertiesForType(checker, instanceType, declaration); + member.staticProperties = propertiesForType(checker, staticType, declaration).filter( + (property) => !['length', 'name', 'prototype'].some((skip) => property.startsWith(`${skip}:`)) + ); + } else if (ts.isInterfaceDeclaration(declaration)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + member.properties = propertiesForType(checker, type, declaration); + member.signatures = signaturesForType(checker, type, declaration); + } else if (ts.isFunctionDeclaration(declaration)) { + member.signatures = signaturesForType( + checker, + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration + ); + } else if (ts.isEnumDeclaration(declaration)) { + member.members = enumMembers(declaration); + } else if (ts.isTypeAliasDeclaration(declaration)) { + member.declaration = normalizeText(declaration.type.getText()); + member.type = checker.typeToString( + checker.getTypeFromTypeNode(declaration.type), + declaration, + FORMAT_FLAGS + ); + } else if (ts.isVariableDeclaration(declaration)) { + member.type = checker.typeToString( + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration, + FORMAT_FLAGS + ); + } + + return member; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} describe('compat public runtime API drift guard', () => { it('keeps root runtime exports intentional', () => { expect(Object.keys(publicApi).sort()).toMatchSnapshot(); }); + it('keeps root TypeScript public API signatures intentional', () => { + expect(serializePublicApi()).toMatchSnapshot(); + }); + it('proves adversarial public export removal is observable', () => { const exports = new Set(Object.keys(publicApi)); exports.delete('NitroliteClient'); expect(exports.has('NitroliteClient')).toBe(false); }); + + it('proves adversarial public signature changes are observable', () => { + const api = serializePublicApi(); + const client = api.find((member) => member.name === 'NitroliteClient'); + expect(client?.properties?.some((property) => property.includes('ping:'))).toBe(true); + + const mutated = api.map((member) => + member.name === 'NitroliteClient' + ? { + ...member, + properties: member.properties?.filter((property) => !property.includes('ping:')), + } + : member + ); + const mutatedClient = mutated.find((member) => member.name === 'NitroliteClient'); + + expect(mutatedClient?.properties?.some((property) => property.includes('ping:'))).toBe(false); + }); + + it('proves adversarial type-only export removal is observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === 'NitroliteClientConfig' && member.kind === 'interface')).toBe(true); + + const mutated = api.filter((member) => member.name !== 'NitroliteClientConfig'); + expect(mutated.some((member) => member.name === 'NitroliteClientConfig')).toBe(false); + }); + + it('proves adversarial function parameter changes are observable', () => { + const api = serializePublicApi(); + const builder = api.find((member) => member.name === 'buildClientOptions'); + const original = builder?.signatures?.[0] ?? ''; + expect(original).toContain('config: CompatClientConfig'); + + const mutated = original.replace('config: CompatClientConfig', 'config: unknown'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial enum value changes are observable', () => { + const api = serializePublicApi(); + const method = api.find((member) => member.name === 'RPCMethod'); + const original = method?.members?.join('|') ?? ''; + expect(original).toContain('Ping'); + + const mutated = original.replace('Ping', 'PingChanged'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial public export additions are observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === '__FakeExport')).toBe(false); + + const mutated = [...api, { name: '__FakeExport', kind: 'function' }]; + expect(mutated.some((member) => member.name === '__FakeExport')).toBe(true); + }); }); diff --git a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index a6be5c389..c86cd2df1 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 @@ -1,5 +1,2468 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +exports[`SDK public runtime API drift guard keeps root TypeScript public API signatures intentional 1`] = ` +[ + { + "kind": "interface", + "name": "ActionAllowance", + "properties": [ + "allowance: bigint", + "gatedAction: string", + "timeWindow: string", + "used: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ActionAllowanceV1", + "properties": [ + "allowance: string", + "gated_action: string", + "time_window: string", + "used: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppAllocationV1", + "properties": [ + "amount: Decimal", + "asset: string", + "participant: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppDefinitionV1", + "properties": [ + "applicationId: string", + "nonce: bigint", + "participants: AppParticipantV1[]", + "quorum: number", + ], + "signatures": [], + }, + { + "kind": "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": "function", + "name": "applyAcknowledgementTransition", + "signatures": [ + "(state: State): Transition", + ], + }, + { + "kind": "function", + "name": "applyChannelCreation", + "signatures": [ + "(state: State, channelDef: ChannelDefinition, blockchainId: bigint, tokenAddress: Address, nodeAddress: Address): string", + ], + }, + { + "kind": "function", + "name": "applyCommitTransition", + "signatures": [ + "(state: State, accountId: string, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyEscrowDepositTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyEscrowLockTransition", + "signatures": [ + "(state: State, blockchainId: bigint, tokenAddress: Address, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyEscrowWithdrawTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyFinalizeTransition", + "signatures": [ + "(state: State): Transition", + ], + }, + { + "kind": "function", + "name": "applyHomeDepositTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyHomeWithdrawalTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyMigrateTransition", + "signatures": [ + "(state: State, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyMutualLockTransition", + "signatures": [ + "(state: State, blockchainId: bigint, tokenAddress: Address, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyReceiverTransitions", + "signatures": [ + "(state: State, ...transitions: Transition[]): void", + ], + }, + { + "kind": "function", + "name": "applyReleaseTransition", + "signatures": [ + "(state: State, accountId: string, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "applyTransferReceiveTransition", + "signatures": [ + "(state: State, sender: string, amount: Decimal, txId: string): Transition", + ], + }, + { + "kind": "function", + "name": "applyTransferSendTransition", + "signatures": [ + "(state: State, recipient: string, amount: Decimal): Transition", + ], + }, + { + "kind": "interface", + "name": "AppParticipantV1", + "properties": [ + "signatureWeight: number", + "walletAddress: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionInfoV1", + "properties": [ + "allocations: AppAllocationV1[]", + "appDefinition: AppDefinitionV1", + "appSessionId: string", + "isClosed: boolean", + "sessionData: string", + "version: bigint", + ], + "signatures": [], + }, + { + "constructors": [ + "(inner: StateSigner): AppSessionKeySignerV1", + ], + "kind": "class", + "name": "AppSessionKeySignerV1", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "AppSessionKeyStateV1", + "properties": [ + "app_session_ids: string[]", + "application_ids: string[]", + "expires_at: string", + "session_key: string", + "user_address: string", + "user_sig: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Void = 0", + "Open = 1", + "Closed = 2", + ], + "name": "AppSessionStatus", + }, + { + "kind": "function", + "name": "appSessionStatusToString", + "signatures": [ + "(status: AppSessionStatus): string", + ], + }, + { + "kind": "const", + "name": "AppSessionsV1CreateAppSessionMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1CreateAppSessionRequest", + "properties": [ + "definition: AppDefinitionV1", + "owner_sig: string", + "quorum_sigs: string[]", + "session_data: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1CreateAppSessionResponse", + "properties": [ + "app_session_id: string", + "status: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1GetAppDefinitionMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppDefinitionRequest", + "properties": [ + "app_session_id: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppDefinitionResponse", + "properties": [ + "definition: AppDefinitionV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1GetAppSessionsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppSessionsRequest", + "properties": [ + "app_session_id: string", + "pagination: PaginationParamsV1", + "participant: Address", + "status: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1GetAppSessionsResponse", + "properties": [ + "app_sessions: AppSessionInfoV1[]", + "metadata: PaginationMetadataV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1GetLastKeyStatesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1GetLastKeyStatesRequest", + "properties": [ + "session_key: string", + "user_address: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1GetLastKeyStatesResponse", + "properties": [ + "states: AppSessionKeyStateV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1Group", + "type": "string", + }, + { + "kind": "const", + "name": "AppSessionsV1RebalanceAppSessionsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1RebalanceAppSessionsRequest", + "properties": [ + "signed_updates: SignedAppStateUpdateV1[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1RebalanceAppSessionsResponse", + "properties": [ + "batch_id: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1SubmitAppStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitAppStateRequest", + "properties": [ + "app_state_update: AppStateUpdateV1", + "quorum_sigs: string[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitAppStateResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1SubmitDepositStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitDepositStateRequest", + "properties": [ + "app_state_update: AppStateUpdateV1", + "quorum_sigs: string[]", + "user_state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitDepositStateResponse", + "properties": [ + "signature: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppSessionsV1SubmitSessionKeyStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitSessionKeyStateRequest", + "properties": [ + "state: AppSessionKeyStateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionsV1SubmitSessionKeyStateResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionV1", + "properties": [ + "application: string", + "createdAt: Date", + "nonce: bigint", + "participants: AppParticipantV1[]", + "quorum: number", + "sessionData: string", + "sessionId: string", + "status: AppSessionStatus", + "updatedAt: Date", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppSessionVersionV1", + "properties": [ + "sessionId: string", + "version: bigint", + ], + "signatures": [], + }, + { + "constructors": [ + "(inner: StateSigner): AppSessionWalletSignerV1", + ], + "kind": "class", + "name": "AppSessionWalletSignerV1", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "enum", + "members": [ + "Operate = 0", + "Deposit = 1", + "Withdraw = 2", + "Close = 3", + "Rebalance = 4", + ], + "name": "AppStateUpdateIntent", + }, + { + "kind": "function", + "name": "appStateUpdateIntentToString", + "signatures": [ + "(intent: AppStateUpdateIntent): string", + ], + }, + { + "kind": "interface", + "name": "AppStateUpdateV1", + "properties": [ + "allocations: AppAllocationV1[]", + "appSessionId: string", + "intent: AppStateUpdateIntent", + "sessionData: string", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppsV1GetAppsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppsV1GetAppsRequest", + "properties": [ + "app_id: string", + "owner_wallet: string", + "pagination: PaginationParamsV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppsV1GetAppsResponse", + "properties": [ + "apps: AppInfoV1[]", + "metadata: PaginationMetadataV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "AppsV1Group", + "type": "string", + }, + { + "kind": "const", + "name": "AppsV1SubmitAppVersionMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "AppsV1SubmitAppVersionRequest", + "properties": [ + "app: AppV1", + "owner_sig: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppsV1SubmitAppVersionResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "AppV1", + "properties": [ + "creation_approval_not_required: boolean", + "id: string", + "metadata: string", + "owner_wallet: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Asset", + "properties": [ + "decimals: number", + "name: string", + "suggestedBlockchainId: bigint", + "symbol: string", + "tokens: Token[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetAllowance", + "properties": [ + "allowance: Decimal", + "asset: string", + "used: Decimal", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetAllowanceV1", + "properties": [ + "allowance: Decimal", + "asset: string", + "used: Decimal", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetStore", + "properties": [ + "getAssetDecimals: (asset: string): Promise", + "getTokenDecimals: (blockchainId: bigint, tokenAddress: Address): Promise", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "AssetV1", + "properties": [ + "decimals: number", + "name: string", + "suggested_blockchain_id: string", + "symbol: string", + "tokens: TokenV1[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BalanceEntry", + "properties": [ + "asset: string", + "balance: Decimal", + "enforced: Decimal", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BalanceEntryV1", + "properties": [ + "amount: string", + "asset: string", + "enforced: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Blockchain", + "properties": [ + "blockStep: bigint", + "channelHubAddress: Address", + "id: bigint", + "lockingContractAddress: Address", + "name: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BlockchainEvent", + "properties": [ + "blockNumber: bigint", + "blockchainId: bigint", + "contractAddress: Address", + "logIndex: number", + "name: string", + "transactionHash: Hex", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BlockchainEventHandler", + "properties": [ + "handleEscrowDepositChallenged: (event: EscrowDepositChallengedEvent): Promise", + "handleEscrowDepositFinalized: (event: EscrowDepositFinalizedEvent): Promise", + "handleEscrowDepositInitiated: (event: EscrowDepositInitiatedEvent): Promise", + "handleEscrowWithdrawalChallenged: (event: EscrowWithdrawalChallengedEvent): Promise", + "handleEscrowWithdrawalFinalized: (event: EscrowWithdrawalFinalizedEvent): Promise", + "handleEscrowWithdrawalInitiated: (event: EscrowWithdrawalInitiatedEvent): Promise", + "handleHomeChannelChallenged: (event: HomeChannelChallengedEvent): Promise", + "handleHomeChannelCheckpointed: (event: HomeChannelCheckpointedEvent): Promise", + "handleHomeChannelClosed: (event: HomeChannelClosedEvent): Promise", + "handleHomeChannelCreated: (event: HomeChannelCreatedEvent): Promise", + "handleHomeChannelMigrated: (event: HomeChannelMigratedEvent): Promise", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "BlockchainInfoV1", + "properties": [ + "blockchain_id: string", + "channel_hub_address: Address", + "locking_contract_address: Address", + "name: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Channel", + "properties": [ + "approvedSigValidators: string", + "asset: string", + "blockchainId: bigint", + "challengeDuration: number", + "challengeExpiresAt: Date", + "channelId: string", + "nonce: bigint", + "stateVersion: bigint", + "status: ChannelStatus", + "tokenAddress: Address", + "type: ChannelType", + "userWallet: Address", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "CHANNEL_HUB_VERSION", + "type": "1", + }, + { + "kind": "interface", + "name": "ChannelChallengedEvent", + "properties": [ + "challengeExpiry: bigint", + "channelId: string", + "stateVersion: bigint", + ], + "signatures": [], + }, + { + "constructors": [ + "(inner: StateSigner): ChannelDefaultSigner", + ], + "kind": "class", + "name": "ChannelDefaultSigner", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "ChannelDefinition", + "properties": [ + "approvedSigValidators: string", + "challenge: number", + "nonce: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelDefinitionV1", + "properties": [ + "approved_sig_validators: string", + "challenge: number", + "nonce: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelEvent", + "properties": [ + "channelId: string", + "stateVersion: bigint", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "User = 0", + "Node = 1", + ], + "name": "ChannelParticipant", + }, + { + "constructors": [ + "(sessionKeyPrivateKey: Hex, walletAddress: Address, metadataHash: Hex, authSignature: Hex): ChannelSessionKeyStateSigner", + ], + "kind": "class", + "name": "ChannelSessionKeyStateSigner", + "properties": [ + "getAddress: (): Address", + "getSessionKeyAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "ChannelSessionKeyStateV1", + "properties": [ + "assets: string[]", + "expires_at: string", + "session_key: string", + "user_address: string", + "user_sig: string", + "version: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Default = 0x00", + "SessionKey = 0x01", + ], + "name": "ChannelSignerType", + }, + { + "kind": "enum", + "members": [ + "Void = 0", + "Open = 1", + "Challenged = 2", + "Closed = 3", + ], + "name": "ChannelStatus", + }, + { + "kind": "const", + "name": "ChannelsV1GetChannelsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetChannelsRequest", + "properties": [ + "asset: string", + "channel_type: string", + "pagination: PaginationParamsV1", + "status: string", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetChannelsResponse", + "properties": [ + "channels: ChannelV1[]", + "metadata: PaginationMetadataV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetEscrowChannelMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetEscrowChannelRequest", + "properties": [ + "escrow_channel_id: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetEscrowChannelResponse", + "properties": [ + "channel: ChannelV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetHomeChannelMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetHomeChannelRequest", + "properties": [ + "asset: string", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetHomeChannelResponse", + "properties": [ + "channel: ChannelV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetLastKeyStatesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetLastKeyStatesRequest", + "properties": [ + "session_key: string", + "user_address: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetLastKeyStatesResponse", + "properties": [ + "states: ChannelSessionKeyStateV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1GetLatestStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1GetLatestStateRequest", + "properties": [ + "asset: string", + "only_signed: boolean", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1GetLatestStateResponse", + "properties": [ + "state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1HomeChannelCreatedEvent", + "properties": [ + "channel: ChannelV1", + "initial_state: StateV1", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1RequestCreationMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1RequestCreationRequest", + "properties": [ + "channel_definition: ChannelDefinitionV1", + "state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1RequestCreationResponse", + "properties": [ + "signature: string", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1SubmitSessionKeyStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitSessionKeyStateRequest", + "properties": [ + "state: ChannelSessionKeyStateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitSessionKeyStateResponse", + "properties": [], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelsV1SubmitStateMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitStateRequest", + "properties": [ + "state: StateV1", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "ChannelsV1SubmitStateResponse", + "properties": [ + "signature: string", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "Home = 1", + "Escrow = 2", + ], + "name": "ChannelType", + }, + { + "kind": "interface", + "name": "ChannelV1", + "properties": [ + "approved_sig_validators: string", + "asset: string", + "blockchain_id: string", + "challenge_duration: number", + "challenge_expires_at: string", + "channel_id: string", + "nonce: string", + "state_version: string", + "status: string", + "token_address: Address", + "type: string", + "user_wallet: Address", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ChannelV1Group", + "type": "string", + }, + { + "constructors": [ + "(rpcClient: RPCClient, config: Config, stateSigner: StateSigner, txSigner: TransactionSigner, assetStore: ClientAssetStore): Client", + ], + "kind": "class", + "name": "Client", + "properties": [ + "acknowledge: (asset: string): Promise", + "approveSecurityToken: (chainId: bigint, amount: Decimal): Promise", + "approveToken: (chainId: bigint, asset: string, amount: Decimal): Promise", + "cancelSecurityTokensWithdrawal: (blockchainId: bigint): Promise", + "challenge: (state: core.State): Promise", + "checkTokenAllowance: (chainId: bigint, tokenAddress: string, owner: string): Promise", + "checkpoint: (asset: string): Promise", + "close: (): Promise", + "closeHomeChannel: (asset: string): Promise", + "createAppSession: (definition: app.AppDefinitionV1, sessionData: string, quorumSigs: string[], opts?: { ownerSig?: string; }): Promise<{ appSessionId: string; version: string; status: string; }>", + "deposit: (blockchainId: bigint, asset: string, amount: Decimal): Promise", + "escrowSecurityTokens: (targetWalletAddress: string, blockchainId: bigint, amount: Decimal): Promise", + "getActionAllowances: (wallet: Address): Promise", + "getAppDefinition: (appSessionId: string): Promise", + "getAppSessions: (options?: { appSessionId?: string; wallet?: Address; status?: string; page?: number; pageSize?: number; }): Promise<{ sessions: app.AppSessionInfoV1[]; metadata: core.PaginationMetadata; }>", + "getApps: (options?: { appId?: string; ownerWallet?: string; page?: number; pageSize?: number; }): Promise<{ apps: AppInfoV1[]; metadata: core.PaginationMetadata; }>", + "getAssets: (blockchainId?: bigint): Promise", + "getBalances: (wallet: Address): Promise", + "getBlockchains: (): Promise", + "getChannels: (wallet: Address, options?: { status?: string; asset?: string; channelType?: string; pagination?: core.PaginationParams; }): Promise<{ channels: core.Channel[]; metadata: core.PaginationMetadata; }>", + "getConfig: (): Promise", + "getEscrowChannel: (escrowChannelId: string): Promise", + "getHomeChannel: (wallet: Address, asset: string): Promise", + "getLastChannelKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLastKeyStates: (userAddress: string, sessionKey?: string): Promise", + "getLatestState: (wallet: Address, asset: string, onlySigned: boolean): Promise", + "getLockedBalance: (chainId: bigint, wallet: string): Promise", + "getOnChainBalance: (chainId: bigint, asset: string, wallet: Address): Promise", + "getTransactions: (wallet: Address, options?: { asset?: string; txType?: core.TransactionType; fromTime?: bigint; toTime?: bigint; page?: number; pageSize?: number; }): Promise<{ transactions: core.Transaction[]; metadata: core.PaginationMetadata; }>", + "getUserAddress: (): Address", + "initiateSecurityTokensWithdrawal: (blockchainId: bigint): Promise", + "ping: (): Promise", + "rebalanceAppSessions: (signedUpdates: app.SignedAppStateUpdateV1[]): Promise", + "registerApp: (appID: string, metadata: string, creationApprovalNotRequired: boolean): Promise", + "setHomeBlockchain: (asset: string, blockchainId: bigint): Promise", + "signChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "signSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", + "signState: (state: core.State): Promise", + "submitAppSessionDeposit: (appStateUpdate: app.AppStateUpdateV1, quorumSigs: string[], asset: string, depositAmount: Decimal): Promise", + "submitAppState: (appStateUpdate: app.AppStateUpdateV1, quorumSigs: string[]): Promise", + "submitChannelSessionKeyState: (state: ChannelSessionKeyStateV1): Promise", + "submitSessionKeyState: (state: app.AppSessionKeyStateV1): Promise", + "transfer: (recipientWallet: string, asset: string, amount: Decimal): Promise", + "validateAndSignState: (currentState: core.State, proposedState: core.State): Promise", + "waitForClose: (): Promise", + "withdraw: (blockchainId: bigint, asset: string, amount: Decimal): Promise", + "withdrawSecurityTokens: (blockchainId: bigint, destinationWalletAddress: string): Promise", + ], + "staticProperties": [ + "create: (wsURL: string, stateSigner: StateSigner, txSigner: TransactionSigner, ...opts: Option[]): Promise", + ], + }, + { + "constructors": [ + "(getAssetsFn: () => Promise): ClientAssetStore", + ], + "kind": "class", + "name": "ClientAssetStore", + "properties": [ + "assetExistsOnBlockchain: (blockchainId: bigint, asset: string): Promise", + "clearCache: (): void", + "getAssetDecimals: (asset: string): Promise", + "getSuggestedBlockchainId: (asset: string): Promise", + "getTokenAddress: (asset: string, blockchainId: bigint): Promise
", + "getTokenDecimals: (blockchainId: bigint, tokenAddress: string): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "Config", + "properties": [ + "blockchainRPCs: Map", + "errorHandler: (error: Error) => void", + "handshakeTimeout: number", + "pingInterval: number", + "url: string", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "createSigners", + "signatures": [ + "(privateKey: Hex): { stateSigner: StateSigner; txSigner: TransactionSigner; }", + ], + }, + { + "kind": "function", + "name": "decimalToBigInt", + "signatures": [ + "(amount: Decimal, decimals: number): bigint", + ], + }, + { + "kind": "const", + "name": "DEFAULT_CHALLENGE_PERIOD", + "type": "86400", + }, + { + "kind": "const", + "name": "DefaultConfig", + "type": "Partial", + }, + { + "kind": "const", + "name": "DefaultWebsocketDialerConfig", + "type": "WebsocketDialerConfig", + }, + { + "kind": "interface", + "name": "Dialer", + "properties": [ + "call: (req: Message, signal?: AbortSignal): Promise", + "close: (): Promise", + "dial: (url: string, handleClosure: (err?: Error) => void): Promise", + "eventChannel: (): AsyncIterable", + "isConnected: (): boolean", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "ErrAlreadyConnected", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrConnectionTimeout", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrDialingWebsocket", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrInvalidRequestMethod", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrMarshalingRequest", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrNilRequest", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrNoResponse", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrNotConnected", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ERROR_PARAM_KEY", + "type": "'error'", + }, + { + "kind": "function", + "name": "errorf", + "signatures": [ + "(format: string, ...args: unknown[]): RPCError", + ], + }, + { + "kind": "const", + "name": "ErrReadingMessage", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrSendingPing", + "type": "RPCError", + }, + { + "kind": "const", + "name": "ErrSendingRequest", + "type": "RPCError", + }, + { + "declaration": "ChannelChallengedEvent", + "kind": "type", + "name": "EscrowDepositChallengedEvent", + "type": "ChannelChallengedEvent", + }, + { + "kind": "interface", + "name": "EscrowDepositDataResponse", + "properties": [ + "challengeExpiry: bigint", + "escrowChannelId: string", + "lastState: State", + "node: Address", + "unlockExpiry: bigint", + ], + "signatures": [], + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowDepositFinalizedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowDepositInitiatedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelChallengedEvent", + "kind": "type", + "name": "EscrowWithdrawalChallengedEvent", + "type": "ChannelChallengedEvent", + }, + { + "kind": "interface", + "name": "EscrowWithdrawalDataResponse", + "properties": [ + "escrowChannelId: string", + "lastState: State", + "node: Address", + ], + "signatures": [], + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowWithdrawalFinalizedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "EscrowWithdrawalInitiatedEvent", + "type": "ChannelEvent", + }, + { + "constructors": [ + "(privateKeyOrAccount: Hex | ReturnType): EthereumMsgSigner", + ], + "kind": "class", + "name": "EthereumMsgSigner", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "constructors": [ + "(privateKeyOrAccount: Hex | ReturnType): EthereumRawSigner", + ], + "kind": "class", + "name": "EthereumRawSigner", + "properties": [ + "getAccount: (): ReturnType", + "getAddress: (): Address", + "sendTransaction: (tx: any): Promise", + "signMessage: (message: { raw: Hex; }): Promise", + "signPersonalMessage: (hash: Hex): Promise", + ], + "staticProperties": [], + }, + { + "declaration": "string", + "kind": "type", + "name": "Event", + "type": "string", + }, + { + "kind": "SourceFile", + "name": "evm", + }, + { + "kind": "function", + "name": "generateAppSessionIDV1", + "signatures": [ + "(definition: AppDefinitionV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "generateChannelMetadata", + "signatures": [ + "(asset: string): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "generateNonce", + "signatures": [ + "(): bigint", + ], + }, + { + "kind": "function", + "name": "generateRebalanceBatchIDV1", + "signatures": [ + "(sessionVersions: AppSessionVersionV1[]): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "generateRebalanceTransactionIDV1", + "signatures": [ + "(batchId: string, sessionId: string, asset: string): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "getChannelSessionKeyAuthMetadataHashV1", + "signatures": [ + "(version: bigint, assets: string[], expiresAt: bigint): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "getEscrowChannelId", + "signatures": [ + "(homeChannelId: string, stateVersion: bigint): string", + ], + }, + { + "kind": "function", + "name": "getHomeChannelId", + "signatures": [ + "(node: Address, user: Address, asset: string, nonce: bigint, challengeDuration: number, approvedSigValidators?: string): string", + ], + }, + { + "kind": "function", + "name": "getLastTransition", + "signatures": [ + "(state: State): Transition | null", + ], + }, + { + "kind": "function", + "name": "getOffsetAndLimit", + "signatures": [ + "(params: PaginationParams | undefined, defaultLimit: number, maxLimit: number): { offset: number; limit: number; }", + ], + }, + { + "kind": "function", + "name": "getReceiverTransactionId", + "signatures": [ + "(fromAccount: string, receiverNewStateId: string): string", + ], + }, + { + "kind": "function", + "name": "getSenderTransactionId", + "signatures": [ + "(toAccount: string, senderNewStateId: string): string", + ], + }, + { + "kind": "function", + "name": "getStateId", + "signatures": [ + "(userWallet: Address, asset: string, epoch: bigint, version: bigint): string", + ], + }, + { + "kind": "function", + "name": "getStateTransitionHash", + "signatures": [ + "(transition: Transition): string", + ], + }, + { + "declaration": "string", + "kind": "type", + "name": "Group", + "type": "string", + }, + { + "declaration": "ChannelChallengedEvent", + "kind": "type", + "name": "HomeChannelChallengedEvent", + "type": "ChannelChallengedEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelCheckpointedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelClosedEvent", + "type": "ChannelEvent", + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelCreatedEvent", + "type": "ChannelEvent", + }, + { + "kind": "interface", + "name": "HomeChannelDataResponse", + "properties": [ + "challengeExpiry: bigint", + "definition: ChannelDefinition", + "lastState: State", + "node: Address", + ], + "signatures": [], + }, + { + "declaration": "ChannelEvent", + "kind": "type", + "name": "HomeChannelMigratedEvent", + "type": "ChannelEvent", + }, + { + "kind": "const", + "name": "INTENT_CLOSE", + "type": "1", + }, + { + "kind": "const", + "name": "INTENT_DEPOSIT", + "type": "2", + }, + { + "kind": "const", + "name": "INTENT_FINALIZE_ESCROW_DEPOSIT", + "type": "5", + }, + { + "kind": "const", + "name": "INTENT_FINALIZE_ESCROW_WITHDRAWAL", + "type": "7", + }, + { + "kind": "const", + "name": "INTENT_FINALIZE_MIGRATION", + "type": "9", + }, + { + "kind": "const", + "name": "INTENT_INITIATE_ESCROW_DEPOSIT", + "type": "4", + }, + { + "kind": "const", + "name": "INTENT_INITIATE_ESCROW_WITHDRAWAL", + "type": "6", + }, + { + "kind": "const", + "name": "INTENT_INITIATE_MIGRATION", + "type": "8", + }, + { + "kind": "const", + "name": "INTENT_OPERATE", + "type": "0", + }, + { + "kind": "const", + "name": "INTENT_WITHDRAW", + "type": "3", + }, + { + "kind": "function", + "name": "isFinal", + "signatures": [ + "(state: State): boolean", + ], + }, + { + "kind": "interface", + "name": "Ledger", + "properties": [ + "blockchainId: bigint", + "nodeBalance: Decimal", + "nodeNetFlow: Decimal", + "tokenAddress: Address", + "userBalance: Decimal", + "userNetFlow: Decimal", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "ledgerEqual", + "signatures": [ + "(a: Ledger, b: Ledger): string | null", + ], + }, + { + "kind": "interface", + "name": "LedgerV1", + "properties": [ + "blockchain_id: string", + "node_balance: string", + "node_net_flow: string", + "token_address: Address", + "user_balance: string", + "user_net_flow: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Listener", + "properties": [ + "listen: (): Promise", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "marshalMessage", + "signatures": [ + "(message: Message): string", + ], + }, + { + "kind": "interface", + "name": "Message", + "properties": [ + "method: string", + "payload: Payload", + "requestId: number", + "timestamp: number", + "type: MsgType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "messageError", + "signatures": [ + "(message: Message): Error | null", + ], + }, + { + "declaration": "string", + "kind": "type", + "name": "Method", + "type": "string", + }, + { + "kind": "enum", + "members": [ + "Req = 1", + "Resp = 2", + "Event = 3", + "RespErr = 4", + ], + "name": "MsgType", + }, + { + "kind": "function", + "name": "newChannel", + "signatures": [ + "(channelId: string, userWallet: Address, asset: string, type: ChannelType, blockchainId: bigint, tokenAddress: Address, nonce: bigint, challenge: number, approvedSigValidators?: string): Channel", + ], + }, + { + "kind": "function", + "name": "newErrorPayload", + "signatures": [ + "(errMsg: string): Payload", + ], + }, + { + "kind": "function", + "name": "newErrorResponse", + "signatures": [ + "(requestId: number, method: string, errMsg: string): Message", + ], + }, + { + "kind": "function", + "name": "newEvent", + "signatures": [ + "(requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newMessage", + "signatures": [ + "(type: MsgType, requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newPayload", + "signatures": [ + "(v: unknown): Payload", + ], + }, + { + "kind": "function", + "name": "newRequest", + "signatures": [ + "(requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newResponse", + "signatures": [ + "(requestId: number, method: string, payload?: Payload): Message", + ], + }, + { + "kind": "function", + "name": "newRPCClient", + "signatures": [ + "(dialer: Dialer): RPCClient", + ], + }, + { + "kind": "function", + "name": "newStatePackerV1", + "signatures": [ + "(assetStore: AssetStore): StatePackerV1", + ], + }, + { + "kind": "function", + "name": "newTransaction", + "signatures": [ + "(id: string, asset: string, txType: TransactionType, fromAccount: Address, toAccount: Address, amount: Decimal): Transaction", + ], + }, + { + "kind": "function", + "name": "newTransition", + "signatures": [ + "(type: TransitionType, txId: string, accountId: string, amount: Decimal): Transition", + ], + }, + { + "kind": "function", + "name": "newVoidState", + "signatures": [ + "(asset: string, userWallet: Address): State", + ], + }, + { + "kind": "function", + "name": "newWebsocketDialer", + "signatures": [ + "(config?: WebsocketDialerConfig): WebsocketDialer", + ], + }, + { + "kind": "function", + "name": "nextState", + "signatures": [ + "(state: State): State", + ], + }, + { + "kind": "interface", + "name": "NodeConfig", + "properties": [ + "blockchains: Blockchain[]", + "nodeAddress: Address", + "nodeVersion: string", + "supportedSigValidators: number[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NodeV1GetAssetsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "NodeV1GetAssetsRequest", + "properties": [ + "blockchain_id: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "NodeV1GetAssetsResponse", + "properties": [ + "assets: AssetV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NodeV1GetConfigMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "NodeV1GetConfigRequest", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "NodeV1GetConfigResponse", + "properties": [ + "blockchains: BlockchainInfoV1[]", + "node_address: Address", + "node_version: string", + "supported_sig_validators: number[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "NodeV1Group", + "type": "string", + }, + { + "kind": "const", + "name": "NodeV1PingMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "NodeV1PingRequest", + "properties": [], + "signatures": [], + }, + { + "kind": "interface", + "name": "NodeV1PingResponse", + "properties": [], + "signatures": [], + }, + { + "declaration": "(config: Config) => void", + "kind": "type", + "name": "Option", + "type": "Option", + }, + { + "kind": "function", + "name": "packAppSessionKeyStateV1", + "signatures": [ + "(state: AppSessionKeyStateV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packAppStateUpdateV1", + "signatures": [ + "(stateUpdate: AppStateUpdateV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packAppV1", + "signatures": [ + "(app: AppV1): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packChallengeState", + "signatures": [ + "(state: State, assetStore: AssetStore): Promise<\`0x\${string}\`>", + ], + }, + { + "kind": "function", + "name": "packChannelKeyStateV1", + "signatures": [ + "(sessionKey: Address, metadataHash: \`0x\${string}\`): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packCreateAppSessionRequestV1", + "signatures": [ + "(definition: AppDefinitionV1, sessionData: string): \`0x\${string}\`", + ], + }, + { + "kind": "function", + "name": "packState", + "signatures": [ + "(state: State, assetStore: AssetStore): Promise<\`0x\${string}\`>", + ], + }, + { + "kind": "interface", + "name": "PaginationMetadata", + "properties": [ + "page: number", + "pageCount: number", + "perPage: number", + "totalCount: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "PaginationMetadataV1", + "properties": [ + "page: number", + "page_count: number", + "per_page: number", + "total_count: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "PaginationParams", + "properties": [ + "limit: number", + "offset: number", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "PaginationParamsV1", + "properties": [ + "limit: number", + "offset: number", + ], + "signatures": [], + }, + { + "declaration": "Record", + "kind": "type", + "name": "Payload", + "type": "Payload", + }, + { + "kind": "function", + "name": "payloadError", + "signatures": [ + "(payload: Payload): Error | null", + ], + }, + { + "constructors": [ + "(dialer: Dialer): RPCClient", + ], + "kind": "class", + "name": "RPCClient", + "properties": [ + "appSessionsV1CreateAppSession: (req: API.AppSessionsV1CreateAppSessionRequest, signal?: AbortSignal): Promise", + "appSessionsV1GetAppDefinition: (req: API.AppSessionsV1GetAppDefinitionRequest, signal?: AbortSignal): Promise", + "appSessionsV1GetAppSessions: (req: API.AppSessionsV1GetAppSessionsRequest, signal?: AbortSignal): Promise", + "appSessionsV1GetLastKeyStates: (req: API.AppSessionsV1GetLastKeyStatesRequest, signal?: AbortSignal): Promise", + "appSessionsV1RebalanceAppSessions: (req: API.AppSessionsV1RebalanceAppSessionsRequest, signal?: AbortSignal): Promise", + "appSessionsV1SubmitAppState: (req: API.AppSessionsV1SubmitAppStateRequest, signal?: AbortSignal): Promise", + "appSessionsV1SubmitDepositState: (req: API.AppSessionsV1SubmitDepositStateRequest, signal?: AbortSignal): Promise", + "appSessionsV1SubmitSessionKeyState: (req: API.AppSessionsV1SubmitSessionKeyStateRequest, signal?: AbortSignal): Promise", + "appsV1GetApps: (req: API.AppsV1GetAppsRequest, signal?: AbortSignal): Promise", + "appsV1SubmitAppVersion: (req: API.AppsV1SubmitAppVersionRequest, signal?: AbortSignal): Promise", + "channelsV1GetChannels: (req: API.ChannelsV1GetChannelsRequest, signal?: AbortSignal): Promise", + "channelsV1GetEscrowChannel: (req: API.ChannelsV1GetEscrowChannelRequest, signal?: AbortSignal): Promise", + "channelsV1GetHomeChannel: (req: API.ChannelsV1GetHomeChannelRequest, signal?: AbortSignal): Promise", + "channelsV1GetLastKeyStates: (req: API.ChannelsV1GetLastKeyStatesRequest, signal?: AbortSignal): Promise", + "channelsV1GetLatestState: (req: API.ChannelsV1GetLatestStateRequest, signal?: AbortSignal): Promise", + "channelsV1RequestCreation: (req: API.ChannelsV1RequestCreationRequest, signal?: AbortSignal): Promise", + "channelsV1SubmitSessionKeyState: (req: API.ChannelsV1SubmitSessionKeyStateRequest, signal?: AbortSignal): Promise", + "channelsV1SubmitState: (req: API.ChannelsV1SubmitStateRequest, signal?: AbortSignal): Promise", + "close: (): Promise", + "eventChannel: (): AsyncIterable", + "isConnected: (): boolean", + "nodeV1GetAssets: (req: API.NodeV1GetAssetsRequest, signal?: AbortSignal): Promise", + "nodeV1GetConfig: (signal?: AbortSignal): Promise", + "nodeV1Ping: (signal?: AbortSignal): Promise", + "start: (url: string, handleClosure: (err?: Error) => void): Promise", + "userV1GetActionAllowances: (req: API.UserV1GetActionAllowancesRequest, signal?: AbortSignal): Promise", + "userV1GetBalances: (req: API.UserV1GetBalancesRequest, signal?: AbortSignal): Promise", + "userV1GetTransactions: (req: API.UserV1GetTransactionsRequest, signal?: AbortSignal): Promise", + ], + "staticProperties": [], + }, + { + "constructors": [ + "(message: string): RPCError", + ], + "kind": "class", + "name": "RPCError", + "properties": [ + "message: string", + "name: string", + "stack: string", + ], + "staticProperties": [ + "captureStackTrace: (targetObject: object, constructorOpt?: Function): void", + "prepareStackTrace: (err: Error, stackTraces: NodeJS.CallSite[]): any", + "stackTraceLimit: number", + ], + }, + { + "kind": "interface", + "name": "SessionKey", + "properties": [ + "allowances: AssetAllowance[]", + "application: string", + "createdAt: string", + "expiresAt: string", + "id: bigint", + "scope: string", + "sessionKey: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SessionKeyV1", + "properties": [ + "allowances: AssetAllowanceV1[]", + "application: string", + "createdAt: Date", + "expiresAt: Date", + "id: bigint", + "scope: string", + "sessionKey: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "SignedAppStateUpdateV1", + "properties": [ + "appStateUpdate: AppStateUpdateV1", + "quorumSigs: string[]", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "State", + "properties": [ + "asset: string", + "epoch: bigint", + "escrowChannelId: string", + "escrowLedger: Ledger", + "homeChannelId: string", + "homeLedger: Ledger", + "id: string", + "nodeSig: Hex", + "transition: Transition", + "userSig: Hex", + "userWallet: Address", + "version: bigint", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "StateAdvancer", + "properties": [ + "validateAdvancement: (currentState: State, proposedState: State): Promise", + ], + "signatures": [], + }, + { + "constructors": [ + "(assetStore: AssetStore): StateAdvancerV1", + ], + "kind": "class", + "name": "StateAdvancerV1", + "properties": [ + "validateAdvancement: (currentState: State, proposedState: State): Promise", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "StatePacker", + "properties": [ + "packState: (state: State): Promise<\`0x\${string}\`>", + ], + "signatures": [], + }, + { + "constructors": [ + "(assetStore: AssetStore): StatePackerV1", + ], + "kind": "class", + "name": "StatePackerV1", + "properties": [ + "packChallengeState: (state: State): Promise<\`0x\${string}\`>", + "packState: (state: State): Promise<\`0x\${string}\`>", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "StateSigner", + "properties": [ + "getAddress: (): Address", + "signMessage: (hash: Hex): Promise", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "StateV1", + "properties": [ + "asset: string", + "epoch: string", + "escrow_channel_id: string", + "escrow_ledger: LedgerV1", + "home_channel_id: string", + "home_ledger: LedgerV1", + "id: string", + "node_sig: string", + "transition: TransitionV1", + "user_sig: string", + "user_wallet: Address", + "version: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Token", + "properties": [ + "address: Address", + "blockchainId: bigint", + "decimals: number", + "name: string", + "symbol: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "TokenV1", + "properties": [ + "address: Address", + "blockchain_id: string", + "decimals: number", + "name: string", + "symbol: string", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "Transaction", + "properties": [ + "amount: Decimal", + "asset: string", + "createdAt: Date", + "fromAccount: Address", + "id: string", + "receiverNewStateId: string", + "senderNewStateId: string", + "toAccount: Address", + "txType: TransactionType", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "TransactionSigner", + "properties": [ + "getAccount: (() => ReturnType) | undefined", + "getAddress: (): Address", + "sendTransaction: (tx: any): Promise", + "signMessage: (message: { raw: Hex; }): Promise", + "signPersonalMessage: ((hash: Hex) => Promise) | undefined", + ], + "signatures": [], + }, + { + "kind": "enum", + "members": [ + "HomeDeposit = 10", + "HomeWithdrawal = 11", + "EscrowDeposit = 20", + "EscrowWithdraw = 21", + "Transfer = 30", + "Commit = 40", + "Release = 41", + "Rebalance = 42", + "Migrate = 100", + "EscrowLock = 110", + "MutualLock = 120", + "Finalize = 200", + ], + "name": "TransactionType", + }, + { + "kind": "interface", + "name": "TransactionV1", + "properties": [ + "amount: string", + "asset: string", + "created_at: string", + "from_account: Address", + "id: string", + "receiver_new_state_id: string", + "sender_new_state_id: string", + "to_account: Address", + "tx_type: TransactionType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "transformActionAllowance", + "signatures": [ + "(a: ActionAllowanceV1): core.ActionAllowance", + ], + }, + { + "kind": "function", + "name": "transformAppDefinitionFromRPC", + "signatures": [ + "(raw: any): AppDefinitionV1", + ], + }, + { + "kind": "function", + "name": "transformAppDefinitionToRPC", + "signatures": [ + "(def: AppDefinitionV1): any", + ], + }, + { + "kind": "function", + "name": "transformAppSessionInfo", + "signatures": [ + "(raw: any): AppSessionInfoV1", + ], + }, + { + "kind": "function", + "name": "transformAppStateUpdateToRPC", + "signatures": [ + "(update: AppStateUpdateV1): { app_session_id: string; intent: AppStateUpdateIntent; version: string; allocations: Array<{ participant: \`0x\${string}\`; asset: string; amount: string; }>; session_data: string; }", + ], + }, + { + "kind": "function", + "name": "transformAssets", + "signatures": [ + "(assets: AssetV1[]): core.Asset[]", + ], + }, + { + "kind": "function", + "name": "transformBalances", + "signatures": [ + "(balances: BalanceEntryV1[]): core.BalanceEntry[]", + ], + }, + { + "kind": "function", + "name": "transformChannel", + "signatures": [ + "(channel: ChannelV1): core.Channel", + ], + }, + { + "kind": "function", + "name": "transformLedger", + "signatures": [ + "(ledger: LedgerV1): core.Ledger", + ], + }, + { + "kind": "function", + "name": "transformNodeConfig", + "signatures": [ + "(resp: API.NodeV1GetConfigResponse): core.NodeConfig", + ], + }, + { + "kind": "function", + "name": "transformPaginationMetadata", + "signatures": [ + "(metadata: PaginationMetadataV1): core.PaginationMetadata", + ], + }, + { + "kind": "function", + "name": "transformSignedAppStateUpdateToRPC", + "signatures": [ + "(signed: SignedAppStateUpdateV1): { app_state_update: { app_session_id: string; intent: AppStateUpdateIntent; version: string; allocations: Array<{ participant: \`0x\${string}\`; asset: string; amount: string; }>; session_data: string; }; quorum_sigs: Array; }", + ], + }, + { + "kind": "function", + "name": "transformState", + "signatures": [ + "(state: StateV1): core.State", + ], + }, + { + "kind": "function", + "name": "transformTransaction", + "signatures": [ + "(tx: TransactionV1): core.Transaction", + ], + }, + { + "kind": "function", + "name": "transformTransition", + "signatures": [ + "(transition: TransitionV1): core.Transition", + ], + }, + { + "kind": "interface", + "name": "Transition", + "properties": [ + "accountId: string", + "amount: Decimal", + "txId: string", + "type: TransitionType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "transitionRequiresOpenChannel", + "signatures": [ + "(type: TransitionType): boolean", + ], + }, + { + "kind": "function", + "name": "transitionsEqual", + "signatures": [ + "(a: Transition, b: Transition): string | null", + ], + }, + { + "kind": "function", + "name": "transitionToIntent", + "signatures": [ + "(transition: Transition): number", + ], + }, + { + "kind": "function", + "name": "transitionToString", + "signatures": [ + "(type: TransitionType): string", + ], + }, + { + "kind": "enum", + "members": [ + "Void = 0", + "Acknowledgement = 1", + "HomeDeposit = 10", + "HomeWithdrawal = 11", + "EscrowDeposit = 20", + "EscrowWithdraw = 21", + "TransferSend = 30", + "TransferReceive = 31", + "Commit = 40", + "Release = 41", + "Migrate = 100", + "EscrowLock = 110", + "MutualLock = 120", + "Finalize = 200", + ], + "name": "TransitionType", + }, + { + "kind": "interface", + "name": "TransitionV1", + "properties": [ + "account_id: string", + "amount: string", + "tx_id: string", + "type: TransitionType", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "translatePayload", + "signatures": [ + "(payload: Payload): T", + ], + }, + { + "kind": "function", + "name": "unmarshalMessage", + "signatures": [ + "(data: string): Message", + ], + }, + { + "kind": "const", + "name": "UserV1GetActionAllowancesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "UserV1GetActionAllowancesRequest", + "properties": [ + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "UserV1GetActionAllowancesResponse", + "properties": [ + "allowances: ActionAllowanceV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "UserV1GetBalancesMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "UserV1GetBalancesRequest", + "properties": [ + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "UserV1GetBalancesResponse", + "properties": [ + "balances: BalanceEntryV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "UserV1GetTransactionsMethod", + "type": "string", + }, + { + "kind": "interface", + "name": "UserV1GetTransactionsRequest", + "properties": [ + "asset: string", + "from_time: bigint", + "pagination: PaginationParamsV1", + "to_time: bigint", + "tx_type: TransactionType", + "wallet: Address", + ], + "signatures": [], + }, + { + "kind": "interface", + "name": "UserV1GetTransactionsResponse", + "properties": [ + "metadata: PaginationMetadataV1", + "transactions: TransactionV1[]", + ], + "signatures": [], + }, + { + "kind": "const", + "name": "UserV1Group", + "type": "string", + }, + { + "kind": "function", + "name": "validateDecimalPrecision", + "signatures": [ + "(amount: Decimal, maxDecimals: number): void", + ], + }, + { + "kind": "function", + "name": "validateLedger", + "signatures": [ + "(ledger: Ledger): void", + ], + }, + { + "constructors": [ + "(config?: WebsocketDialerConfig): WebsocketDialer", + ], + "kind": "class", + "name": "WebsocketDialer", + "properties": [ + "call: (req: Message, signal?: AbortSignal): Promise", + "close: (): Promise", + "dial: (url: string, handleClosure: (err?: Error) => void): Promise", + "eventChannel: (): AsyncIterable", + "isConnected: (): boolean", + ], + "staticProperties": [], + }, + { + "kind": "interface", + "name": "WebsocketDialerConfig", + "properties": [ + "eventChanSize: number", + "handshakeTimeout: number", + ], + "signatures": [], + }, + { + "kind": "function", + "name": "withBlockchainRPC", + "signatures": [ + "(chainId: bigint, rpcUrl: string): Option", + ], + }, + { + "kind": "function", + "name": "withErrorHandler", + "signatures": [ + "(handler: (error: Error) => void): Option", + ], + }, + { + "kind": "function", + "name": "withHandshakeTimeout", + "signatures": [ + "(timeout: number): Option", + ], + }, +] +`; + exports[`SDK public runtime API drift guard keeps root runtime exports intentional 1`] = ` [ "AppSessionKeySignerV1", diff --git a/sdk/ts/test/unit/public-api-drift.test.ts b/sdk/ts/test/unit/public-api-drift.test.ts index eca3bd9ce..d532d8c3c 100644 --- a/sdk/ts/test/unit/public-api-drift.test.ts +++ b/sdk/ts/test/unit/public-api-drift.test.ts @@ -1,14 +1,250 @@ import * as publicApi from '../../src/index.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(testDir, '../..'); + +const FORMAT_FLAGS = + ts.TypeFormatFlags.NoTruncation | + ts.TypeFormatFlags.UseSingleQuotesForStringLiteralType | + ts.TypeFormatFlags.WriteArrayAsGenericType | + ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope; + +type PublicApiMember = { + name: string; + kind: string; + signatures?: string[]; + constructors?: string[]; + properties?: string[]; + staticProperties?: string[]; + members?: string[]; + type?: string; + declaration?: string; +}; + +function normalizeText(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + +function createPackageProgram() { + const configPath = ts.findConfigFile(packageRoot, ts.sys.fileExists, 'tsconfig.json'); + if (!configPath) throw new Error(`tsconfig.json not found under ${packageRoot}`); + + const configFile = ts.readConfigFile(configPath, ts.sys.readFile); + if (configFile.error) { + throw new Error(ts.flattenDiagnosticMessageText(configFile.error.messageText, '\n')); + } + + const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, packageRoot); + return ts.createProgram(parsed.fileNames, parsed.options); +} + +function declarationKind(declaration: ts.Declaration): string { + if (ts.isClassDeclaration(declaration)) return 'class'; + if (ts.isInterfaceDeclaration(declaration)) return 'interface'; + if (ts.isFunctionDeclaration(declaration)) return 'function'; + if (ts.isEnumDeclaration(declaration)) return 'enum'; + if (ts.isTypeAliasDeclaration(declaration)) return 'type'; + if (ts.isVariableDeclaration(declaration)) return 'const'; + return ts.SyntaxKind[declaration.kind] ?? 'unknown'; +} + +function signaturesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return type + .getCallSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); +} + +function isPrivateOrProtected(declaration: ts.Declaration): boolean { + const flags = ts.getCombinedModifierFlags(declaration as ts.Declaration); + return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); +} + +function propertiesForType( + checker: ts.TypeChecker, + type: ts.Type, + declaration: ts.Declaration +): string[] { + return checker + .getPropertiesOfType(type) + .flatMap((property) => { + const propertyDeclaration = property.valueDeclaration ?? property.declarations?.[0] ?? declaration; + if (isPrivateOrProtected(propertyDeclaration)) return []; + + const propertyType = checker.getTypeOfSymbolAtLocation(property, propertyDeclaration); + const signatures = signaturesForType(checker, propertyType, propertyDeclaration); + if (signatures.length > 0) { + return [`${property.getName()}: ${signatures.join(' | ')}`]; + } + if ( + (ts.isPropertySignature(propertyDeclaration) || + ts.isPropertyDeclaration(propertyDeclaration)) && + propertyDeclaration.type + ) { + return [`${property.getName()}: ${normalizeText(propertyDeclaration.type.getText())}`]; + } + return [ + `${property.getName()}: ${checker.typeToString(propertyType, propertyDeclaration, FORMAT_FLAGS)}`, + ]; + }) + .sort(); +} + +function enumMembers(declaration: ts.EnumDeclaration): string[] { + return declaration.members.map((member) => { + const initializer = member.initializer ? normalizeText(member.initializer.getText()) : ''; + return `${member.name.getText()} = ${initializer}`; + }); +} + +function serializePublicApi(): PublicApiMember[] { + const program = createPackageProgram(); + const checker = program.getTypeChecker(); + const entrypoint = program.getSourceFile(path.join(packageRoot, 'src/index.ts')); + if (!entrypoint) throw new Error('src/index.ts not found in program'); + + const moduleSymbol = checker.getSymbolAtLocation(entrypoint); + if (!moduleSymbol) throw new Error('src/index.ts module symbol not found'); + + return checker + .getExportsOfModule(moduleSymbol) + .filter((symbol) => symbol.getName() !== '__esModule') + .map((exportedSymbol) => { + const symbol = + exportedSymbol.flags & ts.SymbolFlags.Alias + ? checker.getAliasedSymbol(exportedSymbol) + : exportedSymbol; + const declaration = symbol.getDeclarations()?.[0]; + if (!declaration) { + return { + name: exportedSymbol.getName(), + kind: 'unknown', + }; + } + + const kind = declarationKind(declaration); + const member: PublicApiMember = { + name: exportedSymbol.getName(), + kind, + }; + + if (ts.isClassDeclaration(declaration)) { + const staticType = checker.getTypeOfSymbolAtLocation(symbol, declaration); + const instanceType = checker.getDeclaredTypeOfSymbol(symbol); + member.constructors = staticType + .getConstructSignatures() + .map((signature) => checker.signatureToString(signature, declaration, FORMAT_FLAGS)) + .sort(); + member.properties = propertiesForType(checker, instanceType, declaration); + member.staticProperties = propertiesForType(checker, staticType, declaration).filter( + (property) => !['length', 'name', 'prototype'].some((skip) => property.startsWith(`${skip}:`)) + ); + } else if (ts.isInterfaceDeclaration(declaration)) { + const type = checker.getDeclaredTypeOfSymbol(symbol); + member.properties = propertiesForType(checker, type, declaration); + member.signatures = signaturesForType(checker, type, declaration); + } else if (ts.isFunctionDeclaration(declaration)) { + member.signatures = signaturesForType( + checker, + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration + ); + } else if (ts.isEnumDeclaration(declaration)) { + member.members = enumMembers(declaration); + } else if (ts.isTypeAliasDeclaration(declaration)) { + member.declaration = normalizeText(declaration.type.getText()); + member.type = checker.typeToString( + checker.getTypeFromTypeNode(declaration.type), + declaration, + FORMAT_FLAGS + ); + } else if (ts.isVariableDeclaration(declaration)) { + member.type = checker.typeToString( + checker.getTypeOfSymbolAtLocation(symbol, declaration), + declaration, + FORMAT_FLAGS + ); + } + + return member; + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} describe('SDK public runtime API drift guard', () => { it('keeps root runtime exports intentional', () => { expect(Object.keys(publicApi).sort()).toMatchSnapshot(); }); + it('keeps root TypeScript public API signatures intentional', () => { + expect(serializePublicApi()).toMatchSnapshot(); + }); + it('proves adversarial public export removal is observable', () => { const exports = new Set(Object.keys(publicApi)); exports.delete('Client'); expect(exports.has('Client')).toBe(false); }); + + it('proves adversarial public signature changes are observable', () => { + const api = serializePublicApi(); + const client = api.find((member) => member.name === 'Client'); + expect(client?.properties?.some((property) => property.includes('ping:'))).toBe(true); + + const mutated = api.map((member) => + member.name === 'Client' + ? { + ...member, + properties: member.properties?.filter((property) => !property.includes('ping:')), + } + : member + ); + const mutatedClient = mutated.find((member) => member.name === 'Client'); + + expect(mutatedClient?.properties?.some((property) => property.includes('ping:'))).toBe(false); + }); + + it('proves adversarial type-only export removal is observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === 'Config' && member.kind === 'interface')).toBe(true); + + const mutated = api.filter((member) => member.name !== 'Config'); + expect(mutated.some((member) => member.name === 'Config')).toBe(false); + }); + + it('proves adversarial function parameter changes are observable', () => { + const api = serializePublicApi(); + const packer = api.find((member) => member.name === 'packAppStateUpdateV1'); + const original = packer?.signatures?.[0] ?? ''; + expect(original).toContain('stateUpdate: AppStateUpdateV1'); + + const mutated = original.replace('stateUpdate: AppStateUpdateV1', 'stateUpdate: unknown'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial enum value changes are observable', () => { + const api = serializePublicApi(); + const intent = api.find((member) => member.name === 'AppStateUpdateIntent'); + const original = intent?.members?.join('|') ?? ''; + expect(original).toContain('Deposit'); + + const mutated = original.replace('Deposit', 'DepositChanged'); + expect(mutated).not.toEqual(original); + }); + + it('proves adversarial public export additions are observable', () => { + const api = serializePublicApi(); + expect(api.some((member) => member.name === '__FakeExport')).toBe(false); + + const mutated = [...api, { name: '__FakeExport', kind: 'function' }]; + expect(mutated.some((member) => member.name === '__FakeExport')).toBe(true); + }); }); From 18dcd749fe4797b0e3375a2658939bd97afcc7da Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:35:19 +0530 Subject: [PATCH 05/11] Complete app signing drift vectors --- scripts/drift/generate-app-signing-vectors.go | 139 +++++++++++++++ sdk/PROTOCOL_DRIFT_GUARDS.md | 10 +- sdk/ts/test/unit/app-signing-drift.test.ts | 164 +++++++++++++++++- 3 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 scripts/drift/generate-app-signing-vectors.go diff --git a/scripts/drift/generate-app-signing-vectors.go b/scripts/drift/generate-app-signing-vectors.go new file mode 100644 index 000000000..00fcbb93b --- /dev/null +++ b/scripts/drift/generate-app-signing-vectors.go @@ -0,0 +1,139 @@ +//go:build ignore + +package main + +import ( + "encoding/json" + "fmt" + "math" + "os" + "time" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/layer-3/nitrolite/pkg/app" + "github.com/shopspring/decimal" +) + +const ( + user = "0x1111111111111111111111111111111111111111" + svc = "0x2222222222222222222222222222222222222222" +) + +type vector struct { + Name string `json:"name"` + Hash string `json:"hash"` +} + +func main() { + definition := app.AppDefinitionV1{ + ApplicationID: "store-v1", + Participants: []app.AppParticipantV1{ + {WalletAddress: user, SignatureWeight: 1}, + {WalletAddress: svc, SignatureWeight: 1}, + }, + Quorum: 2, + Nonce: 123456789, + } + + appSessionID, err := app.GenerateAppSessionIDV1(definition) + must("generate app session id", err) + + vectors := []vector{ + hashCreate("create_session", definition, `{"cart":"demo"}`), + {Name: "app_session_id", Hash: appSessionID}, + hashUpdate("deposit", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 2, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("1.25")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0")}, + }, + SessionData: `{"intent":"deposit"}`, + }), + hashUpdate("operate_purchase", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentOperate, + Version: 3, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("0.35")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0.90")}, + }, + SessionData: `{"intent":"purchase","item_id":1,"item_price":"0.90"}`, + }), + hashUpdate("withdraw", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentWithdraw, + Version: 4, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("0.10")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0.90")}, + }, + SessionData: `{"intent":"withdraw"}`, + }), + hashUpdate("fractional_deposit", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentDeposit, + Version: 5, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("1.23456789")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("0")}, + }, + SessionData: `{"intent":"deposit","note":"fractional"}`, + }), + hashUpdate("max_uint64_version", app.AppStateUpdateV1{ + AppSessionID: appSessionID, + Intent: app.AppStateUpdateIntentWithdraw, + Version: math.MaxUint64, + Allocations: []app.AppAllocationV1{ + {Participant: user, Asset: "YUSD", Amount: decimal.RequireFromString("0")}, + {Participant: svc, Asset: "YUSD", Amount: decimal.RequireFromString("1.25")}, + }, + SessionData: `{"intent":"withdraw","boundary":"max_uint64_version"}`, + }), + hashCreate("max_uint64_nonce_create_session", app.AppDefinitionV1{ + ApplicationID: definition.ApplicationID, + Participants: definition.Participants, + Quorum: definition.Quorum, + Nonce: math.MaxUint64, + }, `{"cart":"max-nonce"}`), + hashSessionKey("session_key_state", app.AppSessionKeyStateV1{ + UserAddress: user, + SessionKey: svc, + Version: 1, + ApplicationIDs: []string{"0x00000000000000000000000000000000000000000000000000000000000000a1"}, + AppSessionIDs: []string{"0x00000000000000000000000000000000000000000000000000000000000000b1"}, + ExpiresAt: time.Unix(1739812234, 0).UTC(), + UserSig: "0xSig", + }), + } + + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + must("encode vectors", encoder.Encode(vectors)) +} + +func hashCreate(name string, definition app.AppDefinitionV1, sessionData string) vector { + hash, err := app.PackCreateAppSessionRequestV1(definition, sessionData) + must(name, err) + return vector{Name: name, Hash: hexutil.Encode(hash)} +} + +func hashUpdate(name string, update app.AppStateUpdateV1) vector { + hash, err := app.PackAppStateUpdateV1(update) + must(name, err) + return vector{Name: name, Hash: hexutil.Encode(hash)} +} + +func hashSessionKey(name string, state app.AppSessionKeyStateV1) vector { + hash, err := app.PackAppSessionKeyStateV1(state) + must(name, err) + return vector{Name: name, Hash: hexutil.Encode(hash)} +} + +func must(action string, err error) { + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%s: %v\n", action, err) + os.Exit(1) + } +} diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index d9f94f3e4..a9c0c657c 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -33,7 +33,7 @@ This is not a load test. It uses empty local `blockchains` and `assets` config s - RPC DTO drift: compares Go JSON-tagged DTO structs against TS request/response interfaces for required fields, optional fields, and scalar/container shape. - Public API drift: snapshots root runtime exports and compiler-derived TypeScript signatures for `@yellow-org/sdk` and `@yellow-org/sdk-compat`, including type-only exports, interfaces, functions, classes, public class methods, enums, constants, and type aliases. - ABI drift: compares checked-in `ChannelHub` functions against the current Foundry artifact, checks SDK-consumed ERC20 functions against the ERC20 artifact, and guards the manually checked-in AppRegistry ABI against an explicit consumed-function manifest until that contract artifact exists in this repo. -- Signing drift: compares TS app-session packers against Go-generated canonical vectors. +- 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 Clearnode response fixtures for app sessions, node config, assets, and strict failure on unsupported required shapes. - Compat drift: checks current v1 app-session shape, legacy flat fallback shape, and asset decimal conversion in `NitroliteClient.getAppSessionsList()`. - Runtime smoke drift: starts an isolated local Clearnode and verifies live SDK/compat calls against the current runtime response shape. @@ -60,6 +60,14 @@ For a new DTO field, update the Go JSON-tagged struct and TS request/response in For a new response transform, add a raw fixture and expected behavior test. Unsupported wire shapes should fail clearly instead of silently producing partial data. +For intentional app/session-key signing vector changes, regenerate the Go source-of-truth hashes from the repo root: + +```bash +go run ./scripts/drift/generate-app-signing-vectors.go +``` + +Then update `sdk/ts/test/unit/app-signing-drift.test.ts` with the changed hashes in the same PR as the Go packing/protocol change. + ## Adversarial Proof Each guard includes at least one negative test or mutation-style check that proves the guard would fail if the relevant surface drifted. These checks must use fixtures, temp copies, or local in-test mutations. They must not leave tracked files dirty. diff --git a/sdk/ts/test/unit/app-signing-drift.test.ts b/sdk/ts/test/unit/app-signing-drift.test.ts index 1c1e2f24f..01faa412a 100644 --- a/sdk/ts/test/unit/app-signing-drift.test.ts +++ b/sdk/ts/test/unit/app-signing-drift.test.ts @@ -4,12 +4,18 @@ import { AppStateUpdateIntent, generateAppSessionIDV1, packAppStateUpdateV1, + packAppSessionKeyStateV1, packCreateAppSessionRequestV1, type AppDefinitionV1, + type AppSessionKeyStateV1, } from '../../src/app/index.js'; +// Regenerate expected hashes with: +// go run ./scripts/drift/generate-app-signing-vectors.go + const user = '0x1111111111111111111111111111111111111111'; const app = '0x2222222222222222222222222222222222222222'; +const maxUint64 = 18446744073709551615n; const definition: AppDefinitionV1 = { applicationId: 'store-v1', @@ -21,6 +27,16 @@ const definition: AppDefinitionV1 = { nonce: 123456789n, }; +const sessionKeyState: AppSessionKeyStateV1 = { + user_address: user, + session_key: app, + version: '1', + application_ids: ['0x00000000000000000000000000000000000000000000000000000000000000a1'], + app_session_ids: ['0x00000000000000000000000000000000000000000000000000000000000000b1'], + expires_at: '1739812234', + user_sig: '0xSig', +}; + describe('Go/TS app signing drift vectors', () => { it('matches Go PackCreateAppSessionRequestV1 and GenerateAppSessionIDV1 vectors', () => { expect(packCreateAppSessionRequestV1(definition, '{"cart":"demo"}')).toBe( @@ -31,7 +47,19 @@ describe('Go/TS app signing drift vectors', () => { ); }); - it('matches Go PackAppStateUpdateV1 deposit and operate vectors', () => { + it('matches Go PackCreateAppSessionRequestV1 uint64 nonce boundary vector', () => { + expect( + packCreateAppSessionRequestV1( + { + ...definition, + nonce: maxUint64, + }, + '{"cart":"max-nonce"}' + ) + ).toBe('0xf15b0c1bc732b62d840e3c026e125cb5dec7da2b658c36355835ae56802c781c'); + }); + + it('matches Go PackAppStateUpdateV1 deposit, withdraw, operate, fractional, and uint64 vectors', () => { const appSessionId = generateAppSessionIDV1(definition); expect( @@ -59,6 +87,51 @@ describe('Go/TS app signing drift vectors', () => { sessionData: '{"intent":"purchase","item_id":1,"item_price":"0.90"}', }) ).toBe('0xe44d77fa3eda431b1bc088e6f89e114b2191ef5ce03cc6851c702d01bdbf3457'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Withdraw, + version: 4n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.10') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"withdraw"}', + }) + ).toBe('0x4290525a204a34e5fc4d37427f1b0b1e2d375ed09ed0ac3b23d14dbc481c7d71'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 5n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.23456789') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit","note":"fractional"}', + }) + ).toBe('0x626e03a0850b83f3bac66dc7bd27b1e2d882fd88b54a61fd76d9fbaa35703098'); + + expect( + packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Withdraw, + version: maxUint64, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0') }, + { participant: app, asset: 'YUSD', amount: new Decimal('1.25') }, + ], + sessionData: '{"intent":"withdraw","boundary":"max_uint64_version"}', + }) + ).toBe('0x6460b0c93c88da7fa34bfbf3893be74362e35987b525c7b34b4749e66fef8862'); + }); + + it('matches Go PackAppSessionKeyStateV1 vector', () => { + expect(packAppSessionKeyStateV1(sessionKeyState)).toBe( + '0x9fedfbcd577c5e677b95b1273e38f52ffdeee096e98f731c5455e4c73e0274aa' + ); }); it('proves adversarial allocation ordering changes the signed hash', () => { @@ -86,4 +159,93 @@ describe('Go/TS app signing drift vectors', () => { expect(mutated).not.toBe(canonical); }); + + it('proves adversarial amount rounding changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 5n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.23456789') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit","note":"fractional"}', + }); + const rounded = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 5n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('1.234568') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0') }, + ], + sessionData: '{"intent":"deposit","note":"fractional"}', + }); + + expect(rounded).not.toBe(canonical); + }); + + it('proves adversarial intent enum changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Withdraw, + version: 4n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.10') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"withdraw"}', + }); + const wrongIntent = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Deposit, + version: 4n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.10') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"withdraw"}', + }); + + expect(wrongIntent).not.toBe(canonical); + }); + + it('proves adversarial session data normalization changes the signed hash', () => { + const appSessionId = generateAppSessionIDV1(definition); + const canonical = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Operate, + version: 3n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.35') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"intent":"purchase","item_id":1,"item_price":"0.90"}', + }); + const normalized = packAppStateUpdateV1({ + appSessionId, + intent: AppStateUpdateIntent.Operate, + version: 3n, + allocations: [ + { participant: user, asset: 'YUSD', amount: new Decimal('0.35') }, + { participant: app, asset: 'YUSD', amount: new Decimal('0.90') }, + ], + sessionData: '{"item_id":1,"item_price":"0.90","intent":"purchase"}', + }); + + expect(normalized).not.toBe(canonical); + }); + + it('proves adversarial session-key ID placement changes the signed hash', () => { + const canonical = packAppSessionKeyStateV1(sessionKeyState); + const swappedIds = packAppSessionKeyStateV1({ + ...sessionKeyState, + application_ids: sessionKeyState.app_session_ids, + app_session_ids: sessionKeyState.application_ids, + }); + + expect(swappedIds).not.toBe(canonical); + }); }); From d5fb0816f0c8702e93424a974036949833755bc3 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:43:29 +0530 Subject: [PATCH 06/11] Validate SDK key-state transform drift --- sdk/ts/src/client.ts | 12 +- sdk/ts/src/session_key_state_transforms.ts | 56 +++++ sdk/ts/src/utils.ts | 30 ++- sdk/ts/test/unit/transform-drift.test.ts | 238 ++++++++++++++++++--- 4 files changed, 297 insertions(+), 39 deletions(-) create mode 100644 sdk/ts/src/session_key_state_transforms.ts diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index a3ef68922..96363f417 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -31,6 +31,10 @@ import { transformAppDefinitionFromRPC, transformActionAllowance, } from './utils.js'; +import { + transformChannelSessionKeyState, + transformAppSessionKeyState, +} from './session_key_state_transforms.js'; import * as blockchain from './blockchain/index.js'; import { nextState, applyChannelCreation, applyAcknowledgementTransition, applyHomeDepositTransition, applyHomeWithdrawalTransition, applyTransferSendTransition, applyFinalizeTransition, applyCommitTransition } from './core/state.js'; import { newVoidState } from './core/types.js'; @@ -1701,7 +1705,9 @@ export class Client { session_key: sessionKey, }; const resp = await this.rpcClient.channelsV1GetLastKeyStates(req); - return resp.states; + return resp.states.map((state, index) => + transformChannelSessionKeyState(state, `channel session key state[${index}]`) + ); } // ============================================================================ @@ -1751,7 +1757,9 @@ export class Client { session_key: sessionKey, }; const resp = await this.rpcClient.appSessionsV1GetLastKeyStates(req); - return resp.states; + return resp.states.map((state, index) => + transformAppSessionKeyState(state, `app session key state[${index}]`) + ); } // ============================================================================ diff --git a/sdk/ts/src/session_key_state_transforms.ts b/sdk/ts/src/session_key_state_transforms.ts new file mode 100644 index 000000000..1f10bb3f0 --- /dev/null +++ b/sdk/ts/src/session_key_state_transforms.ts @@ -0,0 +1,56 @@ +import type { AppSessionKeyStateV1 } from './app/types.js'; +import type { ChannelSessionKeyStateV1 } from './rpc/types.js'; + +function asRecord(raw: unknown, context: string): Record { + if (!raw || typeof raw !== 'object') { + throw new Error(`Invalid ${context}: expected object`); + } + return raw as Record; +} + +function requireStringField(raw: unknown, context: string, field: string): string { + const record = asRecord(raw, context); + const value = record[field]; + if (typeof value !== 'string') { + throw new Error(`Invalid ${context}: missing required string field ${field}`); + } + return value; +} + +function requireStringArrayField(raw: unknown, context: string, field: string): string[] { + const record = asRecord(raw, context); + const value = record[field]; + if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) { + throw new Error(`Invalid ${context}: expected ${field} to be string[]`); + } + return value; +} + +export function transformChannelSessionKeyState( + raw: unknown, + context = 'channel session key state' +): ChannelSessionKeyStateV1 { + return { + user_address: requireStringField(raw, context, 'user_address'), + session_key: requireStringField(raw, context, 'session_key'), + version: requireStringField(raw, context, 'version'), + assets: requireStringArrayField(raw, context, 'assets'), + expires_at: requireStringField(raw, context, 'expires_at'), + user_sig: requireStringField(raw, context, 'user_sig'), + }; +} + +export function transformAppSessionKeyState( + raw: unknown, + context = 'app session key state' +): AppSessionKeyStateV1 { + return { + user_address: requireStringField(raw, context, 'user_address'), + session_key: requireStringField(raw, context, 'session_key'), + version: requireStringField(raw, context, 'version'), + application_ids: requireStringArrayField(raw, context, 'application_ids'), + app_session_ids: requireStringArrayField(raw, context, 'app_session_ids'), + expires_at: requireStringField(raw, context, 'expires_at'), + user_sig: requireStringField(raw, context, 'user_sig'), + }; +} diff --git a/sdk/ts/src/utils.ts b/sdk/ts/src/utils.ts index cb6be4f51..a980ba367 100644 --- a/sdk/ts/src/utils.ts +++ b/sdk/ts/src/utils.ts @@ -355,17 +355,37 @@ export function transformSignedAppStateUpdateToRPC(signed: SignedAppStateUpdateV * The server returns snake_case JSON that needs conversion to SDK types. */ export function transformAppSessionInfo(raw: any): AppSessionInfoV1 { + const allocations = raw.allocations || []; + if (!Array.isArray(allocations)) { + throw new Error('Invalid app session allocations: expected allocations to be an array'); + } + return { appSessionId: raw.app_session_id, appDefinition: transformAppDefinitionFromRPC(raw.app_definition), isClosed: raw.status === 'closed', sessionData: raw.session_data || '', version: BigInt(raw.version), - allocations: (raw.allocations || []).map((a: any) => ({ - participant: a.participant as Address, - asset: a.asset, - amount: new Decimal(a.amount), - })), + allocations: allocations.map(transformAppAllocationFromRPC), + }; +} + +function transformAppAllocationFromRPC(raw: any, index: number) { + const context = `app session allocation[${index}]`; + if (!raw || typeof raw.participant !== 'string') { + throw new Error(`Invalid ${context}: missing required string field participant`); + } + if (typeof raw.asset !== 'string') { + throw new Error(`Invalid ${context}: missing required string field asset`); + } + if (typeof raw.amount !== 'string') { + throw new Error(`Invalid ${context}: missing required string field amount`); + } + + return { + participant: raw.participant as Address, + asset: raw.asset, + amount: new Decimal(raw.amount), }; } diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts index 60f5e2b8c..eb2eb5b8b 100644 --- a/sdk/ts/test/unit/transform-drift.test.ts +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -1,37 +1,71 @@ import { Decimal } from 'decimal.js'; +import { jest } from '@jest/globals'; -import { transformAppSessionInfo, transformAssets, transformNodeConfig } from '../../src/utils.js'; +import { Client } from '../../src/client.js'; +import { + transformAppSessionInfo, + transformAssets, + transformNodeConfig, +} from '../../src/utils.js'; +import { + transformAppSessionKeyState, + transformChannelSessionKeyState, +} from '../../src/session_key_state_transforms.js'; + +const userAddress = '0x1111111111111111111111111111111111111111'; +const sessionKeyAddress = '0x2222222222222222222222222222222222222222'; + +const appSessionRaw = { + app_session_id: '0xsession', + app_definition: { + application_id: 'store-v1', + participants: [ + { + wallet_address: userAddress, + signature_weight: 1, + }, + { + wallet_address: sessionKeyAddress, + signature_weight: 1, + }, + ], + quorum: 2, + nonce: '123', + }, + status: 'open', + session_data: '{"intent":"purchase"}', + version: '4', + allocations: [ + { + participant: userAddress, + asset: 'YUSD', + amount: '1.25', + }, + ], +}; + +const channelKeyStateRaw = { + user_address: userAddress, + session_key: sessionKeyAddress, + version: '7', + assets: ['YUSD'], + expires_at: '1739812234', + user_sig: '0xabc123', +}; + +const appSessionKeyStateRaw = { + user_address: userAddress, + session_key: sessionKeyAddress, + version: '8', + application_ids: ['0x00000000000000000000000000000000000000000000000000000000000000a1'], + app_session_ids: ['0x00000000000000000000000000000000000000000000000000000000000000b1'], + expires_at: '1739812234', + user_sig: '0xdef456', +}; describe('Clearnode response transform drift guards', () => { it('maps current get_app_sessions app_definition shape to SDK appDefinition', () => { - const session = transformAppSessionInfo({ - app_session_id: '0xsession', - app_definition: { - application_id: 'store-v1', - participants: [ - { - wallet_address: '0x1111111111111111111111111111111111111111', - signature_weight: 1, - }, - { - wallet_address: '0x2222222222222222222222222222222222222222', - signature_weight: 1, - }, - ], - quorum: 2, - nonce: '123', - }, - status: 'open', - session_data: '{"intent":"purchase"}', - version: '4', - allocations: [ - { - participant: '0x1111111111111111111111111111111111111111', - asset: 'YUSD', - amount: '1.25', - }, - ], - }); + const session = transformAppSessionInfo(appSessionRaw); expect(session).toEqual({ appSessionId: '0xsession', @@ -39,11 +73,11 @@ describe('Clearnode response transform drift guards', () => { applicationId: 'store-v1', participants: [ { - walletAddress: '0x1111111111111111111111111111111111111111', + walletAddress: userAddress, signatureWeight: 1, }, { - walletAddress: '0x2222222222222222222222222222222222222222', + walletAddress: sessionKeyAddress, signatureWeight: 1, }, ], @@ -55,7 +89,7 @@ describe('Clearnode response transform drift guards', () => { version: 4n, allocations: [ { - participant: '0x1111111111111111111111111111111111111111', + participant: userAddress, asset: 'YUSD', amount: new Decimal('1.25'), }, @@ -75,6 +109,20 @@ describe('Clearnode response transform drift guards', () => { ).toThrow('Invalid app definition: missing required fields'); }); + it('rejects app sessions missing required allocation fields', () => { + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + allocations: [ + { + participant: userAddress, + asset: 'YUSD', + }, + ], + }) + ).toThrow('Invalid app session allocation[0]: missing required string field amount'); + }); + it('maps get_config supported_sig_validators from array and base64 forms', () => { const base = { node_address: '0x1111111111111111111111111111111111111111' as const, @@ -102,6 +150,8 @@ describe('Clearnode response transform drift guards', () => { supported_sig_validators: 'AAE=', } as any).supportedSigValidators ).toEqual([0, 1]); + + expect(transformNodeConfig(base as any).supportedSigValidators).toEqual([]); }); it('maps get_assets symbols, decimals, suggested chain, and token chains', () => { @@ -141,4 +191,128 @@ describe('Clearnode response transform drift guards', () => { }, ]); }); + + it('validates channel key-state and app-session key-state fixtures', () => { + expect(transformChannelSessionKeyState(channelKeyStateRaw)).toEqual(channelKeyStateRaw); + expect(transformAppSessionKeyState(appSessionKeyStateRaw)).toEqual(appSessionKeyStateRaw); + }); + + it('rejects malformed key-state fixtures with clear errors', () => { + expect(() => + transformChannelSessionKeyState( + { + ...channelKeyStateRaw, + user_sig: undefined, + }, + 'channel session key state[0]' + ) + ).toThrow('Invalid channel session key state[0]: missing required string field user_sig'); + + expect(() => + transformAppSessionKeyState( + { + ...appSessionKeyStateRaw, + app_session_ids: 'not-an-array', + }, + 'app session key state[0]' + ) + ).toThrow('Invalid app session key state[0]: expected app_session_ids to be string[]'); + + expect(() => + transformAppSessionKeyState( + { + ...appSessionKeyStateRaw, + application_ids: undefined, + applicationIds: appSessionKeyStateRaw.application_ids, + }, + 'app session key state[0]' + ) + ).toThrow('Invalid app session key state[0]: expected application_ids to be string[]'); + }); + + it('maps high-level client app-session and key-state responses through transform paths', async () => { + const rpcClient = { + appSessionsV1GetAppSessions: jest.fn(async () => ({ + app_sessions: [appSessionRaw], + metadata: { + page: 1, + per_page: 10, + total_count: 1, + page_count: 1, + }, + })), + channelsV1GetLastKeyStates: jest.fn(async () => ({ + states: [channelKeyStateRaw], + })), + appSessionsV1GetLastKeyStates: jest.fn(async () => ({ + states: [appSessionKeyStateRaw], + })), + }; + const clientLike = { rpcClient }; + + const sessionsResult = await (Client.prototype.getAppSessions as any).call(clientLike, { + wallet: userAddress, + page: 1, + pageSize: 10, + }); + const channelKeyStates = await (Client.prototype.getLastChannelKeyStates as any).call( + clientLike, + userAddress + ); + const appSessionKeyStates = await (Client.prototype.getLastKeyStates as any).call( + clientLike, + userAddress + ); + + expect(sessionsResult.sessions).toHaveLength(1); + expect(sessionsResult.metadata).toEqual({ + page: 1, + perPage: 10, + totalCount: 1, + pageCount: 1, + }); + expect(channelKeyStates).toEqual([channelKeyStateRaw]); + expect(appSessionKeyStates).toEqual([appSessionKeyStateRaw]); + expect(rpcClient.appSessionsV1GetAppSessions).toHaveBeenCalledWith({ + app_session_id: undefined, + participant: userAddress, + status: undefined, + pagination: { + offset: 0, + limit: 10, + }, + }); + }); + + it('maps high-level client empty app-session responses', async () => { + const clientLike = { + rpcClient: { + appSessionsV1GetAppSessions: jest.fn(async () => ({ + app_sessions: [], + metadata: { + page: 1, + per_page: 10, + total_count: 0, + page_count: 0, + }, + })), + }, + }; + + const result = await (Client.prototype.getAppSessions as any).call(clientLike, { + wallet: userAddress, + page: 1, + pageSize: 10, + }); + + expect(result).toEqual({ + sessions: [], + metadata: { + page: 1, + perPage: 10, + totalCount: 0, + pageCount: 0, + }, + }); + }); }); From 72514397ce827d8f2a8ba78c411fc08d40e0cfcf Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:47:15 +0530 Subject: [PATCH 07/11] Extend compat drift coverage --- sdk/ts-compat/test/unit/client.test.ts | 42 +++++++++++++++++++ .../test/unit/public-api-drift.test.ts | 22 ++++++++++ 2 files changed, 64 insertions(+) diff --git a/sdk/ts-compat/test/unit/client.test.ts b/sdk/ts-compat/test/unit/client.test.ts index cfda32217..9f18c94dd 100644 --- a/sdk/ts-compat/test/unit/client.test.ts +++ b/sdk/ts-compat/test/unit/client.test.ts @@ -11,6 +11,7 @@ function makeClient(sessions: any[]) { client.userAddress = wallet; client.innerClient = { getAppSessions: jest.fn().mockResolvedValue({ sessions }), + getConfig: jest.fn(), }; client.assetsBySymbol = new Map([ ['yusd', { decimals: 6 }], @@ -128,4 +129,45 @@ describe('NitroliteClient getAppSessionsList compat mapping', () => { wallet: wallet.toLowerCase(), }); }); + + it('passes through current SDK camelCase getConfig shape', async () => { + const currentConfig = { + nodeAddress: wallet, + nodeVersion: 'test-node', + supportedSigValidators: [0, 1], + blockchains: [ + { + name: 'Sepolia', + id: 11155111n, + channelHubAddress: '0x3333333333333333333333333333333333333333', + lockingContractAddress: '0x4444444444444444444444444444444444444444', + blockStep: 0n, + }, + ], + }; + const client = makeClient([]); + client.innerClient.getConfig.mockResolvedValue(currentConfig); + + await expect(client.getConfig()).resolves.toBe(currentConfig); + }); + + it('documents snake_case getConfig as pass-through, not normalized compat mapping', async () => { + const rawConfig = { + node_address: wallet, + node_version: 'raw-node', + supported_sig_validators: [0, 1], + blockchains: [ + { + name: 'Sepolia', + blockchain_id: '11155111', + channel_hub_address: '0x3333333333333333333333333333333333333333', + locking_contract_address: '0x4444444444444444444444444444444444444444', + }, + ], + }; + const client = makeClient([]); + client.innerClient.getConfig.mockResolvedValue(rawConfig); + + await expect(client.getConfig()).resolves.toBe(rawConfig); + }); }); diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts index 4abdd7398..4c91c2113 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -187,6 +187,28 @@ describe('compat public runtime API drift guard', () => { expect(serializePublicApi()).toMatchSnapshot(); }); + it('keeps session-key compat helpers and client methods public', () => { + expect(Object.keys(publicApi)).toEqual( + expect.arrayContaining(['toSessionKeyQuorumSignature', 'NitroliteClient']) + ); + + const api = serializePublicApi(); + const client = api.find((member) => member.name === 'NitroliteClient'); + expect(client?.properties).toEqual( + expect.arrayContaining([ + expect.stringContaining('signChannelSessionKeyState:'), + expect.stringContaining('submitChannelSessionKeyState:'), + expect.stringContaining('getLastChannelKeyStates:'), + expect.stringContaining('signSessionKeyState:'), + expect.stringContaining('submitSessionKeyState:'), + expect.stringContaining('getLastKeyStates:'), + ]) + ); + + const helper = api.find((member) => member.name === 'toSessionKeyQuorumSignature'); + expect(helper?.signatures?.[0]).toContain('signature: Hex | string'); + }); + it('proves adversarial public export removal is observable', () => { const exports = new Set(Object.keys(publicApi)); exports.delete('NitroliteClient'); From 50a02cbb52e89249bb3c69cb38f6e44800d8e9d5 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:55:17 +0530 Subject: [PATCH 08/11] Add manual protocol stress smoke --- .../workflows/protocol-drift-stress-smoke.yml | 58 ++++++++++++ scripts/check-protocol-drift.sh | 6 +- scripts/drift/runtime-smoke.mjs | 92 ++++++++++++------- sdk/PROTOCOL_DRIFT_GUARDS.md | 12 ++- 4 files changed, 134 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/protocol-drift-stress-smoke.yml diff --git a/.github/workflows/protocol-drift-stress-smoke.yml b/.github/workflows/protocol-drift-stress-smoke.yml new file mode 100644 index 000000000..72b1eeda8 --- /dev/null +++ b/.github/workflows/protocol-drift-stress-smoke.yml @@ -0,0 +1,58 @@ +name: Protocol Drift Stress Smoke + +on: + workflow_dispatch: + inputs: + clearnode_ws_url: + description: Lightweight shared Clearnode WebSocket URL to smoke test + required: true + default: wss://clearnode-stress.yellow.org/v1/ws + +permissions: + contents: read + +jobs: + protocol-drift-stress-smoke: + name: Protocol Drift Stress Smoke + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version-file: sdk/ts/package.json + cache: npm + cache-dependency-path: | + sdk/ts/package-lock.json + sdk/ts-compat/package-lock.json + + - name: Install TS SDK dependencies + run: npm ci + working-directory: sdk/ts + + - name: Build TS SDK for compat file dependency + run: npm run build:ci + working-directory: sdk/ts + + - name: Install TS SDK Compat dependencies + run: npm ci + working-directory: sdk/ts-compat + + - name: Run shared Clearnode compatibility smoke + run: ./scripts/check-protocol-drift.sh --runtime + env: + CLEARNODE_RUNTIME_SMOKE_EXTERNAL: '1' + CLEARNODE_RUNTIME_SMOKE_WS_URL: ${{ inputs.clearnode_ws_url }} + CLEARNODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + + - name: Upload stress smoke logs + if: failure() + uses: actions/upload-artifact@v4 + with: + name: protocol-drift-stress-smoke-logs + path: runtime-smoke-logs + if-no-files-found: ignore diff --git a/scripts/check-protocol-drift.sh b/scripts/check-protocol-drift.sh index 261162514..aa2fa9ba1 100755 --- a/scripts/check-protocol-drift.sh +++ b/scripts/check-protocol-drift.sh @@ -10,8 +10,10 @@ Usage: scripts/check-protocol-drift.sh [--static|--runtime] --static Run deterministic protocol/SDK/compat drift checks. --runtime Run runtime smoke checks against an ephemeral/local Clearnode. -Runtime smoke starts an isolated local Clearnode with a temporary config. It is a -lightweight compatibility smoke, not a load or stress test. +Runtime smoke starts an isolated local Clearnode with a temporary config by +default. Set CLEARNODE_RUNTIME_SMOKE_EXTERNAL=1 and CLEARNODE_RUNTIME_SMOKE_WS_URL +to run the same lightweight compatibility smoke against an existing node. This is +not a load or stress test. USAGE } diff --git a/scripts/drift/runtime-smoke.mjs b/scripts/drift/runtime-smoke.mjs index 410d99c7c..52b94069d 100644 --- a/scripts/drift/runtime-smoke.mjs +++ b/scripts/drift/runtime-smoke.mjs @@ -18,6 +18,7 @@ const readyTimeoutMs = Number(process.env.CLEARNODE_RUNTIME_SMOKE_READY_TIMEOUT_ const adversarialMode = process.env.CLEARNODE_RUNTIME_SMOKE_ADVERSARIAL ?? ''; const externalLogDirInput = process.env.CLEARNODE_RUNTIME_SMOKE_LOG_DIR ?? ''; const externalLogDir = externalLogDirInput ? path.resolve(repoRoot, externalLogDirInput) : ''; +const useExternalNode = process.env.CLEARNODE_RUNTIME_SMOKE_EXTERNAL === '1'; const privateKey = '0x59c6995e998f97a5a0044966f094538f0d0921e301baca6a9ae52cd7834c90b9'; @@ -72,12 +73,12 @@ function openWebSocket(url, timeoutMs = 500) { }); } -async function waitForWebSocket(url, child, timeoutMs = 15000) { +async function waitForWebSocket(url, child = null, timeoutMs = 15000) { const deadline = Date.now() + timeoutMs; let lastError = null; while (Date.now() < deadline) { - if (child.exitCode !== null) { + if (child && child.exitCode !== null) { throw new SmokeError( 'startup', `Clearnode exited before readiness with code ${child.exitCode}` @@ -113,6 +114,25 @@ async function stopProcess(child) { child.kill('SIGKILL'); } +async function closeClient(client) { + if (!client) return; + + const closed = await Promise.race([ + client.close().then( + () => true, + (err) => { + console.warn(`[runtime-smoke] client.close failed: ${err.message ?? err}`); + return true; + } + ), + sleep(3000).then(() => false), + ]); + + if (!closed) { + console.warn('[runtime-smoke] client.close timed out; continuing cleanup'); + } +} + async function runCommand(command, args, options, category) { return new Promise((resolve, reject) => { let stderr = ''; @@ -188,27 +208,31 @@ async function runSmoke() { ].join('\n'); try { - logStep(`writing isolated config in ${configDir}`); - await writeConfig(configDir); - logStep('building temporary Clearnode binary'); - await runCommand('go', ['build', '-o', binaryPath, './clearnode'], { cwd: repoRoot }, 'setup'); - - logStep(`starting Clearnode and waiting for ${wsURL}`); - child = spawn(binaryPath, { - cwd: repoRoot, - env: { - ...process.env, - CLEARNODE_CONFIG_DIR_PATH: configDir, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - child.stdout.on('data', (chunk) => { - stdout += chunk.toString(); - }); - child.stderr.on('data', (chunk) => { - stderr += chunk.toString(); - }); + if (useExternalNode) { + logStep(`using external Clearnode at ${wsURL}`); + } else { + logStep(`writing isolated config in ${configDir}`); + await writeConfig(configDir); + logStep('building temporary Clearnode binary'); + await runCommand('go', ['build', '-o', binaryPath, './clearnode'], { cwd: repoRoot }, 'setup'); + + logStep(`starting Clearnode and waiting for ${wsURL}`); + child = spawn(binaryPath, { + cwd: repoRoot, + env: { + ...process.env, + CLEARNODE_CONFIG_DIR_PATH: configDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + } await waitForWebSocket(wsURL, child, readyTimeoutMs); @@ -225,23 +249,28 @@ async function runSmoke() { logStep('calling getConfig'); const config = await withTimeout('client.getConfig', client.getConfig()); - assertSmoke( - config.nodeAddress.toLowerCase() === wallet.toLowerCase(), - 'transform', - `expected node address ${wallet}, got ${config.nodeAddress}` - ); + assertSmoke(typeof config.nodeAddress === 'string', 'transform', 'node config nodeAddress is not a string'); assertSmoke(Array.isArray(config.blockchains), 'transform', 'node config blockchains is not an array'); - assertSmoke(config.blockchains.length === 0, 'transform', 'runtime smoke config should expose no blockchains'); assertSmoke( Array.isArray(config.supportedSigValidators), 'transform', 'node config supportedSigValidators is not an array' ); + if (!useExternalNode) { + assertSmoke( + config.nodeAddress.toLowerCase() === wallet.toLowerCase(), + 'transform', + `expected node address ${wallet}, got ${config.nodeAddress}` + ); + assertSmoke(config.blockchains.length === 0, 'transform', 'runtime smoke config should expose no blockchains'); + } logStep('calling getAssets'); const assets = await withTimeout('client.getAssets', client.getAssets()); assertSmoke(Array.isArray(assets), 'transform', 'assets response is not an array'); - assertSmoke(assets.length === 0, 'transform', 'runtime smoke config should expose no assets'); + if (!useExternalNode) { + assertSmoke(assets.length === 0, 'transform', 'runtime smoke config should expose no assets'); + } logStep('calling getAppSessions'); const appSessions = await withTimeout( @@ -306,7 +335,7 @@ async function runSmoke() { process.exitCode = 1; } finally { try { - if (client) await client.close(); + await closeClient(client); } finally { if (child) { logStep('stopping Clearnode'); @@ -322,3 +351,4 @@ async function runSmoke() { } await runSmoke(); +process.exit(process.exitCode ?? 0); diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index a9c0c657c..d0f7b8990 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -27,6 +27,16 @@ The runtime smoke builds the TS SDK, builds TS compat, builds a temporary local This is not a load test. It uses empty local `blockchains` and `assets` config so PR CI does not depend on external RPC endpoints, wallets, or shared Clearnode deployments. +To run the same lightweight compatibility smoke against a shared Clearnode, use external-node mode: + +```bash +CLEARNODE_RUNTIME_SMOKE_EXTERNAL=1 \ +CLEARNODE_RUNTIME_SMOKE_WS_URL=wss://clearnode-stress.yellow.org/v1/ws \ +./scripts/check-protocol-drift.sh --runtime +``` + +External-node mode does not start a local Clearnode and does not assert local-only empty config. It still checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping. + ## Guard Layers - RPC method drift: compares Go RPC method literals, Clearnode router registrations, TS method constants, and public TS client wrappers. @@ -82,4 +92,4 @@ If runtime smoke fails in CI, inspect the `protocol-drift-runtime-smoke-logs` ar The runtime job uses read-only repository permissions and no secrets. It builds Clearnode locally instead of pulling or publishing an image, so ordinary PRs do not need package-write permissions. If organization policy restricts forked PR workflows, a maintainer can rerun the same command locally or through an allowed CI rerun. -Shared stress Clearnode checks are manual/nightly only. `wss://clearnode-stress.yellow.org/v1/ws` can be newer than sandbox while audit remediations roll out, so it is useful for release confidence but must not be a default PR blocker. +Shared stress Clearnode checks are manual only through the `Protocol Drift Stress Smoke` workflow. They are not PR-blocking and are not scheduled by default. `wss://clearnode-stress.yellow.org/v1/ws` can be newer than sandbox while audit remediations roll out, so it is useful for release confidence but must not be a default PR blocker. From 898589bea8761b180df1c885bb3751560c9be78a Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 19:57:51 +0530 Subject: [PATCH 09/11] Document protocol drift guard troubleshooting --- sdk/PROTOCOL_DRIFT_GUARDS.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index d0f7b8990..acd981225 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -68,7 +68,7 @@ For a new RPC method, update all applicable surfaces in the same PR: Go method c For a new DTO field, update the Go JSON-tagged struct and TS request/response interface together. Optionality must match unless a small, named override is added to the drift test. -For a new response transform, add a raw fixture and expected behavior test. Unsupported wire shapes should fail clearly instead of silently producing partial data. +For a new response transform, add a raw fixture and expected behavior test in the relevant drift test. Unsupported wire shapes should fail clearly instead of silently producing partial data. If the high-level client method performs the transform inline, add a client-level mock test in addition to any isolated transform test. For intentional app/session-key signing vector changes, regenerate the Go source-of-truth hashes from the repo root: @@ -82,6 +82,18 @@ Then update `sdk/ts/test/unit/app-signing-drift.test.ts` with the changed hashes Each guard includes at least one negative test or mutation-style check that proves the guard would fail if the relevant surface drifted. These checks must use fixtures, temp copies, or local in-test mutations. They must not leave tracked files dirty. +## Troubleshooting + +- Missing RPC method or client wrapper: update Go method constants, router registrations, TS method constants, and the public TS client wrapper together. If the method is intentionally raw-only, add an explicit exemption in the RPC drift test. +- DTO optionality or field drift: compare the failing method/type/field path in the drift output, then update the Go JSON-tagged struct and TS request/response interface in the same PR. +- Public API snapshot drift: treat the diff as an SDK API change. If intentional, update snapshots with `npm run drift:check -- -u` in the affected package and document the API change in the PR body. +- ABI drift: regenerate Foundry artifacts and SDK ABI files with `cd contracts && forge build` and `cd ../sdk/ts && npm run codegen-abi`. If AppRegistry changes, remember it is currently manifest-guarded because the matching artifact is not in this repo. +- Signing hash mismatch: regenerate Go source-of-truth vectors with `go run ./scripts/drift/generate-app-signing-vectors.go`, then inspect whether the change is field order, enum value, amount formatting, nonce/version encoding, or exact session-data bytes. +- Transform fixture failure: update or add raw Clearnode fixtures only for wire shapes the SDK intentionally supports. Do not silently accept missing required fields that would later crash consumers. +- Compat mapping failure: current v1 SDK shapes are primary. Legacy fallbacks must stay explicit in tests; do not add broad best-effort mappers without fixture coverage. +- Runtime setup/startup failure: inspect `runtime-smoke-logs` in CI or the preserved temp log directory locally. `[setup]` points to build/setup, `[startup]` to local Clearnode process exit, `[connection]` to WebSocket readiness, and `[transform]` or `[compat mapping]` to SDK response handling. +- Shared stress smoke failure: rerun the manual workflow or local external-node command to confirm it is not shared-environment state. Stress smoke is release signal, not a PR blocker. + ## CI Policy `Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Clearnode deployments. From b06b6f0fb302ed963fbebe7dca097f6f9b5649b8 Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Fri, 24 Apr 2026 21:05:47 +0530 Subject: [PATCH 10/11] Make external protocol smoke URL explicit --- ...moke.yml => protocol-drift-external-smoke.yml} | 15 +++++++-------- sdk/PROTOCOL_DRIFT_GUARDS.md | 10 +++++----- 2 files changed, 12 insertions(+), 13 deletions(-) rename .github/workflows/{protocol-drift-stress-smoke.yml => protocol-drift-external-smoke.yml} (76%) diff --git a/.github/workflows/protocol-drift-stress-smoke.yml b/.github/workflows/protocol-drift-external-smoke.yml similarity index 76% rename from .github/workflows/protocol-drift-stress-smoke.yml rename to .github/workflows/protocol-drift-external-smoke.yml index 72b1eeda8..579f79a25 100644 --- a/.github/workflows/protocol-drift-stress-smoke.yml +++ b/.github/workflows/protocol-drift-external-smoke.yml @@ -1,19 +1,18 @@ -name: Protocol Drift Stress Smoke +name: Protocol Drift External Smoke on: workflow_dispatch: inputs: clearnode_ws_url: - description: Lightweight shared Clearnode WebSocket URL to smoke test + description: Existing Clearnode WebSocket URL to smoke test, for example wss://... required: true - default: wss://clearnode-stress.yellow.org/v1/ws permissions: contents: read jobs: - protocol-drift-stress-smoke: - name: Protocol Drift Stress Smoke + protocol-drift-external-smoke: + name: Protocol Drift External Smoke runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -42,17 +41,17 @@ jobs: run: npm ci working-directory: sdk/ts-compat - - name: Run shared Clearnode compatibility smoke + - name: Run external Clearnode compatibility smoke run: ./scripts/check-protocol-drift.sh --runtime env: CLEARNODE_RUNTIME_SMOKE_EXTERNAL: '1' CLEARNODE_RUNTIME_SMOKE_WS_URL: ${{ inputs.clearnode_ws_url }} CLEARNODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs - - name: Upload stress smoke logs + - name: Upload external smoke logs if: failure() uses: actions/upload-artifact@v4 with: - name: protocol-drift-stress-smoke-logs + name: protocol-drift-external-smoke-logs path: runtime-smoke-logs if-no-files-found: ignore diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index acd981225..82550ab25 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -27,11 +27,11 @@ The runtime smoke builds the TS SDK, builds TS compat, builds a temporary local This is not a load test. It uses empty local `blockchains` and `assets` config so PR CI does not depend on external RPC endpoints, wallets, or shared Clearnode deployments. -To run the same lightweight compatibility smoke against a shared Clearnode, use external-node mode: +To run the same lightweight compatibility smoke against an existing Clearnode, use external-node mode: ```bash CLEARNODE_RUNTIME_SMOKE_EXTERNAL=1 \ -CLEARNODE_RUNTIME_SMOKE_WS_URL=wss://clearnode-stress.yellow.org/v1/ws \ +CLEARNODE_RUNTIME_SMOKE_WS_URL= \ ./scripts/check-protocol-drift.sh --runtime ``` @@ -92,16 +92,16 @@ Each guard includes at least one negative test or mutation-style check that prov - Transform fixture failure: update or add raw Clearnode fixtures only for wire shapes the SDK intentionally supports. Do not silently accept missing required fields that would later crash consumers. - Compat mapping failure: current v1 SDK shapes are primary. Legacy fallbacks must stay explicit in tests; do not add broad best-effort mappers without fixture coverage. - Runtime setup/startup failure: inspect `runtime-smoke-logs` in CI or the preserved temp log directory locally. `[setup]` points to build/setup, `[startup]` to local Clearnode process exit, `[connection]` to WebSocket readiness, and `[transform]` or `[compat mapping]` to SDK response handling. -- Shared stress smoke failure: rerun the manual workflow or local external-node command to confirm it is not shared-environment state. Stress smoke is release signal, not a PR blocker. +- External smoke failure: rerun the manual workflow or local external-node command to confirm it is not shared-environment state. External smoke is release/demo signal, not a PR blocker. ## CI Policy `Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Clearnode deployments. -`Test (Protocol Drift Runtime)` also runs on PRs and main pushes. It starts an isolated local Clearnode inside the GitHub Actions job and does not use shared stress or sandbox endpoints. +`Test (Protocol Drift Runtime)` also runs on PRs and main pushes. It starts an isolated local Clearnode inside the GitHub Actions job and does not use shared external or sandbox endpoints. If runtime smoke fails in CI, inspect the `protocol-drift-runtime-smoke-logs` artifact. The smoke command categorizes failures as setup, startup, connection, timeout, transform, or compat mapping failures. The runtime job uses read-only repository permissions and no secrets. It builds Clearnode locally instead of pulling or publishing an image, so ordinary PRs do not need package-write permissions. If organization policy restricts forked PR workflows, a maintainer can rerun the same command locally or through an allowed CI rerun. -Shared stress Clearnode checks are manual only through the `Protocol Drift Stress Smoke` workflow. They are not PR-blocking and are not scheduled by default. `wss://clearnode-stress.yellow.org/v1/ws` can be newer than sandbox while audit remediations roll out, so it is useful for release confidence but must not be a default PR blocker. +External Clearnode checks are manual only through the `Protocol Drift External Smoke` workflow. The workflow requires the caller to provide the WebSocket URL, is not PR-blocking, and is not scheduled by default. Team-owned temporary environments can still be useful for release confidence, but they must not become default PR blockers unless their availability and data contract are owned. From 32ad8fc9a297c30b7d64de2dde8c03e84429145a Mon Sep 17 00:00:00 2001 From: Maharshi Mishra Date: Wed, 6 May 2026 13:40:03 +0530 Subject: [PATCH 11/11] Address protocol drift review feedback --- .github/workflows/main-pr.yml | 12 ++ .github/workflows/main-push.yml | 14 ++- .../protocol-drift-external-smoke.yml | 13 ++- .github/workflows/test-sdk.yml | 18 +++ scripts/check-protocol-drift.sh | 10 +- scripts/drift/runtime-smoke.mjs | 110 +++++++++++++----- sdk/PROTOCOL_DRIFT_GUARDS.md | 39 ++++--- .../public-api-drift.test.ts.snap | 100 ++++++++++------ .../test/unit/public-api-drift.test.ts | 9 +- sdk/ts/package-lock.json | 3 +- sdk/ts/package.json | 3 +- sdk/ts/src/client.ts | 6 + sdk/ts/src/session_key_state_transforms.ts | 2 +- sdk/ts/src/utils.ts | 38 +++++- .../public-api-drift.test.ts.snap | 23 ++++ sdk/ts/test/unit/abi-drift.test.ts | 17 ++- sdk/ts/test/unit/public-api-drift.test.ts | 9 +- sdk/ts/test/unit/rpc-drift.test.ts | 2 + sdk/ts/test/unit/transform-drift.test.ts | 56 ++++++++- 19 files changed, 364 insertions(+), 120 deletions(-) diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml index 37c875bdf..18c733e48 100644 --- a/.github/workflows/main-pr.yml +++ b/.github/workflows/main-pr.yml @@ -22,6 +22,7 @@ jobs: with: project-path: 'sdk/ts' project-name: 'TS SDK' + forge-build: true test-sdk-compat: name: Test (TS SDK Compat) @@ -38,6 +39,17 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + + - name: Build contract artifacts + run: forge build + working-directory: contracts - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/.github/workflows/main-push.yml b/.github/workflows/main-push.yml index 71c66be3f..baffbf128 100644 --- a/.github/workflows/main-push.yml +++ b/.github/workflows/main-push.yml @@ -22,6 +22,7 @@ jobs: with: project-path: 'sdk/ts' project-name: 'TS SDK' + forge-build: true test-sdk-compat: name: Test (TS SDK Compat) @@ -38,6 +39,17 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install Foundry + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + + - name: Build contract artifacts + run: forge build + working-directory: contracts - name: Setup Node.js uses: actions/setup-node@v4 @@ -103,7 +115,7 @@ jobs: - name: Run runtime protocol drift smoke run: ./scripts/check-protocol-drift.sh --runtime env: - CLEARNODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + NITRONODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs - name: Upload runtime smoke logs if: failure() diff --git a/.github/workflows/protocol-drift-external-smoke.yml b/.github/workflows/protocol-drift-external-smoke.yml index 579f79a25..d28befb86 100644 --- a/.github/workflows/protocol-drift-external-smoke.yml +++ b/.github/workflows/protocol-drift-external-smoke.yml @@ -3,8 +3,8 @@ name: Protocol Drift External Smoke on: workflow_dispatch: inputs: - clearnode_ws_url: - description: Existing Clearnode WebSocket URL to smoke test, for example wss://... + nitronode_ws_url: + description: Existing Nitronode WebSocket URL to smoke test, for example wss://... required: true permissions: @@ -41,12 +41,13 @@ jobs: run: npm ci working-directory: sdk/ts-compat - - name: Run external Clearnode compatibility smoke + - name: Run external Nitronode compatibility smoke run: ./scripts/check-protocol-drift.sh --runtime env: - CLEARNODE_RUNTIME_SMOKE_EXTERNAL: '1' - CLEARNODE_RUNTIME_SMOKE_WS_URL: ${{ inputs.clearnode_ws_url }} - CLEARNODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs + NITRONODE_RUNTIME_SMOKE_EXTERNAL: '1' + NITRONODE_RUNTIME_SMOKE_WS_URL: ${{ inputs.nitronode_ws_url }} + NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY: ${{ secrets.NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY }} + NITRONODE_RUNTIME_SMOKE_LOG_DIR: runtime-smoke-logs - name: Upload external smoke logs if: failure() diff --git a/.github/workflows/test-sdk.yml b/.github/workflows/test-sdk.yml index a69729f9e..da0a6c9c2 100644 --- a/.github/workflows/test-sdk.yml +++ b/.github/workflows/test-sdk.yml @@ -16,6 +16,11 @@ on: required: false type: string default: '' + forge-build: + description: 'Build Foundry artifacts before validating this SDK project' + required: false + type: boolean + default: false jobs: test: @@ -25,6 +30,19 @@ jobs: contents: read steps: - uses: actions/checkout@v6 + with: + submodules: ${{ inputs.forge-build && 'recursive' || 'false' }} + + - name: Install Foundry + if: ${{ inputs.forge-build }} + uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.5.1 + + - name: Build contract artifacts + if: ${{ inputs.forge-build }} + run: forge build + working-directory: contracts - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/scripts/check-protocol-drift.sh b/scripts/check-protocol-drift.sh index aa2fa9ba1..3ea0148dc 100755 --- a/scripts/check-protocol-drift.sh +++ b/scripts/check-protocol-drift.sh @@ -8,12 +8,12 @@ usage() { Usage: scripts/check-protocol-drift.sh [--static|--runtime] --static Run deterministic protocol/SDK/compat drift checks. - --runtime Run runtime smoke checks against an ephemeral/local Clearnode. + --runtime Run runtime smoke checks against an ephemeral/local Nitronode. -Runtime smoke starts an isolated local Clearnode with a temporary config by -default. Set CLEARNODE_RUNTIME_SMOKE_EXTERNAL=1 and CLEARNODE_RUNTIME_SMOKE_WS_URL -to run the same lightweight compatibility smoke against an existing node. This is -not a load or stress test. +Runtime smoke starts an isolated local Nitronode with a temporary config by +default. Set NITRONODE_RUNTIME_SMOKE_EXTERNAL=1, NITRONODE_RUNTIME_SMOKE_WS_URL, +and NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY to run the same lightweight compatibility +smoke against an existing node. This is not a load or stress test. USAGE } diff --git a/scripts/drift/runtime-smoke.mjs b/scripts/drift/runtime-smoke.mjs index 52b94069d..9edf6cb96 100644 --- a/scripts/drift/runtime-smoke.mjs +++ b/scripts/drift/runtime-smoke.mjs @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'; import { once } from 'node:events'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,13 +14,14 @@ import { NitroliteClient } from '../../sdk/ts-compat/dist/index.js'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '../..'); -const wsURL = process.env.CLEARNODE_RUNTIME_SMOKE_WS_URL ?? 'ws://127.0.0.1:7824/ws'; -const readyTimeoutMs = Number(process.env.CLEARNODE_RUNTIME_SMOKE_READY_TIMEOUT_MS ?? 15000); -const adversarialMode = process.env.CLEARNODE_RUNTIME_SMOKE_ADVERSARIAL ?? ''; -const externalLogDirInput = process.env.CLEARNODE_RUNTIME_SMOKE_LOG_DIR ?? ''; -const externalLogDir = externalLogDirInput ? path.resolve(repoRoot, externalLogDirInput) : ''; -const useExternalNode = process.env.CLEARNODE_RUNTIME_SMOKE_EXTERNAL === '1'; -const privateKey = +const sdkRequire = createRequire(path.join(repoRoot, 'sdk/ts/package.json')); +const WebSocketCtor = globalThis.WebSocket ?? sdkRequire('ws'); +const wsURL = process.env.NITRONODE_RUNTIME_SMOKE_WS_URL ?? 'ws://127.0.0.1:7824/ws'; +const readyTimeoutMs = Number(process.env.NITRONODE_RUNTIME_SMOKE_READY_TIMEOUT_MS ?? 15000); +const adversarialMode = process.env.NITRONODE_RUNTIME_SMOKE_ADVERSARIAL ?? ''; +const externalLogDirInput = process.env.NITRONODE_RUNTIME_SMOKE_LOG_DIR ?? ''; +const useExternalNode = process.env.NITRONODE_RUNTIME_SMOKE_EXTERNAL === '1'; +const anvilPrivateKey = '0x59c6995e998f97a5a0044966f094538f0d0921e301baca6a9ae52cd7834c90b9'; class SmokeError extends Error { @@ -37,10 +39,51 @@ function assertSmoke(condition, category, message) { } } +function resolveRepoChildPath(input, label) { + const resolved = path.resolve(repoRoot, input); + const relative = path.relative(repoRoot, resolved); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new SmokeError('setup', `${label} must resolve inside the repository`); + } + return resolved; +} + +function privateKeyForMode() { + const configuredPrivateKey = process.env.NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY; + if (useExternalNode) { + if (!configuredPrivateKey) { + throw new SmokeError( + 'setup', + 'NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY is required when NITRONODE_RUNTIME_SMOKE_EXTERNAL=1' + ); + } + return configuredPrivateKey; + } + + // Well-known Anvil/Hardhat test account #2, used only for isolated local smoke. + return configuredPrivateKey ?? anvilPrivateKey; +} + +function childEnv(configDir) { + const env = { + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + NITRONODE_CONFIG_DIR_PATH: configDir, + }; + + return Object.fromEntries(Object.entries(env).filter(([, value]) => value !== undefined)); +} + function logStep(message) { console.log(`[runtime-smoke] ${message}`); } +const externalLogDir = externalLogDirInput + ? resolveRepoChildPath(externalLogDirInput, 'NITRONODE_RUNTIME_SMOKE_LOG_DIR') + : ''; +const privateKey = privateKeyForMode(); + async function withTimeout(label, promise, timeoutMs = 5000) { const timeout = sleep(timeoutMs).then(() => { throw new SmokeError('timeout', `${label} timed out after ${timeoutMs}ms`); @@ -50,7 +93,7 @@ async function withTimeout(label, promise, timeoutMs = 5000) { function openWebSocket(url, timeoutMs = 500) { return new Promise((resolve, reject) => { - const ws = new WebSocket(url); + const ws = new WebSocketCtor(url); let settled = false; const finish = (err) => { @@ -81,7 +124,7 @@ async function waitForWebSocket(url, child = null, timeoutMs = 15000) { if (child && child.exitCode !== null) { throw new SmokeError( 'startup', - `Clearnode exited before readiness with code ${child.exitCode}` + `Nitronode exited before readiness with code ${child.exitCode}` ); } @@ -96,7 +139,7 @@ async function waitForWebSocket(url, child = null, timeoutMs = 15000) { throw new SmokeError( 'connection', - `Clearnode did not accept WebSocket connections at ${url}`, + `Nitronode did not accept WebSocket connections at ${url}`, lastError ); } @@ -160,10 +203,10 @@ async function writeConfig(configDir) { await writeFile( path.join(configDir, '.env'), [ - 'CLEARNODE_DATABASE_DRIVER=sqlite', - 'CLEARNODE_SIGNER_TYPE=key', - `CLEARNODE_SIGNER_KEY=${privateKey}`, - 'CLEARNODE_LOG_LEVEL=error', + 'NITRONODE_DATABASE_DRIVER=sqlite', + 'NITRONODE_SIGNER_TYPE=key', + `NITRONODE_SIGNER_KEY=${privateKey}`, + 'NITRONODE_LOG_LEVEL=error', '', ].join('\n') ); @@ -186,19 +229,20 @@ async function writeFailureLogs(paths, stdout, stderr, summary) { await mkdir(externalLogDir, { recursive: true }); await writeFile(path.join(externalLogDir, 'summary.txt'), summary); - await writeFile(path.join(externalLogDir, 'clearnode.stdout.log'), stdout); - await writeFile(path.join(externalLogDir, 'clearnode.stderr.log'), stderr); + await writeFile(path.join(externalLogDir, 'nitronode.stdout.log'), stdout); + await writeFile(path.join(externalLogDir, 'nitronode.stderr.log'), stderr); } async function runSmoke() { const configDir = await mkdtemp(path.join(tmpdir(), 'nitrolite-runtime-smoke-')); - const binaryPath = path.join(configDir, 'clearnode-smoke'); - const stdoutPath = path.join(configDir, 'clearnode.stdout.log'); - const stderrPath = path.join(configDir, 'clearnode.stderr.log'); + const binaryPath = path.join(configDir, 'nitronode-smoke'); + const stdoutPath = path.join(configDir, 'nitronode.stdout.log'); + const stderrPath = path.join(configDir, 'nitronode.stderr.log'); let stdout = ''; let stderr = ''; let client = null; let child = null; + let compatLogLines = []; const logs = () => [ `stdout (${stdoutPath}):`, @@ -209,20 +253,17 @@ async function runSmoke() { try { if (useExternalNode) { - logStep(`using external Clearnode at ${wsURL}`); + logStep(`using external Nitronode at ${wsURL}`); } else { logStep(`writing isolated config in ${configDir}`); await writeConfig(configDir); - logStep('building temporary Clearnode binary'); - await runCommand('go', ['build', '-o', binaryPath, './clearnode'], { cwd: repoRoot }, 'setup'); + logStep('building temporary Nitronode binary'); + await runCommand('go', ['build', '-o', binaryPath, './nitronode'], { cwd: repoRoot }, 'setup'); - logStep(`starting Clearnode and waiting for ${wsURL}`); + logStep(`starting Nitronode and waiting for ${wsURL}`); child = spawn(binaryPath, { cwd: repoRoot, - env: { - ...process.env, - CLEARNODE_CONFIG_DIR_PATH: configDir, - }, + env: childEnv(configDir), stdio: ['ignore', 'pipe', 'pipe'], }); @@ -306,8 +347,9 @@ async function runSmoke() { const originalWarn = console.warn; let compatSessions; try { - console.info = () => {}; - console.warn = () => {}; + compatLogLines = []; + console.info = (...args) => compatLogLines.push(['info', ...args].join(' ')); + console.warn = (...args) => compatLogLines.push(['warn', ...args].join(' ')); compatSessions = await withTimeout( 'compat.getAppSessionsList', compatClient.getAppSessionsList() @@ -327,8 +369,14 @@ async function runSmoke() { logStep('runtime smoke passed'); } catch (err) { const message = err instanceof Error ? err.message : String(err); - await writeFailureLogs({ stdoutPath, stderrPath }, stdout, stderr, message); + const summary = compatLogLines.length > 0 + ? `${message}\n\ncompat logs:\n${compatLogLines.join('\n')}` + : message; + await writeFailureLogs({ stdoutPath, stderrPath }, stdout, stderr, summary); console.error(message); + if (compatLogLines.length > 0) { + console.error(`compat logs:\n${compatLogLines.join('\n')}`); + } if (err instanceof SmokeError) { console.error(logs()); } @@ -338,7 +386,7 @@ async function runSmoke() { await closeClient(client); } finally { if (child) { - logStep('stopping Clearnode'); + logStep('stopping Nitronode'); await stopProcess(child); } if (process.exitCode) { diff --git a/sdk/PROTOCOL_DRIFT_GUARDS.md b/sdk/PROTOCOL_DRIFT_GUARDS.md index 82550ab25..c4f87a13a 100644 --- a/sdk/PROTOCOL_DRIFT_GUARDS.md +++ b/sdk/PROTOCOL_DRIFT_GUARDS.md @@ -7,14 +7,18 @@ This repo has deterministic drift checks for protocol, `@yellow-org/sdk`, and `@ Run all implemented static checks from the repo root: ```bash +(cd contracts && forge build) ./scripts/check-protocol-drift.sh --static ``` +`forge build` is required because ABI drift tests compare checked-in SDK ABIs +against generated Foundry artifacts in `contracts/out`. + Run package checks directly: ```bash -cd sdk/ts && npm run drift:check -cd sdk/ts-compat && npm run drift:check +(cd sdk/ts && npm run drift:check) +(cd sdk/ts-compat && npm run drift:check) ``` Run the lightweight runtime smoke from the repo root: @@ -23,30 +27,31 @@ Run the lightweight runtime smoke from the repo root: ./scripts/check-protocol-drift.sh --runtime ``` -The runtime smoke builds the TS SDK, builds TS compat, builds a temporary local Clearnode binary, starts it with isolated SQLite config, and connects to `ws://127.0.0.1:7824/ws`. It checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping over the live SDK app-session result. +The runtime smoke builds the TS SDK, builds TS compat, builds a temporary local Nitronode binary, starts it with isolated SQLite config, and connects to `ws://127.0.0.1:7824/ws`. It checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping over the live SDK app-session result. -This is not a load test. It uses empty local `blockchains` and `assets` config so PR CI does not depend on external RPC endpoints, wallets, or shared Clearnode deployments. +This is not a load test. It uses empty local `blockchains` and `assets` config so PR CI does not depend on external RPC endpoints, wallets, or shared Nitronode deployments. -To run the same lightweight compatibility smoke against an existing Clearnode, use external-node mode: +To run the same lightweight compatibility smoke against an existing Nitronode, use external-node mode: ```bash -CLEARNODE_RUNTIME_SMOKE_EXTERNAL=1 \ -CLEARNODE_RUNTIME_SMOKE_WS_URL= \ +NITRONODE_RUNTIME_SMOKE_EXTERNAL=1 \ +NITRONODE_RUNTIME_SMOKE_WS_URL= \ +NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY=<0x-private-key> \ ./scripts/check-protocol-drift.sh --runtime ``` -External-node mode does not start a local Clearnode and does not assert local-only empty config. It still checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping. +External-node mode does not start a local Nitronode and does not assert local-only empty config. It still checks `ping`, `getConfig`, `getAssets`, `getAppSessions`, key-state reads, and compat mapping. ## Guard Layers -- RPC method drift: compares Go RPC method literals, Clearnode router registrations, TS method constants, and public TS client wrappers. +- RPC method drift: compares Go RPC method literals, Nitronode router registrations, TS method constants, and public TS client wrappers. - RPC DTO drift: compares Go JSON-tagged DTO structs against TS request/response interfaces for required fields, optional fields, and scalar/container shape. - Public API drift: snapshots root runtime exports and compiler-derived TypeScript signatures for `@yellow-org/sdk` and `@yellow-org/sdk-compat`, including type-only exports, interfaces, functions, classes, public class methods, enums, constants, and type aliases. - ABI drift: compares checked-in `ChannelHub` functions against the current Foundry artifact, checks SDK-consumed ERC20 functions against the ERC20 artifact, and guards the manually checked-in AppRegistry ABI against an explicit consumed-function manifest until that contract artifact exists in this repo. - Signing drift: compares TS app-session and session-key packers against Go-generated canonical vectors for create, deposit, withdraw, operate, fractional decimal, and uint64 boundary cases. -- Transform drift: checks raw Clearnode response fixtures for app sessions, node config, assets, and strict failure on unsupported required shapes. +- Transform drift: checks raw Nitronode response fixtures for app sessions, node config, assets, and strict failure on unsupported required shapes. - Compat drift: checks current v1 app-session shape, legacy flat fallback shape, and asset decimal conversion in `NitroliteClient.getAppSessionsList()`. -- Runtime smoke drift: starts an isolated local Clearnode and verifies live SDK/compat calls against the current runtime response shape. +- Runtime smoke drift: starts an isolated local Nitronode and verifies live SDK/compat calls against the current runtime response shape. ## Intentional Updates @@ -89,19 +94,19 @@ Each guard includes at least one negative test or mutation-style check that prov - Public API snapshot drift: treat the diff as an SDK API change. If intentional, update snapshots with `npm run drift:check -- -u` in the affected package and document the API change in the PR body. - ABI drift: regenerate Foundry artifacts and SDK ABI files with `cd contracts && forge build` and `cd ../sdk/ts && npm run codegen-abi`. If AppRegistry changes, remember it is currently manifest-guarded because the matching artifact is not in this repo. - Signing hash mismatch: regenerate Go source-of-truth vectors with `go run ./scripts/drift/generate-app-signing-vectors.go`, then inspect whether the change is field order, enum value, amount formatting, nonce/version encoding, or exact session-data bytes. -- Transform fixture failure: update or add raw Clearnode fixtures only for wire shapes the SDK intentionally supports. Do not silently accept missing required fields that would later crash consumers. +- Transform fixture failure: update or add raw Nitronode fixtures only for wire shapes the SDK intentionally supports. Do not silently accept missing required fields that would later crash consumers. - Compat mapping failure: current v1 SDK shapes are primary. Legacy fallbacks must stay explicit in tests; do not add broad best-effort mappers without fixture coverage. -- Runtime setup/startup failure: inspect `runtime-smoke-logs` in CI or the preserved temp log directory locally. `[setup]` points to build/setup, `[startup]` to local Clearnode process exit, `[connection]` to WebSocket readiness, and `[transform]` or `[compat mapping]` to SDK response handling. +- Runtime setup/startup failure: inspect `runtime-smoke-logs` in CI or the preserved temp log directory locally. `[setup]` points to build/setup, `[startup]` to local Nitronode process exit, `[connection]` to WebSocket readiness, and `[transform]` or `[compat mapping]` to SDK response handling. - External smoke failure: rerun the manual workflow or local external-node command to confirm it is not shared-environment state. External smoke is release/demo signal, not a PR blocker. ## CI Policy -`Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Clearnode deployments. +`Test (Protocol Drift Static)` runs on PRs and main pushes. It is deterministic and does not call shared Nitronode deployments. -`Test (Protocol Drift Runtime)` also runs on PRs and main pushes. It starts an isolated local Clearnode inside the GitHub Actions job and does not use shared external or sandbox endpoints. +`Test (Protocol Drift Runtime)` also runs on PRs and main pushes. It starts an isolated local Nitronode inside the GitHub Actions job and does not use shared external or sandbox endpoints. If runtime smoke fails in CI, inspect the `protocol-drift-runtime-smoke-logs` artifact. The smoke command categorizes failures as setup, startup, connection, timeout, transform, or compat mapping failures. -The runtime job uses read-only repository permissions and no secrets. It builds Clearnode locally instead of pulling or publishing an image, so ordinary PRs do not need package-write permissions. If organization policy restricts forked PR workflows, a maintainer can rerun the same command locally or through an allowed CI rerun. +The runtime job uses read-only repository permissions and no secrets. It builds Nitronode locally instead of pulling or publishing an image, so ordinary PRs do not need package-write permissions. If organization policy restricts forked PR workflows, a maintainer can rerun the same command locally or through an allowed CI rerun. -External Clearnode checks are manual only through the `Protocol Drift External Smoke` workflow. The workflow requires the caller to provide the WebSocket URL, is not PR-blocking, and is not scheduled by default. Team-owned temporary environments can still be useful for release confidence, but they must not become default PR blockers unless their availability and data contract are owned. +External Nitronode checks are manual only through the `Protocol Drift External Smoke` workflow. The workflow requires the caller to provide the WebSocket URL and the repository secret `NITRONODE_RUNTIME_SMOKE_PRIVATE_KEY`, is not PR-blocking, and is not scheduled by default. Team-owned temporary environments can still be useful for release confidence, but they must not become default PR blockers unless their availability and data contract are owned. diff --git a/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts-compat/test/unit/__snapshots__/public-api-drift.test.ts.snap index 3d73f8792..1b4a6dd19 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 @@ -235,9 +235,11 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "signatures": [], }, { - "kind": "const", + "kind": "function", "name": "createAppSessionMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(signer: MessageSigner, params: CreateAppSessionRequestParams, requestId?: number, timestamp?: number): Promise", + ], }, { "kind": "interface", @@ -283,19 +285,25 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "signatures": [], }, { - "kind": "const", + "kind": "function", "name": "createCloseAppSessionMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(signer: MessageSigner, params: CloseAppSessionRequestParams, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createCloseChannelMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, _channelId: string, _fundDestination: Address, _requestId?: number, _timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createCreateChannelMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, _params: unknown, _requestId?: number, _timestamp?: number): Promise", + ], }, { "kind": "function", @@ -312,44 +320,60 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API ], }, { - "kind": "const", + "kind": "function", "name": "createGetAppDefinitionMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, appSessionId: string, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createGetAppSessionsMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, participant: Address, status?: RPCChannelStatus, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createGetChannelsMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, participant?: Address, status?: RPCChannelStatus, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createGetLedgerBalancesMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(signer: MessageSigner, accountId?: string, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createPingMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createResizeChannelMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, _params: unknown, _requestId?: number, _timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createSubmitAppStateMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(signer: MessageSigner, params: SubmitAppStateRequestParams, requestId?: number, timestamp?: number): Promise", + ], }, { - "kind": "const", + "kind": "function", "name": "createTransferMessage", - "type": "(..._args: any[]) => Promise", + "signatures": [ + "(_signer: MessageSigner, _params: unknown, _requestId?: number, _timestamp?: number): Promise", + ], }, { "kind": "const", @@ -488,25 +512,28 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API }, { "constructors": [ - "(client: Client, userAddress: Address, chainId: number, blockchainRPCs?: Record): NitroliteClient", + "(client: Client, userAddress: Address, chainId: number, walletClient: WalletClient, blockchainRPCs?: Record): NitroliteClient", ], "kind": "class", "name": "NitroliteClient", "properties": [ "acknowledge: (tokenAddress: Address): Promise", "approveSecurityToken: (chainId: number, amount: bigint): Promise", + "approveTokens: (tokenAddress: Address, amount: bigint): Promise", "cancelSecurityTokensWithdrawal: (chainId: number): Promise", "challengeChannel: (params: { state: any; }): Promise", "checkTokenAllowance: (chainId: number, tokenAddress: Address): Promise", + "checkpointChannel: (_params: unknown): Promise", "close: (): Promise", "closeAppSession: (appSessionIdOrParams: string | CloseAppSessionRequestParams, allocations?: RPCAppSessionAllocation[], quorumSigs?: string[]): Promise<{ appSessionId: string; }>", "closeChannel: (params?: { tokenAddress?: Address | string; } | any): Promise", "createAppSession: (definitionOrParams: RPCAppDefinition | CreateAppSessionRequestParams, allocations?: RPCAppSessionAllocation[], quorumSigs?: string[], opts?: { ownerSig?: string; }): Promise<{ appSessionId: string; version: string; status: string; }>", - "createChannel: (_respParams?: any): Promise", + "createChannel: (_respParams?: any): Promise", "deposit: (tokenAddress: Address, amount: bigint): Promise", "depositAndCreateChannel: (tokenAddress: Address, amount: bigint, _respParams?: any): Promise", "findOpenChannel: (tokenAddress: Address | string, chainId?: number): LedgerChannel | null", "formatAmount: (tokenAddress: Address | string, rawAmount: bigint): Promise", + "getAccountBalance: (_tokenAddress: Address | Address[]): Promise", "getAccountInfo: (): Promise", "getActionAllowances: (wallet?: Address): Promise", "getAppDefinition: (appSessionId: string): Promise", @@ -515,6 +542,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "getAssetsList: (): Promise", "getBalances: (wallet?: Address): Promise", "getBlockchains: (): Promise", + "getChannelBalance: (_channelId: string, _tokenAddress: Address | Address[]): Promise", "getChannelData: (_channelId: string): Promise", "getChannels: (): Promise", "getConfig: (): Promise", @@ -524,6 +552,9 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "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", @@ -640,7 +671,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API { "kind": "const", "name": "parseCloseAppSessionResponse", - "type": "(raw: string) => { params: { appSessionId: any; }; }", + "type": "(raw: string) => { params: any; }", }, { "kind": "const", @@ -650,7 +681,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API { "kind": "const", "name": "parseCreateAppSessionResponse", - "type": "(raw: string) => { params: { appSessionId: any; }; }", + "type": "(raw: string) => { params: { appSessionId: string; version: string | number; status: string; }; }", }, { "kind": "const", @@ -660,27 +691,27 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API { "kind": "const", "name": "parseGetAppDefinitionResponse", - "type": "(raw: string) => { params: any; }", + "type": "(raw: string) => { params: Record; }", }, { "kind": "const", "name": "parseGetAppSessionsResponse", - "type": "(raw: string) => { params: { appSessions: any; }; }", + "type": "(raw: string) => { params: { appSessions: Array; }; }", }, { "kind": "const", "name": "parseGetChannelsResponse", - "type": "(raw: string) => { params: { channels: any; }; }", + "type": "(raw: string) => { params: { channels: Array; }; }", }, { "kind": "const", "name": "parseGetLedgerBalancesResponse", - "type": "(raw: string) => { params: { ledgerBalances: any; }; }", + "type": "(raw: string) => { params: { ledgerBalances: Array; }; }", }, { "kind": "const", "name": "parseGetLedgerEntriesResponse", - "type": "(raw: string) => { params: { ledgerEntries: any; }; }", + "type": "(raw: string) => { params: { ledgerEntries: Array; }; }", }, { "kind": "const", @@ -690,7 +721,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API { "kind": "const", "name": "parseSubmitAppStateResponse", - "type": "(raw: string) => { params: { appSessionId: any; version: any; status: any; }; }", + "type": "(raw: string) => { params: any; }", }, { "declaration": "number", @@ -759,6 +790,7 @@ exports[`compat public runtime API drift guard keeps root TypeScript public API "Operate = 'operate'", "Deposit = 'deposit'", "Withdraw = 'withdraw'", + "Close = 'close'", ], "name": "RPCAppStateIntent", }, diff --git a/sdk/ts-compat/test/unit/public-api-drift.test.ts b/sdk/ts-compat/test/unit/public-api-drift.test.ts index 4c91c2113..1e8ccccbb 100644 --- a/sdk/ts-compat/test/unit/public-api-drift.test.ts +++ b/sdk/ts-compat/test/unit/public-api-drift.test.ts @@ -63,7 +63,7 @@ function signaturesForType( } function isPrivateOrProtected(declaration: ts.Declaration): boolean { - const flags = ts.getCombinedModifierFlags(declaration as ts.Declaration); + const flags = ts.getCombinedModifierFlags(declaration); return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); } @@ -209,11 +209,8 @@ describe('compat public runtime API drift guard', () => { expect(helper?.signatures?.[0]).toContain('signature: Hex | string'); }); - it('proves adversarial public export removal is observable', () => { - const exports = new Set(Object.keys(publicApi)); - exports.delete('NitroliteClient'); - - expect(exports.has('NitroliteClient')).toBe(false); + it('keeps NitroliteClient exported', () => { + expect(Object.keys(publicApi)).toContain('NitroliteClient'); }); it('proves adversarial public signature changes are observable', () => { diff --git a/sdk/ts/package-lock.json b/sdk/ts/package-lock.json index 89418b279..ae51a7059 100644 --- a/sdk/ts/package-lock.json +++ b/sdk/ts/package-lock.json @@ -34,7 +34,8 @@ "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "ws": "8.18.3" }, "engines": { "node": ">=20.0.0" diff --git a/sdk/ts/package.json b/sdk/ts/package.json index f798a6107..9dba7c92e 100644 --- a/sdk/ts/package.json +++ b/sdk/ts/package.json @@ -86,6 +86,7 @@ "rimraf": "^6.1.3", "ts-jest": "^29.1.2", "ts-node": "^10.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "ws": "8.18.3" } } diff --git a/sdk/ts/src/client.ts b/sdk/ts/src/client.ts index 96363f417..8656ceeb5 100644 --- a/sdk/ts/src/client.ts +++ b/sdk/ts/src/client.ts @@ -1705,6 +1705,9 @@ export class Client { session_key: sessionKey, }; const resp = await this.rpcClient.channelsV1GetLastKeyStates(req); + if (!Array.isArray(resp.states)) { + throw new Error('Invalid channel key states response: expected states to be an array'); + } return resp.states.map((state, index) => transformChannelSessionKeyState(state, `channel session key state[${index}]`) ); @@ -1757,6 +1760,9 @@ export class Client { session_key: sessionKey, }; const resp = await this.rpcClient.appSessionsV1GetLastKeyStates(req); + if (!Array.isArray(resp.states)) { + throw new Error('Invalid app key states response: expected states to be an array'); + } return resp.states.map((state, index) => transformAppSessionKeyState(state, `app session key state[${index}]`) ); diff --git a/sdk/ts/src/session_key_state_transforms.ts b/sdk/ts/src/session_key_state_transforms.ts index 1f10bb3f0..2a1a9fe3a 100644 --- a/sdk/ts/src/session_key_state_transforms.ts +++ b/sdk/ts/src/session_key_state_transforms.ts @@ -2,7 +2,7 @@ import type { AppSessionKeyStateV1 } from './app/types.js'; import type { ChannelSessionKeyStateV1 } from './rpc/types.js'; function asRecord(raw: unknown, context: string): Record { - if (!raw || typeof raw !== 'object') { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { throw new Error(`Invalid ${context}: expected object`); } return raw as Record; diff --git a/sdk/ts/src/utils.ts b/sdk/ts/src/utils.ts index a980ba367..d3199ed9b 100644 --- a/sdk/ts/src/utils.ts +++ b/sdk/ts/src/utils.ts @@ -355,7 +355,11 @@ export function transformSignedAppStateUpdateToRPC(signed: SignedAppStateUpdateV * The server returns snake_case JSON that needs conversion to SDK types. */ export function transformAppSessionInfo(raw: any): AppSessionInfoV1 { - const allocations = raw.allocations || []; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid app session: expected object payload'); + } + + const allocations = raw.allocations; if (!Array.isArray(allocations)) { throw new Error('Invalid app session allocations: expected allocations to be an array'); } @@ -394,16 +398,38 @@ function transformAppAllocationFromRPC(raw: any, index: number) { * The server returns snake_case JSON that needs conversion to SDK types. */ export function transformAppDefinitionFromRPC(raw: any): AppDefinitionV1 { - if (!raw || !raw.application_id || raw.nonce === undefined || raw.nonce === null) { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Invalid app definition: missing required fields (application_id, nonce)'); + } + if (!raw.application_id || raw.nonce === undefined || raw.nonce === null) { throw new Error('Invalid app definition: missing required fields (application_id, nonce)'); } + if (!Array.isArray(raw.participants)) { + throw new Error('Invalid app definition: expected participants to be an array'); + } + if (raw.quorum === undefined || raw.quorum === null) { + throw new Error('Invalid app definition: missing required field quorum'); + } + return { applicationId: raw.application_id, - participants: (raw.participants || []).map((p: any) => ({ - walletAddress: p.wallet_address as Address, - signatureWeight: p.signature_weight, - })), + participants: raw.participants.map(transformAppParticipantFromRPC), quorum: raw.quorum, nonce: BigInt(raw.nonce), }; } + +function transformAppParticipantFromRPC(raw: any, index: number) { + const context = `app definition participant[${index}]`; + if (!raw || typeof raw.wallet_address !== 'string') { + throw new Error(`Invalid ${context}: missing required string field wallet_address`); + } + if (typeof raw.signature_weight !== 'number') { + throw new Error(`Invalid ${context}: missing required numeric field signature_weight`); + } + + return { + walletAddress: raw.wallet_address as Address, + signatureWeight: raw.signature_weight, + }; +} diff --git a/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap b/sdk/ts/test/unit/__snapshots__/public-api-drift.test.ts.snap index c86cd2df1..ea7c38057 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 @@ -45,6 +45,13 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "signatures": [], }, + { + "kind": "function", + "name": "appendApplicationIDQueryParam", + "signatures": [ + "(wsURL: string, applicationID?: string): string", + ], + }, { "kind": "interface", "name": "AppInfoV1", @@ -59,6 +66,11 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "signatures": [], }, + { + "kind": "const", + "name": "APPLICATION_ID_QUERY_PARAM", + "type": "'app_id'", + }, { "kind": "function", "name": "applyAcknowledgementTransition", @@ -1089,6 +1101,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig "kind": "interface", "name": "Config", "properties": [ + "applicationID: string", "blockchainRPCs: Map", "errorHandler: (error: Error) => void", "handshakeTimeout: number", @@ -2439,6 +2452,13 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig ], "signatures": [], }, + { + "kind": "function", + "name": "withApplicationID", + "signatures": [ + "(appID: string): Option", + ], + }, { "kind": "function", "name": "withBlockchainRPC", @@ -2465,6 +2485,7 @@ exports[`SDK public runtime API drift guard keeps root TypeScript public API sig exports[`SDK public runtime API drift guard keeps root runtime exports intentional 1`] = ` [ + "APPLICATION_ID_QUERY_PARAM", "AppSessionKeySignerV1", "AppSessionStatus", "AppSessionWalletSignerV1", @@ -2544,6 +2565,7 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "WebsocketDialer", "appSessionStatusToString", "appStateUpdateIntentToString", + "appendApplicationIDQueryParam", "applyAcknowledgementTransition", "applyChannelCreation", "applyCommitTransition", @@ -2627,6 +2649,7 @@ exports[`SDK public runtime API drift guard keeps root runtime exports intention "unmarshalMessage", "validateDecimalPrecision", "validateLedger", + "withApplicationID", "withBlockchainRPC", "withErrorHandler", "withHandshakeTimeout", diff --git a/sdk/ts/test/unit/abi-drift.test.ts b/sdk/ts/test/unit/abi-drift.test.ts index 84c36b2f5..eb0b18574 100644 --- a/sdk/ts/test/unit/abi-drift.test.ts +++ b/sdk/ts/test/unit/abi-drift.test.ts @@ -37,7 +37,8 @@ function canonicalType(param: AbiParam): string { function signature(entry: AbiEntry): string { const inputs = (entry.inputs ?? []).map(canonicalType).join(','); const outputs = (entry.outputs ?? []).map(canonicalType).join(','); - return `${entry.name}(${inputs}) -> (${outputs}) ${entry.stateMutability ?? ''}`; + const mutability = entry.stateMutability ? ` ${entry.stateMutability}` : ''; + return `${entry.name}(${inputs}) -> (${outputs})${mutability}`; } function functionSignatures(abi: readonly AbiEntry[]): Map { @@ -57,10 +58,18 @@ function functionSignatures(abi: readonly AbiEntry[]): Map { } function loadArtifact(relativePath: string): readonly AbiEntry[] { - const artifact = JSON.parse(fs.readFileSync(path.join(repoRoot, relativePath), 'utf8')); + const artifactPath = path.join(repoRoot, relativePath); + if (!fs.existsSync(artifactPath)) { + throw new Error(`ABI artifact not found: ${relativePath}. Run: cd contracts && forge build`); + } + const artifact = JSON.parse(fs.readFileSync(artifactPath, 'utf8')); return artifact.abi; } +function sortedSignatureEntries(signatures: ReadonlyMap): [string, string][] { + return [...signatures].sort(([left], [right]) => left.localeCompare(right)); +} + function diffConsumedFunctions( contract: string, artifactAbi: readonly AbiEntry[], @@ -107,7 +116,7 @@ describe('contract ABI drift guards', () => { ); const sdkSigs = functionSignatures(ChannelHubAbi as readonly AbiEntry[]); - expect([...sdkSigs]).toEqual([...artifactSigs]); + expect(sortedSignatureEntries(sdkSigs)).toEqual(sortedSignatureEntries(artifactSigs)); }); it('keeps SDK-consumed ChannelHub functions aligned with Foundry artifact', () => { @@ -153,7 +162,7 @@ describe('contract ABI drift guards', () => { expect( diffConsumedFunctions( 'ERC20', - loadArtifact('contracts/out/ERC20.sol/ERC20.json'), + loadArtifact('contracts/out/ERC20.sol/ERC20.default.json'), Erc20Abi as readonly AbiEntry[], consumedFunctions ) diff --git a/sdk/ts/test/unit/public-api-drift.test.ts b/sdk/ts/test/unit/public-api-drift.test.ts index d532d8c3c..2b17f6154 100644 --- a/sdk/ts/test/unit/public-api-drift.test.ts +++ b/sdk/ts/test/unit/public-api-drift.test.ts @@ -63,7 +63,7 @@ function signaturesForType( } function isPrivateOrProtected(declaration: ts.Declaration): boolean { - const flags = ts.getCombinedModifierFlags(declaration as ts.Declaration); + const flags = ts.getCombinedModifierFlags(declaration); return Boolean(flags & (ts.ModifierFlags.Private | ts.ModifierFlags.Protected)); } @@ -187,11 +187,8 @@ describe('SDK public runtime API drift guard', () => { expect(serializePublicApi()).toMatchSnapshot(); }); - it('proves adversarial public export removal is observable', () => { - const exports = new Set(Object.keys(publicApi)); - exports.delete('Client'); - - expect(exports.has('Client')).toBe(false); + it('keeps Client exported', () => { + expect(Object.keys(publicApi)).toContain('Client'); }); it('proves adversarial public signature changes are observable', () => { diff --git a/sdk/ts/test/unit/rpc-drift.test.ts b/sdk/ts/test/unit/rpc-drift.test.ts index 6f8a72a89..d88898c9c 100644 --- a/sdk/ts/test/unit/rpc-drift.test.ts +++ b/sdk/ts/test/unit/rpc-drift.test.ts @@ -126,6 +126,8 @@ describe('TS RPC drift guards', () => { const clientMethods = extractTsClientMethods( fs.readFileSync(path.join(repoRoot, 'sdk/ts/src/client.ts'), 'utf8') ); + // The client extractor depends on class-method indentation. Fail loudly if that parser breaks. + expect(clientMethods.size).toBeGreaterThan(20); const coveredMethods = new Set([ ...publicClientMethodsByRPCMethod.keys(), diff --git a/sdk/ts/test/unit/transform-drift.test.ts b/sdk/ts/test/unit/transform-drift.test.ts index eb2eb5b8b..9dcba41a1 100644 --- a/sdk/ts/test/unit/transform-drift.test.ts +++ b/sdk/ts/test/unit/transform-drift.test.ts @@ -63,7 +63,7 @@ const appSessionKeyStateRaw = { user_sig: '0xdef456', }; -describe('Clearnode response transform drift guards', () => { +describe('Nitronode response transform drift guards', () => { it('maps current get_app_sessions app_definition shape to SDK appDefinition', () => { const session = transformAppSessionInfo(appSessionRaw); @@ -109,6 +109,36 @@ describe('Clearnode response transform drift guards', () => { ).toThrow('Invalid app definition: missing required fields'); }); + it('rejects malformed top-level app session and definition payloads', () => { + expect(() => transformAppSessionInfo(null)).toThrow( + 'Invalid app session: expected object payload' + ); + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + allocations: undefined, + }) + ).toThrow('Invalid app session allocations: expected allocations to be an array'); + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + app_definition: { + ...appSessionRaw.app_definition, + participants: undefined, + }, + }) + ).toThrow('Invalid app definition: expected participants to be an array'); + expect(() => + transformAppSessionInfo({ + ...appSessionRaw, + app_definition: { + ...appSessionRaw.app_definition, + quorum: undefined, + }, + }) + ).toThrow('Invalid app definition: missing required field quorum'); + }); + it('rejects app sessions missing required allocation fields', () => { expect(() => transformAppSessionInfo({ @@ -228,6 +258,10 @@ describe('Clearnode response transform drift guards', () => { 'app session key state[0]' ) ).toThrow('Invalid app session key state[0]: expected application_ids to be string[]'); + + expect(() => transformAppSessionKeyState([], 'app session key state[0]')).toThrow( + 'Invalid app session key state[0]: expected object' + ); }); it('maps high-level client app-session and key-state responses through transform paths', async () => { @@ -284,6 +318,26 @@ describe('Clearnode response transform drift guards', () => { }); }); + it('rejects malformed key-state response containers before mapping', async () => { + const clientLike = { + rpcClient: { + channelsV1GetLastKeyStates: jest.fn(async () => ({ + states: null, + })), + appSessionsV1GetLastKeyStates: jest.fn(async () => ({ + states: {}, + })), + }, + }; + + await expect( + (Client.prototype.getLastChannelKeyStates as any).call(clientLike, userAddress) + ).rejects.toThrow('Invalid channel key states response: expected states to be an array'); + await expect( + (Client.prototype.getLastKeyStates as any).call(clientLike, userAddress) + ).rejects.toThrow('Invalid app key states response: expected states to be an array'); + }); + it('maps high-level client empty app-session responses', async () => { const clientLike = { rpcClient: {