diff --git a/examples/scripts/new-client.ts b/examples/scripts/new-client.ts index ee162e91..37669431 100644 --- a/examples/scripts/new-client.ts +++ b/examples/scripts/new-client.ts @@ -2,12 +2,14 @@ import { DedotClient, WsProvider } from 'dedot'; console.log('Connecting'); +const WS_URL = 'wss://acala-rpc-0.aca-api.network'; +// const WS_URL = 'wss://rpc.polkadot.io'; + const client = await DedotClient.new({ - provider: new WsProvider('wss://rpc.polkadot.io'), - rpcVersion: 'legacy', + provider: new WsProvider(WS_URL), }); -console.log('Connected'); +console.log('Connected', client.rpcVersion); console.log(await client.query.system.number()); diff --git a/packages/api/src/client/BaseSubstrateClient.ts b/packages/api/src/client/BaseSubstrateClient.ts index 8ff55c18..0ab5bbbc 100644 --- a/packages/api/src/client/BaseSubstrateClient.ts +++ b/packages/api/src/client/BaseSubstrateClient.ts @@ -345,16 +345,26 @@ export abstract class BaseSubstrateClient< // @ts-ignore this.on('disconnected', this.onDisconnected); - return new Promise((resolve) => { + return new Promise((resolve, reject) => { + // @ts-ignore + const offError = this.on('error', (err: unknown) => { + reject(err instanceof Error ? err : new Error(String(err))); + }); // @ts-ignore this.once('ready', () => { + offError(); resolve(this); }); }); } protected onConnected = async () => { - await this.initialize(); + try { + await this.initialize(); + } catch (e) { + // @ts-ignore — surface init failure so the pending connect() promise can reject + this.emit('error', e); + } }; protected onDisconnected = async () => {}; diff --git a/packages/api/src/client/DedotClient.ts b/packages/api/src/client/DedotClient.ts index 955c1c5d..6eb90887 100644 --- a/packages/api/src/client/DedotClient.ts +++ b/packages/api/src/client/DedotClient.ts @@ -14,7 +14,7 @@ import { TxUnsub, Unsub, } from '@dedot/types'; -import { HexString } from '@dedot/utils'; +import { HexString, JsonRpcV2NotSupportedError, noop } from '@dedot/utils'; import { SubstrateApi } from '../chaintypes/index.js'; import { isJsonRpcProvider } from '../json-rpc/index.js'; import { @@ -36,9 +36,14 @@ import { V2Client } from './V2Client.js'; */ export type ClientOptions = ApiOptions & { /** - * The JSON-RPC version to use - * - 'v2' (default): Uses the new JSON-RPC v2 specification - * - 'legacy': Uses the legacy JSON-RPC specification for older nodes + * The JSON-RPC version to use. + * + * - _unset_ (default): auto-detect. Try JSON-RPC v2 first and transparently fall back + * to legacy if the connected node does not expose v2 methods (no `chainHead_*`). + * After `connect()` resolves, `client.rpcVersion` reflects the version that was picked. + * - `'v2'`: force JSON-RPC v2. Throws `JsonRpcV2NotSupportedError` during `connect()` + * if the node does not support v2. + * - `'legacy'`: force legacy JSON-RPC. */ rpcVersion?: RpcVersion; }; @@ -91,9 +96,14 @@ export type ClientOptions = ApiOptions & { export class DedotClient< ChainApi extends GenericSubstrateApi = SubstrateApi, // -- > implements ISubstrateClient { - #client: ISubstrateClient; - /** The JSON-RPC version being used ('v2' or 'legacy') */ - rpcVersion: RpcVersion; + #client!: ISubstrateClient; + #pendingOptions?: ClientOptions | JsonRpcProvider; + /** + * The JSON-RPC version being used ('v2' or 'legacy'). + * + * In auto-detect mode (no `rpcVersion` option), this is resolved during `connect()`. + */ + rpcVersion!: RpcVersion; /** * Creates a new DedotClient instance. @@ -103,19 +113,14 @@ export class DedotClient< * @param options - Client configuration options or a JsonRpcProvider instance */ constructor(options: ClientOptions | JsonRpcProvider) { - let rpcVersion: RpcVersion = 'v2'; - if (!isJsonRpcProvider(options)) { - if (options['rpcVersion'] === 'legacy') { - rpcVersion = 'legacy'; - } - } - - this.rpcVersion = rpcVersion; + const explicitVersion: RpcVersion | undefined = isJsonRpcProvider(options) ? undefined : options.rpcVersion; - if (this.rpcVersion === 'legacy') { - this.#client = new LegacyClient(options); + if (explicitVersion) { + this.rpcVersion = explicitVersion; + this.#client = + explicitVersion === 'legacy' ? new LegacyClient(options) : new V2Client(options); } else { - this.#client = new V2Client(options); + this.#pendingOptions = options; } } @@ -356,10 +361,36 @@ export class DedotClient< /** * Establishes connection to the blockchain network. * + * When `rpcVersion` was not specified, this tries JSON-RPC v2 first and + * transparently falls back to legacy if the node does not support v2. + * * @returns This client instance for method chaining */ async connect(): Promise { - await this.#client.connect(); + if (this.#client) { + await this.#client.connect(); + return this; + } + + const options = this.#pendingOptions!; + this.#pendingOptions = undefined; + + const v2 = new V2Client(options); + try { + await v2.connect(); + this.#client = v2; + this.rpcVersion = 'v2'; + } catch (e) { + if (!(e instanceof JsonRpcV2NotSupportedError)) throw e; + + console.warn('JSON-RPC v2 is not supported by the connected node, falling back to legacy JSON-RPC.'); + + await v2.disconnect().catch(noop); + const legacy = new LegacyClient(options); + await legacy.connect(); + this.#client = legacy; + this.rpcVersion = 'legacy'; + } return this; } diff --git a/packages/api/src/client/V2Client.ts b/packages/api/src/client/V2Client.ts index b2d439b8..e7ab2484 100644 --- a/packages/api/src/client/V2Client.ts +++ b/packages/api/src/client/V2Client.ts @@ -19,7 +19,17 @@ import { ISubmittableResult, TxUnsub, } from '@dedot/types'; -import { assert, concatU8a, DedotError, HashFn, HexString, twox64Concat, u8aToHex, xxhashAsU8a } from '@dedot/utils'; +import { + assert, + concatU8a, + DedotError, + HashFn, + HexString, + JsonRpcV2NotSupportedError, + twox64Concat, + u8aToHex, + xxhashAsU8a, +} from '@dedot/utils'; import type { SubstrateApi } from '../chaintypes/index.js'; import { ConstantExecutor, @@ -128,6 +138,13 @@ export class V2Client // pr if (shouldInitialize) { const rpcMethods: string[] = (await this.rpc.rpc_methods()).methods; + if (!rpcMethods.some((m) => m.startsWith('chainHead_'))) { + throw new JsonRpcV2NotSupportedError( + 'The connected node does not support JSON-RPC v2 (no chainHead_* methods). ' + + 'Omit `rpcVersion` to auto-detect, or pass `rpcVersion: "legacy"`.', + ); + } + this._chainHead = new ChainHead(this, { rpcMethods }); this._chainSpec = new ChainSpec(this, { rpcMethods }); diff --git a/packages/api/src/client/__tests__/DedotClient.spec.ts b/packages/api/src/client/__tests__/DedotClient.spec.ts new file mode 100644 index 00000000..87e576d3 --- /dev/null +++ b/packages/api/src/client/__tests__/DedotClient.spec.ts @@ -0,0 +1,104 @@ +import staticSubstrateV15 from '@polkadot/types-support/metadata/v15/substrate-hex'; +import { MethodResponse, OperationCallDone } from '@dedot/types/json-rpc'; +import { JsonRpcV2NotSupportedError } from '@dedot/utils'; +import { describe, expect, it } from 'vitest'; +import { newChainHeadSimulator } from '../../json-rpc/group/__tests__/simulator.js'; +import { DedotClient } from '../DedotClient.js'; +import { V2Client } from '../V2Client.js'; +import MockProvider from './MockProvider.js'; + +const prefixedMetadataV15 = staticSubstrateV15; + +const setupV2Simulator = (provider: MockProvider) => { + const simulator = newChainHeadSimulator({ provider }); + simulator.notify(simulator.initializedEvent); + simulator.notify(simulator.nextNewBlock()); + simulator.notify(simulator.nextNewBlock()); + simulator.notify(simulator.nextBestBlock()); + simulator.notify(simulator.nextFinalized()); + + let counter = 0; + provider.setRpcRequests({ + chainSpec_v1_chainName: () => 'MockedChain', + chainHead_v1_call: () => { + counter += 1; + return { result: 'started', operationId: `call${counter.toString().padStart(2, '0')}` } as MethodResponse; + }, + module_rpc_name: () => '0x', + }); + + simulator.notify( + { operationId: 'call01', event: 'operationCallDone', output: '0x0c100000000f0000000e000000' } as OperationCallDone, + 10, + ); + simulator.notify( + { operationId: 'call02', event: 'operationCallDone', output: prefixedMetadataV15 } as OperationCallDone, + 20, + ); + + return simulator; +}; + +describe('DedotClient auto-detect rpc version', () => { + describe('auto mode (no rpcVersion specified)', () => { + it('falls back to legacy when node lacks chainHead_* methods', async () => { + const provider = new MockProvider(); + provider.setRpcRequest('rpc_methods', () => ({ + methods: ['state_getMetadata', 'state_getRuntimeVersion', 'chain_getBlockHash'], + })); + + const client = await DedotClient.new({ provider }); + try { + expect(client.rpcVersion).toBe('legacy'); + expect(client.metadata.version).toEqual('V15'); + } finally { + await client.disconnect(); + } + }); + + it('uses v2 when node exposes chainHead_* methods', async () => { + const provider = new MockProvider(); + const simulator = setupV2Simulator(provider); + + const client = await DedotClient.new({ provider }); + try { + expect(client.rpcVersion).toBe('v2'); + } finally { + await client.disconnect(); + await simulator.cleanup(); + } + }); + }); + + describe('explicit rpcVersion', () => { + it("surfaces JsonRpcV2NotSupportedError when 'v2' is forced against a legacy-only node", async () => { + const provider = new MockProvider(); + provider.setRpcRequest('rpc_methods', () => ({ + methods: ['state_getMetadata', 'state_getRuntimeVersion'], + })); + + await expect(DedotClient.new({ provider, rpcVersion: 'v2' })).rejects.toThrow(JsonRpcV2NotSupportedError); + }); + + it("connects as legacy when rpcVersion: 'legacy' is passed", async () => { + const provider = new MockProvider(); + const client = await DedotClient.new({ provider, rpcVersion: 'legacy' }); + try { + expect(client.rpcVersion).toBe('legacy'); + } finally { + await client.disconnect(); + } + }); + }); +}); + +describe('V2Client rpc v2 detection', () => { + it('throws JsonRpcV2NotSupportedError when no chainHead_* methods are available', async () => { + const provider = new MockProvider(); + provider.setRpcRequest('rpc_methods', () => ({ + methods: ['state_getMetadata', 'chain_getHeader'], + })); + + await expect(V2Client.new({ provider })).rejects.toThrow(JsonRpcV2NotSupportedError); + }); +}); diff --git a/packages/utils/src/error.ts b/packages/utils/src/error.ts index 6eeaf340..70ae0a10 100644 --- a/packages/utils/src/error.ts +++ b/packages/utils/src/error.ts @@ -21,3 +21,14 @@ export class UnknownApiError extends DedotError { export class ApiCompatibilityError extends DedotError { name = 'ApiCompatibilityError'; } + +/** + * Thrown by `V2Client` when the connected node does not expose JSON-RPC v2 methods + * (no `chainHead_*` methods in `rpc_methods`). + * + * `DedotClient` catches this error in auto-detect mode (no `rpcVersion` specified) + * and transparently falls back to `LegacyClient`. + */ +export class JsonRpcV2NotSupportedError extends DedotError { + name = 'JsonRpcV2NotSupportedError'; +}