diff --git a/sdk/ts-compat/README.md b/sdk/ts-compat/README.md index 365a79bb9..ca2fc28f1 100644 --- a/sdk/ts-compat/README.md +++ b/sdk/ts-compat/README.md @@ -15,7 +15,7 @@ `@yellow-org/sdk-compat` is intentionally narrower than the published v0.5.3 package surface. - **Preserved app-facing APIs**: the `NitroliteClient` facade, selected auth helpers, app-session signing helpers, and many app-facing types remain available for supported migration paths. -- **Transitional helper surfaces**: many legacy `create*Message` / `parse*Response` exports remain so imports can keep compiling during migration, but several are transitional shims rather than one-to-one v1 RPC mappings. +- **Transitional helper surfaces**: some legacy `create*Message` / `parse*Response` exports now emit real v1-compatible payloads inside the legacy `req`/`sig` envelope, while workflow-only helpers stay exported as fail-fast migration shims. - **Unsupported full-package parity**: low-level internals, broad root-export parity, and every legacy helper being runtime-faithful are not promised by this package. ## Why @@ -97,10 +97,10 @@ const assets = await client.getAssetsList(); ### 4. Transfer off-chain -The compat `transfer(destination, allocations)` preserves the v0.5.3-style array-of-allocations signature. Each `TransferAllocation.amount` is a **raw-unit string** (smallest denomination). The compat layer divides by token decimals before delegating to the v1 SDK's `transfer(wallet, asset, Decimal)`: +The compat `transfer(destination, allocations)` preserves the v0.5.3-style array-of-allocations signature. Each `TransferAllocation.amount` is a **raw asset-unit string** using the asset's canonical decimals. The compat layer divides by asset decimals before delegating to the v1 SDK's `transfer(wallet, asset, Decimal)`: ```typescript -// 5 USDC = 5_000_000 raw units (6 decimals) +// 5 USDC = 5_000_000 raw asset units when USDC has 6 asset decimals await client.transfer(recipientAddress, [ { asset: 'usdc', amount: '5000000' }, ]); @@ -147,6 +147,7 @@ await client.close(); | `getEscrowChannel(escrowChannelId)` | Query an escrow channel by ID | | `getChannelData(channelId)` | Full channel + state for a specific channel | | `getLastAppSessionsListError()` | Last `getAppSessionsList()` error message (if any) | +| `getOpenChannels()` | Read current-chain open channel IDs from the ChannelHub | ### App Sessions @@ -157,6 +158,8 @@ await client.close(); | `submitAppState(params)` | Submit state update (operate/deposit/withdraw/close) | | `getAppDefinition(appSessionId)` | Get the definition for a session | +App-session allocation strings remain **human-readable decimal strings** such as `'0.01'`. They are not raw smallest-unit token strings. + ### App Registry | Method | Description | @@ -190,6 +193,8 @@ await client.close(); |---|---| | `transfer(destination, allocations)` | Off-chain transfer to another participant | +`TransferAllocation.amount` remains a **raw asset-unit string** using the asset's canonical decimals, for example `'5000000'` for 5 USDC when USDC has 6 asset decimals. + ### Asset Resolution | Method | Description | @@ -202,6 +207,25 @@ await client.close(); | `parseAmount(tokenAddress, humanAmount)` | Convert human-readable string → raw bigint | | `findOpenChannel(tokenAddress, chainId?)` | Find an open channel for a given token | +### Legacy On-Chain Helpers + +| Method | Description | +|---|---| +| `approveTokens(tokenAddress, amount)` | Approve the current-chain ChannelHub using raw token units | +| `getTokenAllowance(tokenAddress)` | Read current-chain ChannelHub allowance in raw token units | +| `getTokenBalance(tokenAddress)` | Read current-chain wallet token balance in raw token units | + +### Unsupported Legacy Gaps + +These legacy methods remain intentionally unsupported because the v1 runtime no longer offers an honest one-to-one mapping: + +- `createChannel(...)` +- `checkpointChannel(...)` +- `getAccountBalance(...)` +- `getChannelBalance(...)` + +Workflow-style RPC helpers such as `createTransferMessage(...)`, `createCreateChannelMessage(...)`, `createCloseChannelMessage(...)`, and `createResizeChannelMessage(...)` are in the same category: they stay exported for migration, but now fail fast with migration guidance instead of silently approximating old behavior. + ### Security Token Locking | Method | Description | @@ -377,32 +401,40 @@ await client.withdrawSecurityTokens(chainId, destinationWallet); ### Amount conventions -The compat layer accepts raw amounts (smallest token unit) and converts to human-readable `Decimal` before delegating to the v1 SDK. +The compat layer keeps the old amount conventions explicit instead of flattening them: | Method group | Input type | Example: 100 tokens (18 decimals) | |---|---|---| | `deposit`, `withdrawal`, `lockSecurityTokens`, `approveSecurityToken`, `getLockedBalance` | Raw `bigint` | `100_000_000_000_000_000_000n` | -| `transfer` | Raw string via `TransferAllocation.amount` | `'100000000000000000000'` | +| `transfer` | Raw asset-unit string via `TransferAllocation.amount` | `'100000000000000000000'` | +| `createAppSession`, `closeAppSession`, `submitAppState` allocations | Human-readable decimal string | `'100.0'` | > For direct access to the v1 SDK's human-readable `Decimal` API, use `client.innerClient`. ## RPC Stubs The following functions remain exported primarily so legacy `create*Message` / `parse*Response` imports can keep compiling while an app migrates. -Many `create*` helpers are transitional shims rather than protocol-backed one-to-one v1 RPC mappings, and `parse*` helpers only do lightweight normalization of known response shapes. +Some `create*` helpers emit real live-v1 method names and payload shapes inside the legacy `req` / `sig` envelope. Workflow-only helpers fail fast with migration guidance instead of returning fake wire payloads. `parse*` helpers only do lightweight normalization of known response shapes. Prefer `NitroliteClient` methods directly for new integrations: ```typescript -// Transitional compat exports: +// Direct v1-compatible helper mappings: createGetChannelsMessage, parseGetChannelsResponse, createGetLedgerBalancesMessage, parseGetLedgerBalancesResponse, -parseGetLedgerEntriesResponse, parseGetAppSessionsResponse, -createTransferMessage, createAppSessionMessage, parseCreateAppSessionResponse, +parseGetLedgerEntriesResponse, createGetAppSessionsMessage, parseGetAppSessionsResponse, +createGetAppDefinitionMessage, parseGetAppDefinitionResponse, +createAppSessionMessage, parseCreateAppSessionResponse, createCloseAppSessionMessage, parseCloseAppSessionResponse, +createSubmitAppStateMessage, parseSubmitAppStateResponse, +createPingMessage, + +// Migration-only shims that fail fast: +createTransferMessage, createCreateChannelMessage, parseCreateChannelResponse, createCloseChannelMessage, parseCloseChannelResponse, createResizeChannelMessage, parseResizeChannelResponse, -createPingMessage, + +// Generic normalizers: convertRPCToClientChannel, convertRPCToClientState, parseAnyRPCResponse, NitroliteRPC ``` diff --git a/sdk/ts-compat/docs/migration-offchain.md b/sdk/ts-compat/docs/migration-offchain.md index a6432eef8..d476cfee9 100644 --- a/sdk/ts-compat/docs/migration-offchain.md +++ b/sdk/ts-compat/docs/migration-offchain.md @@ -23,16 +23,22 @@ parseCreateAppSessionResponse(raw); ``` **After:** `client.createAppSession(definition, allocations)` +`createAppSessionMessage` now emits a real `app_sessions.v1.create_app_session` payload inside the legacy `req` / `sig` envelope, but new integrations should still prefer `client.createAppSession(...)`. + ### Close **Before:** `createCloseAppSessionMessage` + send + parse **After:** `client.closeAppSession(appSessionId, allocations)` +`createCloseAppSessionMessage` maps to `app_sessions.v1.submit_app_state` with `intent = close`, so it now requires an explicit `version` if you keep the helper path. + ### Submit State **Before:** `createSubmitAppStateMessage` + send **After:** `client.submitAppState(params)` +`createSubmitAppStateMessage` also requires `params.version` for the live v1 mapping. + ### Get Definition **Before:** `createGetAppDefinitionMessage` + send + parse @@ -47,11 +53,15 @@ await sendRequest(msg); ``` **After:** `client.transfer(destination, allocations)` +`createTransferMessage` remains exported only so old imports keep compiling, but it now fails fast with migration guidance because transfer is no longer a single direct v1 RPC helper. This is a deliberate runtime change from the old silent placeholder behavior. + ## 4. Ledger Queries **Before:** `createGetLedgerBalancesMessage` / `createGetLedgerEntriesMessage` + send + parse **After:** `client.getBalances()`, `client.getLedgerEntries()` +`createGetLedgerBalancesMessage` now emits a real `user.v1.get_balances` request and requires the wallet/account parameter. `createGetAppSessionsMessage` and `createGetAppDefinitionMessage` likewise emit live v1 request shapes inside the legacy envelope. + ## 5. Event Polling v0.5.3 used WebSocket push events (`ChannelUpdate`, `BalanceUpdate`). v1 uses polling. The compat layer provides `EventPoller`: @@ -70,4 +80,15 @@ poller.start(); ## 6. RPC Compatibility Helpers -The `create*Message` and `parse*Response` exports still exist primarily so legacy imports can keep compiling while you migrate call sites. Treat them as transitional compatibility shims, not as proof of full one-to-one v1 RPC coverage. For new code, prefer `NitroliteClient` methods directly. Examples: `createGetChannelsMessage`, `parseGetChannelsResponse`, `createTransferMessage`, `createAppSessionMessage`, `createCloseAppSessionMessage`, etc. +The `create*Message` and `parse*Response` exports still exist primarily so legacy imports can keep compiling while you migrate call sites. + +- Direct query/app-session helpers such as `createGetChannelsMessage`, `createGetLedgerBalancesMessage`, `createGetAppSessionsMessage`, `createGetAppDefinitionMessage`, `createAppSessionMessage`, `createSubmitAppStateMessage`, `createCloseAppSessionMessage`, and `createPingMessage` now emit live v1 method names and payload shapes inside the legacy envelope. +- Workflow helpers such as `createTransferMessage` stay exported but fail fast with migration guidance instead of returning fake wire payloads. +- `parse*Response` helpers only normalize known response fields; they do not recreate old payloads that the live v1 server no longer returns. + +For new code, prefer `NitroliteClient` methods directly. + +### Amount conventions + +- `TransferAllocation.amount` remains a raw asset-unit string such as `'5000000'` for 5 USDC when USDC has 6 asset decimals. +- App-session allocation amounts in `createAppSession`, `closeAppSession`, and `submitAppState` remain human-readable decimal strings such as `'0.01'`. diff --git a/sdk/ts-compat/docs/migration-onchain.md b/sdk/ts-compat/docs/migration-onchain.md index 290e52e1b..3b5facfc1 100644 --- a/sdk/ts-compat/docs/migration-onchain.md +++ b/sdk/ts-compat/docs/migration-onchain.md @@ -8,7 +8,6 @@ This guide covers on-chain operations when migrating from v0.5.3 to the compat l ```typescript await approveToken(custody, tokenAddress, amount); -await sendRequest(createDepositMessage(signer.sign, { token: tokenAddress, amount })); await sendRequest(createCreateChannelMessage(signer.sign, { token: tokenAddress, amount })); ``` @@ -18,6 +17,10 @@ await sendRequest(createCreateChannelMessage(signer.sign, { token: tokenAddress, await client.deposit(tokenAddress, amount); ``` +The legacy `createChannel()` client method and `createCreateChannelMessage(...)` helper still exist for migration, but they now throw with migration guidance instead of warning or fabricating a fake wire payload. Use `deposit(...)` or `depositAndCreateChannel(...)` instead. + +`createCreateChannelMessage` remains exported so old imports keep compiling, but it now fails fast with migration guidance because channel creation is no longer a standalone protocol-backed RPC in v1. + ## 2. Withdrawals **Before (v0.5.3):** Manual close → checkpoint → withdraw @@ -26,7 +29,6 @@ await client.deposit(tokenAddress, amount); const closeMsg = await createCloseChannelMessage(signer.sign, { channel_id }); const raw = await sendRequest(closeMsg); // ... checkpoint on-chain ... -await sendRequest(createWithdrawMessage(signer.sign, { token, amount })); ``` **After (compat):** Single call @@ -35,24 +37,21 @@ await sendRequest(createWithdrawMessage(signer.sign, { token, amount })); await client.withdrawal(tokenAddress, amount); ``` +`createCloseChannelMessage` now fails fast with migration guidance instead of pretending to be a real v1 wire helper. + ## 3. Channel Operations Legacy channel helper imports may still exist to keep migration moving, but the supported path is the compat client methods below. Do not treat every legacy helper name as a protocol-backed one-to-one v1 RPC. | Operation | v0.5.3 | Compat | |-----------|--------|--------| -| Create | Explicit `createChannel()` | Implicit on first `deposit()` | -| Close | `createCloseChannelMessage` + send + parse | `client.closeChannel()` | -| Resize | `createResizeChannelMessage` + send + parse | `client.resizeChannel({ allocate_amount, token })` | +| Create | Explicit `createChannel()` (now throwing migration shim) | Implicit on first `deposit()` | +| Close | `createCloseChannelMessage` (now fail-fast shim) | `client.closeChannel()` | +| Resize | `createResizeChannelMessage` (now fail-fast shim) | `client.resizeChannel({ allocate_amount, token })` | **Example — close:** ```typescript -// Before -const msg = await createCloseChannelMessage(signer.sign, { channel_id }); -const raw = await sendRequest(msg); -const parsed = parseCloseChannelResponse(raw); - // After await client.closeChannel(); ``` @@ -66,7 +65,7 @@ const amount = 11_000_000n; // 11 USDC (6 decimals) // Manual conversion for display: formatUnits(amount, 6) ``` -**After (compat):** App-facing methods still accept raw token amounts, and the compat layer handles the conversion needed to call the v1 SDK correctly +**After (compat):** On-chain app-facing methods still accept raw token amounts, and the compat layer handles the conversion needed to call the v1 SDK correctly ```typescript // Raw BigInt still works @@ -77,7 +76,9 @@ const formatted = client.formatAmount(tokenAddress, 11_000_000n); // "11.0" const parsed = client.parseAmount(tokenAddress, "11.0"); // 11_000_000n ``` -For transfers and allocations, compat accepts human-readable strings: `{ asset: 'usdc', amount: '5.0' }`. +Transfer allocations still use raw asset-unit strings, for example `{ asset: 'usdc', amount: '5000000' }` for 5 USDC when USDC has 6 asset decimals. + +App-session allocations are different: those remain human-readable decimal strings such as `{ asset: 'usdc', amount: '5.0' }`. ## 5. Contract Addresses diff --git a/sdk/ts-compat/docs/migration-overview.md b/sdk/ts-compat/docs/migration-overview.md index cac845d18..930688aaf 100644 --- a/sdk/ts-compat/docs/migration-overview.md +++ b/sdk/ts-compat/docs/migration-overview.md @@ -21,7 +21,7 @@ npm install @yellow-org/sdk viem | `import { createGetChannelsMessage, parseGetChannelsResponse } from '@layer-3/nitrolite'` | `import { NitroliteClient } from '@yellow-org/sdk-compat'` | | Types: `AppSession`, `LedgerChannel`, `RPCAppDefinition` | Many app-facing types remain re-exported from `@yellow-org/sdk-compat` | -For **types**, many app-facing imports only need a package-name swap. For **functions**, prefer client methods instead of `create*Message` / `parse*Response`. Some legacy helper imports remain exported only as transitional migration shims. +For **types**, many app-facing imports only need a package-name swap. For **functions**, prefer client methods instead of `create*Message` / `parse*Response`. Some legacy helper imports remain exported as direct v1-compatible request builders; workflow-style helpers stay exported as fail-fast migration shims so imports keep compiling while call sites move. ## 4. The Key Pattern Change @@ -53,7 +53,7 @@ const channels = await client.getChannels(); |---------|--------|--------| | WebSocket | App creates and manages `WebSocket` | Managed internally by the client | | Signing | App passes `signer.sign` into every message | Internal — client uses `WalletClient` | -| Amounts | Raw `BigInt` everywhere | Compat keeps raw-unit app-facing inputs and handles the v1 bridge internally | +| Amounts | Raw `BigInt` everywhere | Compat keeps legacy amount conventions explicit: transfers stay raw asset-unit strings, app-session allocations stay human-decimal strings | | Contract addresses | Manual config | Fetched from nitronode `get_config` | | Channel creation | Explicit `createChannel()` | Implicit on first `deposit()` | @@ -79,7 +79,7 @@ const balances = await client.getBalances(); const sessions = await client.getAppSessionsList(); // Transfer -await client.transfer(recipientAddress, [{ asset: 'usdc', amount: '5.0' }]); +await client.transfer(recipientAddress, [{ asset: 'usdc', amount: '5000000' }]); // Cleanup await client.closeChannel(); diff --git a/sdk/ts-compat/src/client.ts b/sdk/ts-compat/src/client.ts index 22278d9c0..f4ba1cfc5 100644 --- a/sdk/ts-compat/src/client.ts +++ b/sdk/ts-compat/src/client.ts @@ -15,7 +15,16 @@ import type { } from '@yellow-org/sdk'; import type * as core from '@yellow-org/sdk'; import { Decimal } from 'decimal.js'; -import { Address, Hex, WalletClient, createPublicClient, http, formatUnits, parseUnits } from 'viem'; +import { + Address, + Hex, + PublicClient, + WalletClient, + createPublicClient, + formatUnits, + http, + parseUnits, +} from 'viem'; import type { RPCBalance, @@ -101,10 +110,21 @@ class WalletTxSigner implements TransactionSigner { interface AssetInfo { symbol: string; chainId: bigint; - decimals: number; + assetDecimals: number; + tokenDecimals: number; tokenAddress: string; } +const ChannelHubCompatAbi = [ + { + type: 'function', + stateMutability: 'view', + name: 'getOpenChannels', + inputs: [{ name: 'user', type: 'address' }], + outputs: [{ name: '', type: 'bytes32[]' }], + }, +] as const; + // --------------------------------------------------------------------------- // NitroliteClient compat facade // --------------------------------------------------------------------------- @@ -127,10 +147,12 @@ export interface NitroliteClientConfig { } export class NitroliteClient { - /** The underlying v1 SDK Client -- use for any functionality not wrapped by compat. */ + /** The underlying v1.0.0 SDK Client -- use for any functionality not wrapped by compat. */ readonly innerClient: Client; readonly userAddress: Address; + private readonly walletClient: WalletClient; + private assetsByChainAndToken = new Map(); // `${chainId}:${tokenAddr}` -> info private assetsByToken = new Map(); // lowercase tokenAddr -> info private assetsBySymbol = new Map(); // lowercase symbol -> info private _chainId: bigint; @@ -140,10 +162,18 @@ export class NitroliteClient { private _blockchains: core.Blockchain[] | null = null; private _lockingTokenDecimals = new Map(); private _blockchainRPCs: Record; + private _publicClients = new Map(); - private constructor(client: Client, userAddress: Address, chainId: number, blockchainRPCs?: Record) { + private constructor( + client: Client, + userAddress: Address, + chainId: number, + walletClient: WalletClient, + blockchainRPCs?: Record, + ) { this.innerClient = client; this.userAddress = userAddress; + this.walletClient = walletClient; this._chainId = BigInt(chainId); this._blockchainRPCs = blockchainRPCs ?? {}; } @@ -184,7 +214,13 @@ export class NitroliteClient { const v1Client = await Client.create(config.wsURL, stateSigner, txSigner, ...opts); - const compat = new NitroliteClient(v1Client, walletAddress, config.chainId, config.blockchainRPCs); + const compat = new NitroliteClient( + v1Client, + walletAddress, + config.chainId, + config.walletClient, + config.blockchainRPCs, + ); try { await compat.refreshAssets(); @@ -199,8 +235,13 @@ export class NitroliteClient { // Asset mapping // ----------------------------------------------------------------------- + private assetTokenKey(chainId: bigint, tokenAddress: string): string { + return `${chainId}:${tokenAddress.toLowerCase()}`; + } + async refreshAssets(): Promise { const assets = await this.innerClient.getAssets(); + this.assetsByChainAndToken.clear(); this.assetsByToken.clear(); this.assetsBySymbol.clear(); @@ -209,11 +250,13 @@ export class NitroliteClient { const info: AssetInfo = { symbol: asset.symbol, chainId: token.blockchainId, - decimals: asset.decimals, + assetDecimals: asset.decimals, + tokenDecimals: token.decimals, tokenAddress: token.address.toLowerCase(), }; - this.assetsByToken.set(info.tokenAddress, info); + this.assetsByChainAndToken.set(this.assetTokenKey(info.chainId, info.tokenAddress), info); if (token.blockchainId === this._chainId) { + this.assetsByToken.set(info.tokenAddress, info); this.assetsBySymbol.set(asset.symbol.toLowerCase(), info); } } @@ -224,13 +267,49 @@ export class NitroliteClient { if (this.assetsByToken.size === 0) await this.refreshAssets(); } - private async getDecimalsForAsset(assetSymbol: string): Promise { + private async getTokenDecimalsForAsset(assetSymbol: string): Promise { + await this.ensureAssets(); + const info = this.assetsBySymbol.get(assetSymbol.toLowerCase()); + if (!info) { + console.warn(`[compat] Unknown asset symbol ${assetSymbol}, falling back to 6 token decimals`); + } + return info?.tokenDecimals ?? 6; + } + + private async getAssetDecimalsForAsset(assetSymbol: string): Promise { await this.ensureAssets(); const info = this.assetsBySymbol.get(assetSymbol.toLowerCase()); if (!info) { - console.warn(`[compat] Unknown asset symbol ${assetSymbol}, falling back to 6 decimals`); + console.warn(`[compat] Unknown asset symbol ${assetSymbol}, falling back to 6 asset decimals`); } - return info?.decimals ?? 6; + return info?.assetDecimals ?? 6; + } + + private async getTokenInfoForChain(chainId: bigint, tokenAddress: string): Promise { + await this.ensureAssets(); + return this.assetsByChainAndToken.get(this.assetTokenKey(chainId, tokenAddress)) ?? null; + } + + private async getTokenDecimalsForChannel( + assetSymbol: string, + chainId: bigint, + tokenAddress?: string, + ): Promise { + if (tokenAddress) { + const info = await this.getTokenInfoForChain(chainId, tokenAddress.toLowerCase()); + if (info) { + return info.tokenDecimals; + } + } + + if (chainId === this._chainId) { + return this.getTokenDecimalsForAsset(assetSymbol); + } + + const assets = await this.innerClient.getAssets(); + const asset = assets.find((candidate) => candidate.symbol.toLowerCase() === assetSymbol.toLowerCase()); + const token = asset?.tokens.find((candidate) => candidate.blockchainId === chainId); + return token?.decimals ?? asset?.decimals ?? 6; } async resolveToken(tokenAddress: Address | string): Promise { @@ -259,7 +338,7 @@ export class NitroliteClient { if (!info) { console.warn(`[compat] Unknown token ${tokenAddress}, falling back to 6 decimals`); } - return info?.decimals ?? 6; + return info?.tokenDecimals ?? 6; } async formatAmount(tokenAddress: Address | string, rawAmount: bigint): Promise { @@ -275,9 +354,11 @@ export class NitroliteClient { async resolveAssetDisplay(tokenAddress: Address | string, _chainId?: number): Promise<{ symbol: string; decimals: number } | null> { await this.ensureAssets(); const key = tokenAddress.toString().toLowerCase(); - const info = this.assetsByToken.get(key); + const info = _chainId !== undefined + ? await this.getTokenInfoForChain(BigInt(_chainId), key) + : this.assetsByToken.get(key) ?? null; if (!info) return null; - return { symbol: info.symbol, decimals: info.decimals }; + return { symbol: info.symbol, decimals: info.tokenDecimals }; } findOpenChannel(tokenAddress: Address | string, chainId?: number): LedgerChannel | null { @@ -298,11 +379,55 @@ export class NitroliteClient { }; } + private getRPCUrl(chainId: number): string { + const configured = this._blockchainRPCs[chainId]; + if (configured) { + return configured; + } + + const walletChain = this.walletClient.chain; + if (walletChain && Number(walletChain.id) === chainId) { + const walletRpcUrl = walletChain.rpcUrls.public?.http?.[0] + ?? walletChain.rpcUrls.default?.http?.[0]; + if (walletRpcUrl) { + return walletRpcUrl; + } + } + + throw new Error( + `[compat] No RPC URL configured for chain ${chainId}. Pass blockchainRPCs when creating NitroliteClient.`, + ); + } + + private getReadClientForChain(chainId: bigint): PublicClient { + const key = Number(chainId); + const cached = this._publicClients.get(key); + if (cached) { + return cached; + } + + const walletChain = this.walletClient.chain; + const client = createPublicClient({ + ...(walletChain && Number(walletChain.id) === key ? { chain: walletChain } : {}), + transport: http(this.getRPCUrl(key)), + }); + this._publicClients.set(key, client); + return client; + } + + private async getBlockchainById(chainId: bigint): Promise { + const blockchains = await this.ensureBlockchains(); + const blockchain = blockchains.find((candidate) => candidate.id === chainId); + if (!blockchain) { + throw new Error(`Unknown blockchain ${chainId.toString()}`); + } + return blockchain; + } + // ----------------------------------------------------------------------- // On-chain operations (v0.5.3 compat surface) // ----------------------------------------------------------------------- - private static readonly MAX_UINT256 = 2n ** 256n - 1n; private static readonly DEFAULT_APPROVE_AMOUNT = new Decimal(100000); /** Classify raw SDK/wallet errors into typed compat errors. */ @@ -334,9 +459,9 @@ export class NitroliteClient { } async deposit(tokenAddress: Address, amount: bigint): Promise { - const { symbol, chainId, decimals, tokenAddress: resolvedAddr } = await this.resolveToken(tokenAddress); + const { symbol, chainId, tokenDecimals, tokenAddress: resolvedAddr } = await this.resolveToken(tokenAddress); await this.innerClient.setHomeBlockchain(symbol, chainId).catch(() => {}); - const humanAmount = this.toHumanAmount(amount, decimals); + const humanAmount = this.toHumanAmount(amount, tokenDecimals); await this.innerClient.deposit(chainId, symbol, humanAmount); return await this.checkpointWithApproval(symbol, chainId, resolvedAddr); } @@ -345,8 +470,10 @@ export class NitroliteClient { return this.deposit(tokenAddress, amount); } - async createChannel(_respParams?: any): Promise { - console.warn('[compat] createChannel is implicit in v1 -- use deposit() instead'); + async createChannel(_respParams?: any): Promise { + throw new Error( + '[compat] createChannel is not supported as a standalone v1 flow. Use deposit(tokenAddress, amount) or depositAndCreateChannel(...) instead.', + ); } async closeChannel(params?: { tokenAddress?: Address | string } | any): Promise { @@ -367,8 +494,7 @@ export class NitroliteClient { for (const ch of openChannels) { try { - await this.ensureAssets(); - const info = this.assetsByToken.get(ch.token.toLowerCase()); + const info = await this.getTokenInfoForChain(BigInt(ch.chain_id), ch.token.toLowerCase()); const symbol = info?.symbol; if (!symbol) continue; @@ -389,9 +515,9 @@ export class NitroliteClient { } async withdrawal(tokenAddress: Address, amount: bigint): Promise { - const { symbol, chainId, decimals, tokenAddress: resolvedAddr } = await this.resolveToken(tokenAddress); + const { symbol, chainId, tokenDecimals, tokenAddress: resolvedAddr } = await this.resolveToken(tokenAddress); await this.innerClient.setHomeBlockchain(symbol, chainId).catch(() => {}); - const humanAmount = this.toHumanAmount(amount, decimals); + const humanAmount = this.toHumanAmount(amount, tokenDecimals); await this.innerClient.withdraw(chainId, symbol, humanAmount); return await this.checkpointWithApproval(symbol, chainId, resolvedAddr); } @@ -414,6 +540,55 @@ export class NitroliteClient { throw new Error(`Channel ${_channelId} not found`); } + async checkpointChannel(_params: unknown): Promise { + throw new Error( + '[compat] checkpointChannel is not supported as a direct v0.5.3 parity method. Use asset-driven v1 flows such as deposit(), withdrawal(), closeChannel(), or client.innerClient.checkpoint(asset).', + ); + } + + async getOpenChannels(): Promise { + const blockchain = await this.getBlockchainById(this._chainId); + const client = this.getReadClientForChain(this._chainId); + const channelIds = await client.readContract({ + address: blockchain.channelHubAddress, + abi: ChannelHubCompatAbi, + functionName: 'getOpenChannels', + args: [this.userAddress], + }); + + return [...channelIds]; + } + + async getAccountBalance(_tokenAddress: Address | Address[]): Promise { + throw new Error( + '[compat] getAccountBalance is not supported as a direct v0.5.3 parity method. Use getBalances() for nitronode ledger balances or getTokenBalance(tokenAddress) for on-chain wallet balances.', + ); + } + + async getChannelBalance(_channelId: string, _tokenAddress: Address | Address[]): Promise { + throw new Error( + '[compat] getChannelBalance is not supported as a direct v0.5.3 parity method. Use getChannelData(channelId) and inspect the returned state balances instead.', + ); + } + + async approveTokens(tokenAddress: Address, amount: bigint): Promise { + const { symbol, chainId, tokenDecimals } = await this.resolveToken(tokenAddress); + const humanAmount = this.toHumanAmount(amount, tokenDecimals); + return this.innerClient.approveToken(chainId, symbol, humanAmount); + } + + async getTokenAllowance(tokenAddress: Address): Promise { + const info = await this.resolveToken(tokenAddress); + return this.innerClient.checkTokenAllowance(info.chainId, info.tokenAddress, this.userAddress); + } + + async getTokenBalance(tokenAddress: Address): Promise { + const info = await this.resolveToken(tokenAddress); + const balance = await this.innerClient.getOnChainBalance(info.chainId, info.symbol, this.userAddress); + const normalized = balance.toDecimalPlaces(info.tokenDecimals, Decimal.ROUND_DOWN); + return parseUnits(normalized.toString(), info.tokenDecimals); + } + // ----------------------------------------------------------------------- // Off-chain queries (for hooks to call directly) // ----------------------------------------------------------------------- @@ -447,8 +622,11 @@ export class NitroliteClient { const raw = state.homeLedger?.userBalance; if (raw) { - const info = this.assetsBySymbol.get(ch.asset.toLowerCase()); - const dec = info?.decimals ?? 6; + const dec = await this.getTokenDecimalsForChannel( + ch.asset, + ch.blockchainId ?? 0n, + ch.tokenAddress ?? undefined, + ); userBalance = BigInt(raw.mul(new Decimal(10).pow(dec)).toFixed(0)); } } catch { @@ -491,8 +669,11 @@ export class NitroliteClient { const raw = state.homeLedger?.userBalance; if (raw) { - const info = this.assetsBySymbol.get(asset.symbol.toLowerCase()); - const dec = info?.decimals ?? asset.decimals; + const dec = await this.getTokenDecimalsForChannel( + asset.symbol, + ch.blockchainId ?? asset.tokens?.[0]?.blockchainId ?? 0n, + ch.tokenAddress || asset.tokens?.[0]?.address, + ); userBalance = BigInt(raw.mul(new Decimal(10).pow(dec)).toFixed(0)); } } catch { @@ -523,17 +704,31 @@ export class NitroliteClient { return channels; } + /** + * Returns nitronode ledger balances encoded with asset-level decimals. + * + * @remarks + * The raw string amounts returned here are not guaranteed to match the raw + * token-unit bigint values expected by on-chain helpers such as deposit(), + * withdrawal(), approveTokens(), getTokenBalance(), or getTokenAllowance(). + * When asset decimals and chain-specific token decimals differ, convert via + * the token helpers instead of passing BigInt(balance.amount) through + * directly to an on-chain method. + */ async getBalances(wallet?: Address): Promise { const balances = await this.innerClient.getBalances(wallet ?? this.userAddress); - return balances.map((b) => { - const info = this.assetsBySymbol.get(b.asset.toLowerCase()); - const dec = info?.decimals ?? 6; - const rawAmount = b.balance.mul(new Decimal(10).pow(dec)).toFixed(0); - return { - asset: b.asset, + const result: LedgerBalance[] = []; + + for (const balance of balances) { + const decimals = await this.getAssetDecimalsForAsset(balance.asset); + const rawAmount = balance.balance.mul(new Decimal(10).pow(decimals)).toFixed(0); + result.push({ + asset: balance.asset, amount: rawAmount, - }; - }); + }); + } + + return result; } async getLedgerEntries(wallet?: Address): Promise { @@ -569,15 +764,10 @@ export class NitroliteClient { version: Number(s.version ?? 0), weights: participants.map((p: any) => p.signatureWeight), allocations: s.allocations?.map((a: any) => { - const info = this.assetsBySymbol.get(a.asset?.toLowerCase?.() ?? ''); - const dec = info?.decimals ?? 6; - const rawAmount = a.amount - ? a.amount.mul(new Decimal(10).pow(dec)).toFixed(0) - : '0'; return { participant: a.participant as Address, asset: a.asset, - amount: rawAmount, + amount: a.amount?.toString?.() ?? '0', }; }) ?? [], sessionData: s.sessionData ?? '', @@ -650,7 +840,7 @@ export class NitroliteClient { token: token.address as Address, chainId: Number(token.blockchainId), symbol: asset.symbol, - decimals: asset.decimals, + decimals: token.decimals, }); } } @@ -756,12 +946,10 @@ export class NitroliteClient { const session = sessions[0]; const v1Allocations: AppAllocationV1[] = []; for (const a of closeAllocations) { - const decimals = await this.getDecimalsForAsset(a.asset); - const humanAmount = new Decimal(a.amount).div(new Decimal(10).pow(decimals)); v1Allocations.push({ participant: a.participant as Address, asset: a.asset, - amount: humanAmount, + amount: new Decimal(a.amount), }); } @@ -820,12 +1008,10 @@ export class NitroliteClient { const v1Allocations: AppAllocationV1[] = []; for (const a of params.allocations) { - const decimals = await this.getDecimalsForAsset(a.asset); - const humanAmount = new Decimal(a.amount).div(new Decimal(10).pow(decimals)); v1Allocations.push({ participant: a.participant as Address, asset: a.asset, - amount: humanAmount, + amount: new Decimal(a.amount), }); } @@ -939,7 +1125,7 @@ export class NitroliteClient { /** Transfers are executed sequentially per allocation and are not atomic; a mid-loop failure leaves prior transfers committed. */ async transfer(destination: Address, allocations: TransferAllocation[]): Promise { for (const alloc of allocations) { - const decimals = await this.getDecimalsForAsset(alloc.asset); + const decimals = await this.getAssetDecimalsForAsset(alloc.asset); const humanAmount = new Decimal(alloc.amount).div(new Decimal(10).pow(decimals)); await this.innerClient.transfer(destination, alloc.asset, humanAmount); } diff --git a/sdk/ts-compat/src/rpc.ts b/sdk/ts-compat/src/rpc.ts index 89374a91f..fa12d0606 100644 --- a/sdk/ts-compat/src/rpc.ts +++ b/sdk/ts-compat/src/rpc.ts @@ -1,35 +1,210 @@ /** * RPC compatibility helpers for v0.5.3 imports. * - * In v0.5.3, hooks used a create-sign-send-parse pattern: + * In v0.5.3, apps used a create-sign-send-parse pattern: * const msg = await createGetChannelsMessage(signer.sign, addr); * const raw = await sendRequest(msg); * const parsed = parseGetChannelsResponse(raw); * - * In the compat layer, most apps should call NitroliteClient methods directly. - * The helpers below keep legacy import sites compiling; most create* helpers are - * lightweight placeholders while parse* helpers normalize response shapes. + * The compat layer keeps that import surface available. Helpers that map + * directly onto a live v1 method emit real v1-compatible payloads inside the + * legacy req/sig envelope. Helpers without an honest one-to-one mapping fail + * fast with migration guidance instead of fabricating fake wire payloads. */ -import type { MessageSigner, RPCResponse, NitroliteRPCMessage } from './types.js'; +import { Decimal } from 'decimal.js'; +import type { Address } from 'viem'; + +import { + RPCAppStateIntent, + type CloseAppSessionRequestParams, + type CreateAppSessionRequestParams, + type MessageSigner, + type NitroliteRPCMessage, + type RPCChannelStatus, + type RPCResponse, + type RPCAppDefinition, + type RPCAppSessionAllocation, + type SubmitAppStateRequestParams, + type SubmitAppStateRequestParamsV04, +} from './types.js'; // --------------------------------------------------------------------------- -// parseAnyRPCResponse -- pass-through +// parseAnyRPCResponse -- response normalization // --------------------------------------------------------------------------- -export function parseAnyRPCResponse(raw: string): RPCResponse { - try { - const data = JSON.parse(raw); - if (Array.isArray(data)) { - return { requestId: data[1] ?? 0, method: data[2] ?? '', params: data[3] ?? {} }; - } - if (data.res) { - return { requestId: data.res[0] ?? 0, method: data.res[1] ?? '', params: data.res[2] ?? {} }; +type LegacyRPCEnvelope = { + req?: [number, string, unknown, number]; + res?: [number, string, unknown, number?]; + sig?: string; +}; + +function legacyJSONReplacer(key: string, value: unknown): unknown { + if (typeof value === 'bigint') { + return value.toString(); + } + + if ( + (key === 'blockchain_id' || key === 'epoch' || key === 'version' || key === 'nonce') && + (typeof value === 'number' || typeof value === 'bigint') + ) { + return value.toString(); + } + + return value; +} + +function parseEnvelope(raw: string): unknown { + return JSON.parse(raw); +} + +function extractResponsePayload(raw: string): { requestId: number; method: string; payload: any } { + const data = parseEnvelope(raw); + + if (Array.isArray(data)) { + return { + requestId: data[0] ?? 0, + method: data[1] ?? '', + payload: data[2] ?? {}, + }; + } + + if (typeof data === 'object' && data !== null && 'res' in data && Array.isArray((data as LegacyRPCEnvelope).res)) { + const response = (data as LegacyRPCEnvelope).res!; + return { + requestId: response[0] ?? 0, + method: response[1] ?? '', + payload: response[2] ?? {}, + }; + } + + return { requestId: 0, method: '', payload: data }; +} + +function normalizePayloadField(payload: any, ...keys: string[]): T | undefined { + for (const key of keys) { + if (payload && Object.prototype.hasOwnProperty.call(payload, key)) { + return payload[key] as T; } - return { requestId: 0, method: '', params: data }; - } catch { - return { requestId: 0, method: 'error', params: { error: 'parse failed' } }; } + return undefined; +} + +function defaultRequestId(): number { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); +} + +function serializeMessage(message: NitroliteRPCMessage): string { + return JSON.stringify(message, legacyJSONReplacer); +} + +function newUnsignedMessage(method: string, params: Record, requestId = defaultRequestId(), timestamp = Date.now()): string { + return serializeMessage( + NitroliteRPC.createRequest({ + requestId, + method, + params, + timestamp, + }), + ); +} + +async function newSignedMessage( + signer: MessageSigner, + method: string, + params: Record, + requestId = defaultRequestId(), + timestamp = Date.now(), +): Promise { + const request = NitroliteRPC.createRequest({ + requestId, + method, + params, + timestamp, + }); + const signed = await NitroliteRPC.signRequestMessage(request, signer); + return serializeMessage(signed); +} + +function missingFieldError(helperName: string, fieldName: string, guidance: string): Error { + return new Error( + `[compat] ${helperName} requires ${fieldName} for the live v1 mapping. ${guidance}`, + ); +} + +function unsupportedHelperError(helperName: string, guidance: string): Error { + return new Error(`[compat] ${helperName} is not supported as a direct v1 RPC helper. ${guidance}`); +} + +function toRPCAppDefinition(definition: RPCAppDefinition): Record { + return { + application_id: definition.application, + participants: definition.participants.map((walletAddress, index) => ({ + wallet_address: walletAddress, + signature_weight: definition.weights[index] ?? 1, + })), + quorum: definition.quorum, + nonce: BigInt(definition.nonce ?? Date.now()), + }; +} + +function toRPCAppAllocations(allocations: RPCAppSessionAllocation[]): Array> { + return allocations.map((allocation) => ({ + participant: allocation.participant, + asset: allocation.asset, + amount: new Decimal(allocation.amount), + })); +} + +function toRPCAppStateIntent(intent: RPCAppStateIntent): number { + switch (intent) { + case RPCAppStateIntent.Deposit: + return 1; + case RPCAppStateIntent.Withdraw: + return 2; + case RPCAppStateIntent.Close: + return 3; + case RPCAppStateIntent.Operate: + default: + return 0; + } +} + +function requireSubmitVersion( + helperName: 'createSubmitAppStateMessage' | 'createCloseAppSessionMessage', + version: number | undefined, +): number { + if (version === undefined) { + throw missingFieldError( + helperName, + 'params.version', + 'Use NitroliteClient.submitAppState(...) / closeAppSession(...) or include the current app-session version explicitly.', + ); + } + return version; +} + +function buildSubmitAppStateParams( + params: SubmitAppStateRequestParams, + intentOverride?: RPCAppStateIntent, + quorumSigsOverride?: string[], +): Record { + const intent = intentOverride ?? ('intent' in params ? params.intent : RPCAppStateIntent.Operate); + const version = requireSubmitVersion( + intentOverride === RPCAppStateIntent.Close ? 'createCloseAppSessionMessage' : 'createSubmitAppStateMessage', + 'version' in params ? params.version : undefined, + ); + + return { + app_state_update: { + app_session_id: params.app_session_id, + intent: toRPCAppStateIntent(intent), + version, + allocations: toRPCAppAllocations(params.allocations), + session_data: params.session_data ?? '', + }, + quorum_sigs: quorumSigsOverride ?? params.quorum_sigs ?? [], + }; } // --------------------------------------------------------------------------- @@ -54,77 +229,296 @@ export const NitroliteRPC = { // create*Message / parse*Response compatibility helpers // --------------------------------------------------------------------------- -const noop = async (..._args: any[]): Promise => - JSON.stringify({ req: [0, 'noop', {}, Date.now()], sig: '0x' }); +export function parseAnyRPCResponse(raw: string): RPCResponse { + try { + const { requestId, method, payload } = extractResponsePayload(raw); + return { requestId, method, params: payload }; + } catch { + return { requestId: 0, method: 'error', params: { error: 'parse failed' } }; + } +} + +export async function createGetChannelsMessage( + _signer: MessageSigner, + participant?: Address, + status?: RPCChannelStatus, + requestId?: number, + timestamp?: number, +): Promise { + if (!participant) { + throw missingFieldError( + 'createGetChannelsMessage', + 'participant', + 'Pass the participant wallet or migrate to NitroliteClient.getChannels().', + ); + } + + return newUnsignedMessage( + 'channels.v1.get_channels', + { + wallet: participant, + ...(status ? { status } : {}), + }, + requestId, + timestamp, + ); +} -export const createGetChannelsMessage = noop; export const parseGetChannelsResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { channels: d?.res?.[2]?.channels ?? [] } }; + const { payload } = extractResponsePayload(raw); + return { params: { channels: normalizePayloadField(payload, 'channels') ?? [] } }; }; -export const createGetLedgerBalancesMessage = noop; +export async function createGetLedgerBalancesMessage( + signer: MessageSigner, + accountId?: string, + requestId?: number, + timestamp?: number, +): Promise { + if (!accountId) { + throw missingFieldError( + 'createGetLedgerBalancesMessage', + 'accountId', + 'Pass the wallet/account address or migrate to NitroliteClient.getBalances().', + ); + } + + return newSignedMessage( + signer, + 'user.v1.get_balances', + { wallet: accountId }, + requestId, + timestamp, + ); +} + export const parseGetLedgerBalancesResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { ledgerBalances: d?.res?.[2]?.ledgerBalances ?? [] } }; + const { payload } = extractResponsePayload(raw); + return { + params: { + ledgerBalances: normalizePayloadField(payload, 'ledgerBalances', 'balances') ?? [], + }, + }; }; export const parseGetLedgerEntriesResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { ledgerEntries: d?.res?.[2]?.ledgerEntries ?? [] } }; + const { payload } = extractResponsePayload(raw); + return { + params: { + ledgerEntries: normalizePayloadField(payload, 'ledgerEntries', 'transactions') ?? [], + }, + }; }; export const parseGetAppSessionsResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { appSessions: d?.res?.[2]?.appSessions ?? [] } }; + const { payload } = extractResponsePayload(raw); + return { + params: { + appSessions: normalizePayloadField(payload, 'appSessions', 'app_sessions') ?? [], + }, + }; }; -export const createTransferMessage = noop; -export const createAppSessionMessage = noop; +export async function createTransferMessage( + _signer: MessageSigner, + _params: unknown, + _requestId?: number, + _timestamp?: number, +): Promise { + throw unsupportedHelperError( + 'createTransferMessage', + 'Use NitroliteClient.transfer(destination, allocations) instead.', + ); +} + +export async function createAppSessionMessage( + signer: MessageSigner, + params: CreateAppSessionRequestParams, + requestId?: number, + timestamp?: number, +): Promise { + return newSignedMessage( + signer, + 'app_sessions.v1.create_app_session', + { + definition: toRPCAppDefinition(params.definition), + session_data: params.session_data ?? JSON.stringify({ allocations: params.allocations }), + quorum_sigs: params.quorum_sigs ?? [], + ...(params.owner_sig ? { owner_sig: params.owner_sig } : {}), + }, + requestId, + timestamp, + ); +} + export const parseCreateAppSessionResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { appSessionId: d?.res?.[2]?.appSessionId ?? '' } }; + const { payload } = extractResponsePayload(raw); + return { + params: { + appSessionId: normalizePayloadField(payload, 'appSessionId', 'app_session_id') ?? '', + version: normalizePayloadField(payload, 'version') ?? '', + status: normalizePayloadField(payload, 'status') ?? '', + }, + }; }; -export const createCloseAppSessionMessage = noop; +export async function createCloseAppSessionMessage( + signer: MessageSigner, + params: CloseAppSessionRequestParams, + requestId?: number, + timestamp?: number, +): Promise { + const version = requireSubmitVersion('createCloseAppSessionMessage', params.version); + + return newSignedMessage( + signer, + 'app_sessions.v1.submit_app_state', + buildSubmitAppStateParams( + { + app_session_id: params.app_session_id, + intent: RPCAppStateIntent.Close, + version, + allocations: params.allocations, + session_data: params.session_data, + quorum_sigs: params.quorum_sigs, + } as SubmitAppStateRequestParamsV04, + RPCAppStateIntent.Close, + params.quorum_sigs, + ), + requestId, + timestamp, + ); +} + export const parseCloseAppSessionResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { appSessionId: d?.res?.[2]?.appSessionId ?? '' } }; + const { payload } = extractResponsePayload(raw); + return { params: payload ?? {} }; }; -export const createSubmitAppStateMessage = noop; +export async function createSubmitAppStateMessage( + signer: MessageSigner, + params: SubmitAppStateRequestParams, + requestId?: number, + timestamp?: number, +): Promise { + return newSignedMessage( + signer, + 'app_sessions.v1.submit_app_state', + buildSubmitAppStateParams(params), + requestId, + timestamp, + ); +} + export const parseSubmitAppStateResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: { appSessionId: d?.res?.[2]?.appSessionId ?? '', version: d?.res?.[2]?.version ?? 0, status: d?.res?.[2]?.status ?? '' } }; + const { payload } = extractResponsePayload(raw); + return { params: payload ?? {} }; }; -export const createGetAppDefinitionMessage = noop; +export async function createGetAppDefinitionMessage( + _signer: MessageSigner, + appSessionId: string, + requestId?: number, + timestamp?: number, +): Promise { + return newUnsignedMessage( + 'app_sessions.v1.get_app_definition', + { app_session_id: appSessionId }, + requestId, + timestamp, + ); +} + export const parseGetAppDefinitionResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: d?.res?.[2] ?? {} }; + const { payload } = extractResponsePayload(raw); + return { params: normalizePayloadField>(payload, 'definition') ?? {} }; }; -export const createGetAppSessionsMessage = noop; +export async function createGetAppSessionsMessage( + _signer: MessageSigner, + participant: Address, + status?: RPCChannelStatus, + requestId?: number, + timestamp?: number, +): Promise { + if (!participant) { + throw missingFieldError( + 'createGetAppSessionsMessage', + 'participant', + 'Pass the participant wallet or migrate to NitroliteClient.getAppSessionsList().', + ); + } + + return newUnsignedMessage( + 'app_sessions.v1.get_app_sessions', + { + participant, + ...(status ? { status } : {}), + }, + requestId, + timestamp, + ); +} + +export async function createCreateChannelMessage( + _signer: MessageSigner, + _params: unknown, + _requestId?: number, + _timestamp?: number, +): Promise { + throw unsupportedHelperError( + 'createCreateChannelMessage', + 'Use NitroliteClient.deposit(tokenAddress, amount) or depositAndCreateChannel(...) instead.', + ); +} -export const createCreateChannelMessage = noop; export const parseCreateChannelResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: d?.res?.[2] ?? {} }; + const { payload } = extractResponsePayload(raw); + return { params: payload ?? {} }; }; -export const createCloseChannelMessage = noop; +export async function createCloseChannelMessage( + _signer: MessageSigner, + _channelId: string, + _fundDestination: Address, + _requestId?: number, + _timestamp?: number, +): Promise { + throw unsupportedHelperError( + 'createCloseChannelMessage', + 'Use NitroliteClient.closeChannel(...) instead.', + ); +} + export const parseCloseChannelResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: d?.res?.[2] ?? {} }; + const { payload } = extractResponsePayload(raw); + return { params: payload ?? {} }; }; -export const createResizeChannelMessage = noop; +export async function createResizeChannelMessage( + _signer: MessageSigner, + _params: unknown, + _requestId?: number, + _timestamp?: number, +): Promise { + throw unsupportedHelperError( + 'createResizeChannelMessage', + 'Use NitroliteClient.resizeChannel(...) instead.', + ); +} + export const parseResizeChannelResponse = (raw: string) => { - const d = JSON.parse(raw); - return { params: d?.res?.[2] ?? {} }; + const { payload } = extractResponsePayload(raw); + return { params: payload ?? {} }; }; -export const createPingMessage = noop; +export async function createPingMessage( + _signer: MessageSigner, + requestId?: number, + timestamp?: number, +): Promise { + return newUnsignedMessage('node.v1.ping', {}, requestId, timestamp); +} export const convertRPCToClientChannel = (ch: any) => ch; export const convertRPCToClientState = (st: any, _sig?: string) => st; diff --git a/sdk/ts-compat/src/types.ts b/sdk/ts-compat/src/types.ts index fcc77a10f..3880fb9ee 100644 --- a/sdk/ts-compat/src/types.ts +++ b/sdk/ts-compat/src/types.ts @@ -48,6 +48,7 @@ export enum RPCAppStateIntent { Operate = 'operate', Deposit = 'deposit', Withdraw = 'withdraw', + Close = 'close', } export enum RPCTxType { @@ -170,6 +171,7 @@ export interface ResizeChannelRequestParams { export interface TransferAllocation { asset: string; + /** Raw asset units using the asset's canonical decimals (for example 5 USDC = "5000000" when USDC has 6 asset decimals). */ amount: string; } @@ -189,6 +191,7 @@ export interface RPCAppDefinition { export interface RPCAppSessionAllocation { asset: string; + /** Human-readable decimal string, matching the legacy app-session APIs (for example "0.01"). */ amount: string; participant: Address; } @@ -343,6 +346,7 @@ export interface ClearNodeAsset { token: Address; chainId: number; symbol: string; + /** Token decimals for this specific chain/token entry. */ decimals: number; } diff --git a/sdk/ts-compat/test/unit/amount-semantics.test.ts b/sdk/ts-compat/test/unit/amount-semantics.test.ts new file mode 100644 index 000000000..1e6a90ce1 --- /dev/null +++ b/sdk/ts-compat/test/unit/amount-semantics.test.ts @@ -0,0 +1,60 @@ +import { NitroliteClient } from '../../src/index.js'; + +const CURRENT_CHAIN = 84532n; +const CURRENT_TOKEN = '0x0000000000000000000000000000000000000b01'; + +function makeCompatClient() { + const client = Object.create(NitroliteClient.prototype) as NitroliteClient & Record; + Object.assign(client, { + innerClient: { + getAssets: async () => ([ + { + name: 'Yellow USD', + symbol: 'yusd', + decimals: 6, + suggestedBlockchainId: CURRENT_CHAIN, + tokens: [ + { + name: 'Yellow USD', + symbol: 'YUSD', + address: CURRENT_TOKEN, + blockchainId: CURRENT_CHAIN, + decimals: 8, + }, + ], + }, + ]), + }, + userAddress: '0x00000000000000000000000000000000000000a1', + walletClient: { + chain: { + rpcUrls: { + public: { http: ['https://rpc.base-sepolia.example'] }, + default: { http: ['https://rpc.base-sepolia.example'] }, + }, + }, + }, + assetsByChainAndToken: new Map(), + assetsByToken: new Map(), + assetsBySymbol: new Map(), + _chainId: CURRENT_CHAIN, + _lastChannels: [], + _lastAppSessionsListError: null, + _lastAppSessionsListErrorLogged: null, + _blockchains: [], + _lockingTokenDecimals: new Map(), + _blockchainRPCs: { 84532: 'https://rpc.base-sepolia.example' }, + _publicClients: new Map(), + }); + return client; +} + +describe('compat amount semantics', () => { + it('formats and parses raw token amounts using token decimals', async () => { + const client = makeCompatClient(); + await client.refreshAssets(); + + await expect(client.formatAmount(CURRENT_TOKEN, 123456789n)).resolves.toBe('1.23456789'); + await expect(client.parseAmount(CURRENT_TOKEN, '1.23456789')).resolves.toBe(123456789n); + }); +}); diff --git a/sdk/ts-compat/test/unit/client-mapping.test.ts b/sdk/ts-compat/test/unit/client-mapping.test.ts new file mode 100644 index 000000000..6f168c8de --- /dev/null +++ b/sdk/ts-compat/test/unit/client-mapping.test.ts @@ -0,0 +1,303 @@ +import { jest } from '@jest/globals'; +import { Decimal } from 'decimal.js'; + +import { NitroliteClient, RPCAppStateIntent } from '../../src/index.js'; + +const USER = '0x00000000000000000000000000000000000000a1'; +const CURRENT_CHAIN = 84532n; +const CURRENT_TOKEN = '0x0000000000000000000000000000000000000b01'; +const OTHER_CHAIN_TOKEN = '0x0000000000000000000000000000000000000b02'; + +function makeAssetsFixture() { + return [ + { + name: 'Yellow USD', + symbol: 'yusd', + decimals: 6, + suggestedBlockchainId: CURRENT_CHAIN, + tokens: [ + { + name: 'Yellow USD', + symbol: 'YUSD', + address: CURRENT_TOKEN, + blockchainId: CURRENT_CHAIN, + decimals: 8, + }, + { + name: 'Yellow USD', + symbol: 'YUSD', + address: OTHER_CHAIN_TOKEN, + blockchainId: 11155111n, + decimals: 6, + }, + ], + }, + ]; +} + +function makeInnerClient(overrides: Record = {}) { + return { + getAssets: jest.fn().mockResolvedValue(makeAssetsFixture()), + getBalances: jest.fn().mockResolvedValue([]), + getChannels: jest.fn().mockResolvedValue({ channels: [] }), + getLatestState: jest.fn(), + getHomeChannel: jest.fn(), + getAppSessions: jest.fn().mockResolvedValue({ sessions: [] }), + submitAppState: jest.fn().mockResolvedValue(undefined), + submitAppSessionDeposit: jest.fn().mockResolvedValue('0xdeposit'), + transfer: jest.fn().mockResolvedValue(undefined), + setHomeBlockchain: jest.fn().mockResolvedValue(undefined), + deposit: jest.fn().mockResolvedValue(undefined), + withdraw: jest.fn().mockResolvedValue(undefined), + closeHomeChannel: jest.fn().mockResolvedValue(undefined), + checkpoint: jest.fn().mockResolvedValue('0xcheckpoint'), + approveToken: jest.fn().mockResolvedValue('0xapprove'), + checkTokenAllowance: jest.fn().mockResolvedValue(77n), + getOnChainBalance: jest.fn().mockResolvedValue(new Decimal('5')), + ping: jest.fn().mockResolvedValue(undefined), + close: jest.fn().mockResolvedValue(undefined), + waitForClose: jest.fn().mockResolvedValue(undefined), + acknowledge: jest.fn().mockResolvedValue(undefined), + getBlockchains: jest.fn().mockResolvedValue([ + { + id: CURRENT_CHAIN, + name: 'Base Sepolia', + channelHubAddress: '0x0000000000000000000000000000000000000c01', + blockStep: 0n, + }, + ]), + ...overrides, + }; +} + +function makeCompatClient(innerOverrides: Record = {}) { + const innerClient = makeInnerClient(innerOverrides); + const client = Object.create(NitroliteClient.prototype) as NitroliteClient & Record; + Object.assign(client, { + innerClient, + userAddress: USER, + walletClient: { + chain: { + id: Number(CURRENT_CHAIN), + rpcUrls: { + public: { http: ['https://rpc.base-sepolia.example'] }, + default: { http: ['https://rpc.base-sepolia.example'] }, + }, + }, + }, + assetsByChainAndToken: new Map(), + assetsByToken: new Map(), + assetsBySymbol: new Map(), + _chainId: CURRENT_CHAIN, + _lastChannels: [], + _lastAppSessionsListError: null, + _lastAppSessionsListErrorLogged: null, + _blockchains: [ + { + id: CURRENT_CHAIN, + name: 'Base Sepolia', + channelHubAddress: '0x0000000000000000000000000000000000000c01', + blockStep: 0n, + }, + ], + _lockingTokenDecimals: new Map(), + _blockchainRPCs: { 84532: 'https://rpc.base-sepolia.example' }, + _publicClients: new Map(), + }); + return { client, innerClient }; +} + +describe('NitroliteClient compat mappings', () => { + it('stores token decimals for token-facing helpers and asset decimals for ledger balance conversions', async () => { + const { client, innerClient } = makeCompatClient({ + getBalances: jest.fn().mockResolvedValue([{ asset: 'yusd', balance: new Decimal('1.23') }]), + getChannels: jest.fn().mockResolvedValue({ + channels: [ + { + channelId: 'channel-1', + userWallet: USER, + status: 1, + asset: 'yusd', + tokenAddress: CURRENT_TOKEN, + blockchainId: CURRENT_CHAIN, + challengeDuration: 86400, + nonce: 1n, + stateVersion: 2n, + }, + ], + }), + getLatestState: jest.fn().mockResolvedValue({ + homeLedger: { userBalance: new Decimal('1.23') }, + }), + }); + + await client.refreshAssets(); + + expect(await client.getTokenDecimals(CURRENT_TOKEN)).toBe(8); + expect(await client.getBalances()).toEqual([{ asset: 'yusd', amount: '1230000' }]); + expect(await client.getChannels()).toEqual([ + expect.objectContaining({ + channel_id: 'channel-1', + amount: 123000000n, + chain_id: 84532, + }), + ]); + expect(innerClient.getLatestState).toHaveBeenCalled(); + }); + + it('keeps app-session allocation amounts in human decimals for list/read/update flows', async () => { + const { client, innerClient } = makeCompatClient({ + getAppSessions: jest.fn() + .mockResolvedValueOnce({ + sessions: [ + { + appSessionId: 'session-1', + nonce: 1n, + participants: [{ walletAddress: USER, signatureWeight: 1 }], + quorum: 1, + isClosed: false, + version: 4n, + allocations: [{ participant: USER, asset: 'yusd', amount: new Decimal('0.01') }], + sessionData: '{"turn":1}', + }, + ], + }) + .mockResolvedValueOnce({ + sessions: [ + { + appSessionId: 'session-2', + version: 6n, + allocations: [], + }, + ], + }) + .mockResolvedValueOnce({ + sessions: [ + { + appSessionId: 'session-3', + version: 8n, + allocations: [], + }, + ], + }), + }); + + await client.refreshAssets(); + + await expect(client.getAppSessionsList()).resolves.toEqual([ + expect.objectContaining({ + app_session_id: 'session-1', + allocations: [{ participant: USER, asset: 'yusd', amount: '0.01' }], + }), + ]); + + await client.closeAppSession('session-2', [{ participant: USER, asset: 'yusd', amount: '1.5' }], ['0xclose']); + expect(innerClient.submitAppState).toHaveBeenCalledWith( + expect.objectContaining({ + appSessionId: 'session-2', + version: 7n, + allocations: [ + expect.objectContaining({ + asset: 'yusd', + amount: expect.any(Decimal), + }), + ], + }), + ['0xclose'], + ); + expect(innerClient.submitAppState.mock.calls[0][0].allocations[0].amount.toString()).toBe('1.5'); + + await client.submitAppState({ + app_session_id: 'session-3', + intent: RPCAppStateIntent.Operate, + version: 9, + allocations: [{ participant: USER, asset: 'yusd', amount: '0.25' }], + quorum_sigs: ['0xoperate'], + session_data: '{"turn":2}', + }); + expect(innerClient.submitAppState.mock.calls[1][0].allocations[0].amount.toString()).toBe('0.25'); + }); + + it('uses asset decimals for transfers and token decimals for token-facing helpers', async () => { + const { client, innerClient } = makeCompatClient(); + + await client.refreshAssets(); + + await client.transfer('0x00000000000000000000000000000000000000d1', [ + { asset: 'yusd', amount: '5000000' }, + ]); + expect(innerClient.transfer).toHaveBeenCalledWith( + '0x00000000000000000000000000000000000000d1', + 'yusd', + expect.any(Decimal), + ); + expect(innerClient.transfer.mock.calls[0][2].toString()).toBe('5'); + + await expect(client.approveTokens(CURRENT_TOKEN, 250000000n)).resolves.toBe('0xapprove'); + expect(innerClient.approveToken).toHaveBeenCalledWith(CURRENT_CHAIN, 'yusd', expect.any(Decimal)); + expect(innerClient.approveToken.mock.calls[0][2].toString()).toBe('2.5'); + + await expect(client.getTokenAllowance(CURRENT_TOKEN)).resolves.toBe(77n); + expect(innerClient.checkTokenAllowance).toHaveBeenCalledWith(CURRENT_CHAIN, CURRENT_TOKEN, USER); + + await expect(client.getTokenBalance(CURRENT_TOKEN)).resolves.toBe(500000000n); + expect(innerClient.getOnChainBalance).toHaveBeenCalledWith(CURRENT_CHAIN, 'yusd', USER); + }); + + it('rounds down on-chain decimal balances to token precision before converting to raw units', async () => { + const { client, innerClient } = makeCompatClient({ + getOnChainBalance: jest.fn().mockResolvedValue(new Decimal('5.123456789')), + }); + + await client.refreshAssets(); + + await expect(client.getTokenBalance(CURRENT_TOKEN)).resolves.toBe(512345678n); + expect(innerClient.getOnChainBalance).toHaveBeenCalledWith(CURRENT_CHAIN, 'yusd', USER); + }); + + it('does not reuse the connected wallet rpc url for a different requested chain', () => { + const { client } = makeCompatClient(); + Object.assign(client, { + _blockchainRPCs: {}, + walletClient: { + chain: { + id: 11155111, + rpcUrls: { + public: { http: ['https://rpc.sepolia.example'] }, + default: { http: ['https://rpc.sepolia.example'] }, + }, + }, + }, + }); + + expect(() => (client as unknown as Record string>).getRPCUrl(84532)).toThrow( + '[compat] No RPC URL configured for chain 84532.', + ); + }); + + it('exposes token-and-chain specific display data and assets list entries', async () => { + const { client } = makeCompatClient(); + + await client.refreshAssets(); + + await expect(client.resolveAssetDisplay(CURRENT_TOKEN)).resolves.toEqual({ symbol: 'yusd', decimals: 8 }); + await expect(client.resolveAssetDisplay(OTHER_CHAIN_TOKEN, 11155111)).resolves.toEqual({ symbol: 'yusd', decimals: 6 }); + await expect(client.getAssetsList()).resolves.toEqual([ + { token: CURRENT_TOKEN, chainId: 84532, symbol: 'yusd', decimals: 8 }, + { token: OTHER_CHAIN_TOKEN, chainId: 11155111, symbol: 'yusd', decimals: 6 }, + ]); + }); + + it('keeps unsupported legacy methods honest and getOpenChannels delegates to the current chain hub', async () => { + const { client } = makeCompatClient(); + const readContract = jest.fn().mockResolvedValue(['0xabc', '0xdef']); + (client as unknown as Record).getReadClientForChain = jest.fn().mockReturnValue({ readContract }); + + await expect(client.getOpenChannels()).resolves.toEqual(['0xabc', '0xdef']); + + await expect(client.createChannel()).rejects.toThrow('deposit(tokenAddress, amount)'); + await expect(client.checkpointChannel({})).rejects.toThrow('client.innerClient.checkpoint(asset)'); + await expect(client.getAccountBalance(CURRENT_TOKEN)).rejects.toThrow('Use getBalances()'); + await expect(client.getChannelBalance('channel-1', CURRENT_TOKEN)).rejects.toThrow('Use getChannelData(channelId)'); + }); +}); diff --git a/sdk/ts-compat/test/unit/client.test.ts b/sdk/ts-compat/test/unit/client.test.ts index 6c338683b..4d875a87c 100644 --- a/sdk/ts-compat/test/unit/client.test.ts +++ b/sdk/ts-compat/test/unit/client.test.ts @@ -74,8 +74,8 @@ describe('NitroliteClient getAppSessionsList compat mapping', () => { version: 7, weights: [1, 2], allocations: [ - { participant: wallet, asset: 'YUSD', amount: '1250000' }, - { participant: friend, asset: 'YELLOW', amount: '2000000000000000000' }, + { participant: wallet, asset: 'YUSD', amount: '1.25' }, + { participant: friend, asset: 'YELLOW', amount: '2' }, ], sessionData: '{"intent":"purchase"}', }, diff --git a/sdk/ts-compat/test/unit/rpc-wire-shape.test.ts b/sdk/ts-compat/test/unit/rpc-wire-shape.test.ts new file mode 100644 index 000000000..aaee33bac --- /dev/null +++ b/sdk/ts-compat/test/unit/rpc-wire-shape.test.ts @@ -0,0 +1,269 @@ +import { jest } from '@jest/globals'; + +import { + RPCAppStateIntent, + createAppSessionMessage, + createCloseAppSessionMessage, + createCloseChannelMessage, + createGetAppDefinitionMessage, + createGetAppSessionsMessage, + createGetChannelsMessage, + createGetLedgerBalancesMessage, + createPingMessage, + createSubmitAppStateMessage, + createTransferMessage, + parseCreateAppSessionResponse, + parseGetAppDefinitionResponse, + parseGetAppSessionsResponse, +} from '../../src/index.js'; + +const signer = jest.fn(async () => '0xsigned'); +const participant = '0x00000000000000000000000000000000000000a1'; +const otherParticipant = '0x00000000000000000000000000000000000000b2'; + +type CompatRequest = { + req: [number, string, Record, number]; + sig: string; +}; + +function parseCompatRequest(raw: string): CompatRequest { + return JSON.parse(raw) as CompatRequest; +} + +describe('compat RPC helper wire shapes', () => { + beforeEach(() => { + signer.mockClear(); + }); + + it('creates a real v1 ping payload inside the legacy req/sig envelope', async () => { + const raw = await createPingMessage(signer, 7, 1234); + const parsed = parseCompatRequest(raw); + + expect(parsed).toEqual({ + req: [7, 'node.v1.ping', {}, 1234], + sig: '', + }); + expect(signer).not.toHaveBeenCalled(); + }); + + it('requires participant for getChannels and emits the v1 method name', async () => { + const raw = await createGetChannelsMessage(signer, participant, 'open', 11, 999); + const parsed = parseCompatRequest(raw); + + expect(parsed.req).toEqual([ + 11, + 'channels.v1.get_channels', + { wallet: participant, status: 'open' }, + 999, + ]); + expect(parsed.sig).toBe(''); + expect(signer).not.toHaveBeenCalled(); + + await expect(createGetChannelsMessage(signer, undefined, 'open')).rejects.toThrow( + 'createGetChannelsMessage requires participant', + ); + expect(signer).not.toHaveBeenCalled(); + }); + + it('requires wallet/account for getLedgerBalances and signs the request', async () => { + const raw = await createGetLedgerBalancesMessage(signer, participant, 12, 555); + const parsed = parseCompatRequest(raw); + + expect(parsed.req).toEqual([ + 12, + 'user.v1.get_balances', + { wallet: participant }, + 555, + ]); + expect(parsed.sig).toBe('0xsigned'); + expect(signer).toHaveBeenCalledTimes(1); + + await expect(createGetLedgerBalancesMessage(signer)).rejects.toThrow( + 'createGetLedgerBalancesMessage requires accountId', + ); + expect(signer).toHaveBeenCalledTimes(1); + }); + + it('maps app-session query helpers to live v1 methods', async () => { + const sessionsRaw = await createGetAppSessionsMessage(signer, participant, 'open', 13, 777); + const sessionsReq = parseCompatRequest(sessionsRaw); + expect(sessionsReq).toEqual({ + req: [13, 'app_sessions.v1.get_app_sessions', { participant, status: 'open' }, 777], + sig: '', + }); + + const definitionRaw = await createGetAppDefinitionMessage(signer, 'session-1', 14, 778); + const definitionReq = parseCompatRequest(definitionRaw); + expect(definitionReq).toEqual({ + req: [14, 'app_sessions.v1.get_app_definition', { app_session_id: 'session-1' }, 778], + sig: '', + }); + expect(signer).not.toHaveBeenCalled(); + }); + + it('creates a signed v1 create_app_session request and encodes legacy allocations into session_data', async () => { + const raw = await createAppSessionMessage( + signer, + { + definition: { + application: 'chess', + protocol: '' as never, + participants: [participant, otherParticipant] as [`0x${string}`, `0x${string}`], + weights: [1, 2], + quorum: 2, + nonce: 42, + }, + allocations: [ + { participant, asset: 'yusd', amount: '0.25' }, + { participant: otherParticipant, asset: 'yusd', amount: '0.75' }, + ], + quorum_sigs: ['0xsig1', '0xsig2'], + }, + 15, + 779, + ); + + const parsed = parseCompatRequest(raw); + expect(parsed.sig).toBe('0xsigned'); + expect(signer).toHaveBeenCalledTimes(1); + expect(parsed.req[0]).toBe(15); + expect(parsed.req[1]).toBe('app_sessions.v1.create_app_session'); + expect(parsed.req[2]).toEqual({ + definition: { + application_id: 'chess', + participants: [ + { wallet_address: participant, signature_weight: 1 }, + { wallet_address: otherParticipant, signature_weight: 2 }, + ], + quorum: 2, + nonce: '42', + }, + quorum_sigs: ['0xsig1', '0xsig2'], + session_data: JSON.stringify({ + allocations: [ + { participant, asset: 'yusd', amount: '0.25' }, + { participant: otherParticipant, asset: 'yusd', amount: '0.75' }, + ], + }), + }); + expect(parsed.req[3]).toBe(779); + }); + + it('requires explicit version for submit/close app-state mappings and emits submit_app_state', async () => { + const submitRaw = await createSubmitAppStateMessage( + signer, + { + app_session_id: 'session-1' as `0x${string}`, + intent: RPCAppStateIntent.Operate, + version: 7, + allocations: [{ participant, asset: 'yusd', amount: '0.01' }], + session_data: '{"move":"e4"}', + quorum_sigs: ['0xsig'], + }, + 16, + 780, + ); + const submitReq = parseCompatRequest(submitRaw); + expect(submitReq.sig).toBe('0xsigned'); + expect(signer).toHaveBeenCalledTimes(1); + expect(submitReq.req).toEqual([ + 16, + 'app_sessions.v1.submit_app_state', + { + app_state_update: { + app_session_id: 'session-1', + intent: 0, + version: '7', + allocations: [{ participant, asset: 'yusd', amount: '0.01' }], + session_data: '{"move":"e4"}', + }, + quorum_sigs: ['0xsig'], + }, + 780, + ]); + + const closeRaw = await createCloseAppSessionMessage( + signer, + { + app_session_id: 'session-2', + allocations: [{ participant, asset: 'yusd', amount: '1.5' }], + version: 9, + quorum_sigs: ['0xclose'], + }, + 17, + 781, + ); + const closeReq = parseCompatRequest(closeRaw); + expect(closeReq.sig).toBe('0xsigned'); + expect(signer).toHaveBeenCalledTimes(2); + expect(closeReq.req).toEqual([ + 17, + 'app_sessions.v1.submit_app_state', + { + app_state_update: { + app_session_id: 'session-2', + intent: 3, + version: '9', + allocations: [{ participant, asset: 'yusd', amount: '1.5' }], + session_data: '', + }, + quorum_sigs: ['0xclose'], + }, + 781, + ]); + + await expect( + createSubmitAppStateMessage(signer, { + app_session_id: 'session-3' as `0x${string}`, + allocations: [{ participant, asset: 'yusd', amount: '0.01' }], + }), + ).rejects.toThrow('createSubmitAppStateMessage requires params.version'); + expect(signer).toHaveBeenCalledTimes(2); + + await expect( + createCloseAppSessionMessage(signer, { + app_session_id: 'session-4', + allocations: [{ participant, asset: 'yusd', amount: '0.01' }], + quorum_sigs: [], + }), + ).rejects.toThrow('createCloseAppSessionMessage requires params.version'); + expect(signer).toHaveBeenCalledTimes(2); + }); + + it('fails fast for legacy workflow helpers that do not map to a single v1 RPC', async () => { + await expect(createTransferMessage(signer, { destination: participant })).rejects.toThrow( + 'NitroliteClient.transfer(destination, allocations)', + ); + await expect(createCloseChannelMessage(signer, 'channel-1', participant)).rejects.toThrow( + 'NitroliteClient.closeChannel(...)', + ); + }); + + it('normalizes snake_case live responses into legacy parse helper shapes', () => { + expect( + parseGetAppSessionsResponse( + JSON.stringify({ res: [1, 'app_sessions.v1.get_app_sessions', { app_sessions: [{ app_session_id: 's1' }] }, 1234] }), + ), + ).toEqual({ params: { appSessions: [{ app_session_id: 's1' }] } }); + + expect( + parseCreateAppSessionResponse( + JSON.stringify({ res: [1, 'app_sessions.v1.create_app_session', { app_session_id: 's1', version: '1', status: 'open' }, 1234] }), + ), + ).toEqual({ params: { appSessionId: 's1', version: '1', status: 'open' } }); + + expect( + parseGetAppDefinitionResponse( + JSON.stringify({ res: [1, 'app_sessions.v1.get_app_definition', { definition: { application_id: 'app-1' } }, 1234] }), + ), + ).toEqual({ params: { application_id: 'app-1' } }); + }); + + it('accepts bare legacy response arrays using the same tuple layout', () => { + expect( + parseGetAppSessionsResponse( + JSON.stringify([1, 'app_sessions.v1.get_app_sessions', { app_sessions: [{ app_session_id: 's1' }] }, 1234]), + ), + ).toEqual({ params: { appSessions: [{ app_session_id: 's1' }] } }); + }); +});