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