Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions examples/scripts/new-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
14 changes: 12 additions & 2 deletions packages/api/src/client/BaseSubstrateClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,16 +345,26 @@ export abstract class BaseSubstrateClient<
// @ts-ignore
this.on('disconnected', this.onDisconnected);

return new Promise<this>((resolve) => {
return new Promise<this>((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 () => {};
Expand Down
69 changes: 50 additions & 19 deletions packages/api/src/client/DedotClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
};
Expand Down Expand Up @@ -91,9 +96,14 @@ export type ClientOptions = ApiOptions & {
export class DedotClient<
ChainApi extends GenericSubstrateApi = SubstrateApi, // --
> implements ISubstrateClient<ChainApi, ApiEvent> {
#client: ISubstrateClient<ChainApi, ApiEvent>;
/** The JSON-RPC version being used ('v2' or 'legacy') */
rpcVersion: RpcVersion;
#client!: ISubstrateClient<ChainApi, ApiEvent>;
#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.
Expand All @@ -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<ChainApi>(options) : new V2Client<ChainApi>(options);
} else {
this.#client = new V2Client(options);
this.#pendingOptions = options;
}
}

Expand Down Expand Up @@ -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<this> {
await this.#client.connect();
if (this.#client) {
await this.#client.connect();
return this;
}

const options = this.#pendingOptions!;
this.#pendingOptions = undefined;

const v2 = new V2Client<ChainApi>(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<ChainApi>(options);
await legacy.connect();
this.#client = legacy;
this.rpcVersion = 'legacy';
}

return this;
}
Expand Down
19 changes: 18 additions & 1 deletion packages/api/src/client/V2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -128,6 +138,13 @@ export class V2Client<ChainApi extends GenericSubstrateApi = SubstrateApi> // 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 });
Expand Down
104 changes: 104 additions & 0 deletions packages/api/src/client/__tests__/DedotClient.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 11 additions & 0 deletions packages/utils/src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Loading