From 41d9286cb4872621cd9c6ae8ff3eed6457471129 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Wed, 26 Nov 2025 15:22:10 +0100 Subject: [PATCH 01/17] feat(agent): lookup canister ranges using the `/canister_ranges//` certificate path --- packages/core/src/agent/certificate.ts | 116 +++++++++++++++++++++++-- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agent/certificate.ts b/packages/core/src/agent/certificate.ts index 64da44f0c..00d77c0de 100644 --- a/packages/core/src/agent/certificate.ts +++ b/packages/core/src/agent/certificate.ts @@ -16,6 +16,7 @@ import { UnexpectedErrorCode, } from './errors.ts'; import { Principal } from '#principal'; +import { compare as uint8Compare } from '#candid'; import * as bls from './utils/bls.ts'; import { decodeTime } from './utils/leb.ts'; import { bytesToHex, concatBytes, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; @@ -789,8 +790,6 @@ export function find_label(label: NodeLabel, tree: HashTree): LabelLookupResult * @param tree the tree to list the paths of * @returns the paths of the tree */ -// @ts-expect-error TODO: remove this once the function is used -// eslint-disable-next-line @typescript-eslint/no-unused-vars function list_paths(path: Array, tree: HashTree): Array> { switch (tree[0]) { case NodeType.Empty | NodeType.Pruned: { @@ -839,16 +838,59 @@ export function check_canister_ranges(params: CheckCanisterRangesParams): boolea } /** - * Lookup the canister ranges using the `/subnet//canister_ranges` path. - * Certificates returned by `/api/v3/canister//call` - * and `/api/v2/canister//read_state` use this path. + * Lookup the canister ranges using the `/canister_ranges//` path. + * Certificates returned by `/api/v4/canister//call` + * and `/api/v3/canister//read_state` use this path. + * + * If the new lookup is not found, it tries the fallback lookup with {@link lookupCanisterRangesFallback}. * @param params the parameters with which to lookup the canister ranges * @param params.subnetId the subnet ID to lookup the canister ranges for * @param params.tree the tree to search + * @param params.canisterId the canister ID to check + * @returns the encoded canister ranges. Use {@link decodeCanisterRanges} to decode them. + * @see https://internetcomputer.org/docs/references/ic-interface-spec#http-read-state + * @see https://internetcomputer.org/docs/references/ic-interface-spec#state-tree-canister-ranges + */ +function lookupCanisterRanges(params: CheckCanisterRangesParams): Uint8Array { + const { subnetId, tree, canisterId } = params; + + const canisterRangeShardsLookup = lookup_subtree( + ['canister_ranges', subnetId.toUint8Array()], + tree, + ); + if (canisterRangeShardsLookup.status !== LookupSubtreeStatus.Found) { + return lookupCanisterRangesFallback(subnetId, tree); + } + + const canisterRangeShards = canisterRangeShardsLookup.value; + + const shardPaths = getCanisterRangeShardPaths(canisterRangeShards); + if (shardPaths.length === 0) { + throw ProtocolError.fromCode(new CertificateNotAuthorizedErrorCode(canisterId, subnetId)); + } + shardPaths.sort(uint8Compare); + + const shardDivision = getCanisterRangeShardPartitionPoint(shardPaths, canisterId); + if (shardDivision === 0) { + throw ProtocolError.fromCode(new CertificateNotAuthorizedErrorCode(canisterId, subnetId)); + } + + const maxPotentialShard = shardPaths[shardDivision]; + const canisterRange = getCanisterRangeFromShards(maxPotentialShard, canisterRangeShards); + + return canisterRange; +} + +/** + * Lookup the canister ranges using the `/subnet//canister_ranges` path. + * Certificates returned by `/api/v3/canister//call` + * and `/api/v2/canister//read_state` use this path. + * @param subnetId the subnet ID to lookup the canister ranges for + * @param tree the tree to search * @returns the encoded canister ranges. Use {@link decodeCanisterRanges} to decode them. * @see https://internetcomputer.org/docs/references/ic-interface-spec#http-read-state */ -function lookupCanisterRanges({ subnetId, tree }: CheckCanisterRangesParams): Uint8Array { +function lookupCanisterRangesFallback(subnetId: Principal, tree: HashTree): Uint8Array { const lookupResult = lookup_path(['subnet', subnetId.toUint8Array(), 'canister_ranges'], tree); if (lookupResult.status !== LookupPathStatus.Found) { throw ProtocolError.fromCode( @@ -869,3 +911,65 @@ function decodeCanisterRanges(lookupValue: Uint8Array): CanisterRanges { ]); return ranges; } + +function getCanisterRangeShardPaths(canisterRangeShards: HashTree): Array { + const shardPaths: Array = []; + + for (const path of list_paths([], canisterRangeShards)) { + const firstLabel = path[0]; + if (!firstLabel) { + throw ProtocolError.fromCode(new CertificateVerificationErrorCode('Path is invalid')); + } + shardPaths.push(firstLabel); + } + + return shardPaths; +} + +/** + * Finds the partition point that divides shard paths into two groups based on canister ID comparison. + * Uses binary search to partition the array where: + * - Elements at indices [0, partitionPoint) have values >= canisterId + * - Elements at indices [partitionPoint, length) have values < canisterId + * @param shardPaths Sorted array of shard paths to search through + * @param canisterId The canister ID to compare against + * @returns The index of the first shard that is less than the canister ID, or shardPaths.length if all shards are >= canisterId + */ +function getCanisterRangeShardPartitionPoint( + shardPaths: Array, + canisterId: Principal, +): number { + const canisterIdBytes = canisterId.toUint8Array(); + let left = 0; + let right = shardPaths.length; + + // Binary search for the first element where shard < canisterId + while (left < right) { + const mid = Math.floor((left + right) / 2); + if (uint8Compare(shardPaths[mid], canisterIdBytes) <= 0) { + // Found an element <= canisterId, search left half for earlier occurrence + right = mid; + } else { + // Element is > canisterId, search right half + left = mid + 1; + } + } + + return left; +} + +function getCanisterRangeFromShards( + maxShardPath: NodeLabel, + canisterRangeShards: HashTree, +): Uint8Array { + const canisterRange = lookup_path([maxShardPath], canisterRangeShards); + if (canisterRange.status !== LookupPathStatus.Found) { + throw ProtocolError.fromCode( + new LookupErrorCode( + `Could not find canister range for shard ${maxShardPath.toString()}`, + canisterRange.status, + ), + ); + } + return canisterRange.value; +} From 09b04054e0037dfa0aebe7fcf96b2de2339b9ebc Mon Sep 17 00:00:00 2001 From: ilbertt Date: Wed, 26 Nov 2025 15:24:33 +0100 Subject: [PATCH 02/17] chore: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87cc1ea1c..640f66798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - feat(assets)!: replaces `@dfinity/{agent,candid,principal}` deps with `@icp-sdk/core` - feat(assets)!: drops support for cjs for the `@dfinity/assets` package - feat(auth-client)!: `@dfinity/auth-client` has been deprecated. Migrate to [`@icp-sdk/auth`](https://js.icp.build/auth/latest/upgrading/v4) +- feat(agent): lookup canister ranges using the `/canister_ranges//` certificate path - refactor(agent): only declare IC URLs once in the `HttpAgent` class - refactor(agent): split inner logic of `check_canister_ranges` into functions - test(principal): remove unneeded dependency From 73ad4376f22ddab2e81dbd8cf4da6be65788692f Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 27 Nov 2025 14:54:55 +0100 Subject: [PATCH 03/17] fix: index 0 is the first good shard --- packages/core/src/agent/certificate.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/core/src/agent/certificate.ts b/packages/core/src/agent/certificate.ts index 00d77c0de..47e3031a2 100644 --- a/packages/core/src/agent/certificate.ts +++ b/packages/core/src/agent/certificate.ts @@ -871,11 +871,8 @@ function lookupCanisterRanges(params: CheckCanisterRangesParams): Uint8Array { shardPaths.sort(uint8Compare); const shardDivision = getCanisterRangeShardPartitionPoint(shardPaths, canisterId); - if (shardDivision === 0) { - throw ProtocolError.fromCode(new CertificateNotAuthorizedErrorCode(canisterId, subnetId)); - } - const maxPotentialShard = shardPaths[shardDivision]; + const canisterRange = getCanisterRangeFromShards(maxPotentialShard, canisterRangeShards); return canisterRange; From 2393a9473b6de01f35015e2a769ba3bc15ef4120 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 2 Dec 2025 10:15:37 +0100 Subject: [PATCH 04/17] feat(agent)!: use `/api/v3` for query and read_state requests --- e2e/node/utils/mock-replica.ts | 4 ++-- packages/core/src/agent/actor.test.ts | 2 +- packages/core/src/agent/agent/http/index.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/e2e/node/utils/mock-replica.ts b/e2e/node/utils/mock-replica.ts index 584546a2c..f717260df 100644 --- a/e2e/node/utils/mock-replica.ts +++ b/e2e/node/utils/mock-replica.ts @@ -73,11 +73,11 @@ export class MockReplica { this.#createEndpointSpy(MockReplicaSpyType.CallV3), ); app.post( - '/api/v2/canister/:canisterId/read_state', + '/api/v3/canister/:canisterId/read_state', this.#createEndpointSpy(MockReplicaSpyType.ReadStateV2), ); app.post( - '/api/v2/canister/:canisterId/query', + '/api/v3/canister/:canisterId/query', this.#createEndpointSpy(MockReplicaSpyType.QueryV2), ); } diff --git a/packages/core/src/agent/actor.test.ts b/packages/core/src/agent/actor.test.ts index d5e856670..af6f46002 100644 --- a/packages/core/src/agent/actor.test.ts +++ b/packages/core/src/agent/actor.test.ts @@ -171,7 +171,7 @@ describe('makeActor', () => { }); expect(calls[1][0].toString()).toEqual( - `http://127.0.0.1/api/v2/canister/${canisterId.toText()}/read_state`, + `http://127.0.0.1/api/v3/canister/${canisterId.toText()}/read_state`, ); expect(calls[1][1]).toEqual({ method: 'POST', diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index a79532d2d..364a73615 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -660,7 +660,7 @@ export class HttpAgent implements Agent { const delay = tries === 0 ? 0 : backoff.next(); - const url = new URL(`/api/v2/canister/${ecid.toString()}/query`, this.host); + const url = new URL(`/api/v3/canister/${ecid.toString()}/query`, this.host); this.log.print(`fetching "${url.pathname}" with tries:`, { tries, @@ -1119,7 +1119,7 @@ export class HttpAgent implements Agent { transformedRequest = await this.createReadStateRequest(fields, identity); } - const url = new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host); + const url = new URL(`/api/v3/canister/${canister.toString()}/read_state`, this.host); this.log.print(`fetching "${url.pathname}" with request:`, transformedRequest); From 29e3a53675a71b7341721c0bd004652b8e36ee15 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 2 Dec 2025 10:22:12 +0100 Subject: [PATCH 05/17] refactor: renames and changelog --- CHANGELOG.md | 1 + e2e/node/basic/canisterStatus.test.ts | 20 +++--- e2e/node/basic/queryExpiry.test.ts | 86 ++++++++++++------------ e2e/node/basic/syncTime.test.ts | 94 +++++++++++++-------------- e2e/node/utils/mock-replica.ts | 78 +++++++++++----------- 5 files changed, 140 insertions(+), 139 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 640f66798..0f5142027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## [Unreleased] - feat(core)!: removes `@dfinity/{agent,candid,identity,identity-secp256k1,principal}` peer dependencies and moves their source code to the `@icp-sdk/core` package +- feat(agent)!: use `/api/v3` for query and read_state requests - feat(assets)!: replaces `@dfinity/{agent,candid,principal}` deps with `@icp-sdk/core` - feat(assets)!: drops support for cjs for the `@dfinity/assets` package - feat(auth-client)!: `@dfinity/auth-client` has been deprecated. Migrate to [`@icp-sdk/auth`](https://js.icp.build/auth/latest/upgrading/v4) diff --git a/e2e/node/basic/canisterStatus.test.ts b/e2e/node/basic/canisterStatus.test.ts index 5ff3b332e..fd3f5d0f7 100644 --- a/e2e/node/basic/canisterStatus.test.ts +++ b/e2e/node/basic/canisterStatus.test.ts @@ -12,7 +12,7 @@ import { getCanisterId } from '../utils/canisterid.ts'; import { MockReplica, mockSyncTimeResponse, - prepareV2ReadStateSubnetResponse, + prepareV3ReadStateSubnetResponse, } from '../utils/mock-replica.ts'; import { randomIdentity, randomKeyPair } from '../utils/identity.ts'; @@ -92,14 +92,14 @@ describe('canister status', () => { identity, }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); // first try, fails - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); // syncs time @@ -124,7 +124,7 @@ describe('canister status', () => { expect(err.cause.code).toBeInstanceOf(CertificateTimeErrorCode); expect(err.message).toContain('Certificate is signed more than 5 minutes in the past'); } - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); }); it('should sync time and succeed if the certificate is not fresh', async () => { @@ -137,14 +137,14 @@ describe('canister status', () => { identity, }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); // first try, fails - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); // sync time, we return the replica date to make the agent sync time properly @@ -162,7 +162,7 @@ describe('canister status', () => { paths: ['subnet'], }), ).resolves.not.toThrow(); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); }); it('should not sync time and succeed if the certificate is not fresh and disableTimeVerification is true', async () => { @@ -175,13 +175,13 @@ describe('canister status', () => { identity, }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); @@ -193,7 +193,7 @@ describe('canister status', () => { disableCertificateTimeVerification: true, }), ).resolves.not.toThrow(); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); }); }); diff --git a/e2e/node/basic/queryExpiry.test.ts b/e2e/node/basic/queryExpiry.test.ts index b61044cef..8a19153d1 100644 --- a/e2e/node/basic/queryExpiry.test.ts +++ b/e2e/node/basic/queryExpiry.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, it, vi, expect } from 'vitest'; import { MockReplica, mockSyncTimeResponse, - prepareV2QueryResponse, - prepareV2ReadStateSubnetResponse, + prepareV3QueryResponse, + prepareV3ReadStateSubnetResponse, } from '../utils/mock-replica.ts'; import { IDL } from '@icp-sdk/core/candid'; import { Principal } from '@icp-sdk/core/principal'; @@ -52,7 +52,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody, requestId } = await prepareV2QueryResponse({ + const { responseBody, requestId } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -61,26 +61,26 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); - const req = mockReplica.getV2QueryReq(canisterId.toString(), 0); + const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); }); @@ -100,7 +100,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -109,16 +109,16 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: futureDate, // make sure the certificate is fresh for this call }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); @@ -130,7 +130,7 @@ describe('queryExpiry', () => { expectCertificateOutdatedError(e); } - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); it('should retry and fail if the timestamp is outside the max ingress expiry (with retry)', async () => { @@ -146,7 +146,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -155,21 +155,21 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); // advance to go over the max ingress expiry (5 minutes) advanceTimeByMilliseconds(timeDiffMsecs); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: now, }); // fetch subnet keys, fails for certificate freshness checks - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); // sync time, keeping a date in the future to make sure the agent still has outdated time @@ -191,8 +191,8 @@ describe('queryExpiry', () => { expect(err.message).toContain('Certificate is signed more than 5 minutes in the past.'); } - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); }); it('should not retry if the timestamp is outside the max ingress expiry (verifyQuerySignatures=false)', async () => { @@ -206,7 +206,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -215,22 +215,22 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); // advance to go over the max ingress expiry (5 minutes) advanceTimeByMilliseconds(6 * MINUTE_TO_MSECS); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(500).send('Should not be called'); }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); }); it.each([ @@ -257,7 +257,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody, requestId } = await prepareV2QueryResponse({ + const { responseBody, requestId } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -267,27 +267,27 @@ describe('queryExpiry', () => { timeDiffMsecs, date: replicaDate, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); - const req = mockReplica.getV2QueryReq(canisterId.toString(), 0); + const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); }, ); @@ -306,7 +306,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -316,7 +316,7 @@ describe('queryExpiry', () => { timeDiffMsecs: 0, // sync time is disabled date: replicaDate, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); @@ -328,7 +328,7 @@ describe('queryExpiry', () => { expectCertificateOutdatedError(e); } - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); it('should succeed if clock is drifted by more than 5 minutes in the future without syncing it', async () => { @@ -345,7 +345,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody, requestId } = await prepareV2QueryResponse({ + const { responseBody, requestId } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -355,17 +355,17 @@ describe('queryExpiry', () => { timeDiffMsecs: 0, // sync time is disabled date: replicaDate, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); await mockSyncTimeResponse({ @@ -374,17 +374,17 @@ describe('queryExpiry', () => { date: replicaDate, canisterId, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); - const req = mockReplica.getV2QueryReq(canisterId.toString(), 0); + const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); }); }); diff --git a/e2e/node/basic/syncTime.test.ts b/e2e/node/basic/syncTime.test.ts index 13b79304d..706215ca0 100644 --- a/e2e/node/basic/syncTime.test.ts +++ b/e2e/node/basic/syncTime.test.ts @@ -19,7 +19,7 @@ import { createActor } from '../canisters/counter.ts'; import { MockReplica, mockSyncTimeResponse, - prepareV2ReadStateTimeResponse, + prepareV3ReadStateTimeResponse, prepareV3Response, } from '../utils/mock-replica.ts'; import { randomIdentity, randomKeyPair } from '../utils/identity.ts'; @@ -154,23 +154,23 @@ describe('syncTime', () => { const reqTwo = mockReplica.getV3CallReq(canisterId.toString(), 1); expect(reqTwo).toEqual(req); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 0), + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 0), { sender: anonIdentity.getPrincipal(), }, - 'V2 read state body one', + 'V3 read state body one', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 1), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 1), { sender: anonIdentity.getPrincipal(), }, 'V3 read state body two', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 2), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 2), { sender: anonIdentity.getPrincipal(), }, @@ -215,23 +215,23 @@ describe('syncTime', () => { } expect(mockReplica.getV3CallSpy(canisterId.toString())).toHaveBeenCalledTimes(2); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); expect(agent.hasSyncedTime()).toBe(true); }); }); describe('on async creation', () => { it('should sync time on when enabled', async () => { - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ keyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); }); @@ -241,23 +241,23 @@ describe('syncTime', () => { shouldSyncTime: true, }); - expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(3); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(ICP_LEDGER, 0), + expect(mockReplica.getV3ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(3); + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(ICP_LEDGER, 0), { sender: anonIdentity.getPrincipal(), }, 'V3 read state body one', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(ICP_LEDGER, 1), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(ICP_LEDGER, 1), { sender: anonIdentity.getPrincipal(), }, 'V3 read state body two', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(ICP_LEDGER, 2), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(ICP_LEDGER, 2), { sender: anonIdentity.getPrincipal(), }, @@ -267,10 +267,10 @@ describe('syncTime', () => { }); it('should not sync time by default', async () => { - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ keyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); }); @@ -281,15 +281,15 @@ describe('syncTime', () => { identity: anonIdentity, }); - expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); it('should not sync time when explicitly disabled', async () => { - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ keyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); }); @@ -300,7 +300,7 @@ describe('syncTime', () => { identity: anonIdentity, }); - expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); }); @@ -315,16 +315,16 @@ describe('syncTime', () => { }); const actor = await createActor(canisterId, { agent }); - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ keyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); }); @@ -359,23 +359,23 @@ describe('syncTime', () => { 'V3 call body', ); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 0), + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 0), { sender: anonIdentity.getPrincipal(), }, 'V3 read state body one', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 1), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 1), { sender: anonIdentity.getPrincipal(), }, 'V3 read state body two', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 2), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 2), { sender: anonIdentity.getPrincipal(), }, @@ -393,10 +393,10 @@ describe('syncTime', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ keyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); }); @@ -430,7 +430,7 @@ describe('syncTime', () => { 'V3 call body', ); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); @@ -444,10 +444,10 @@ describe('syncTime', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ keyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); }); @@ -482,7 +482,7 @@ describe('syncTime', () => { 'V3 call body', ); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); }); @@ -518,13 +518,13 @@ function expectV3CallRequest( ); } -interface ExpectedV2ReadStateRequest { +interface ExpectedV3ReadStateRequest { sender: Principal; } -function expectV2ReadStateRequest( +function expectV3ReadStateRequest( actual: UnSigned, - expected: ExpectedV2ReadStateRequest, + expected: ExpectedV3ReadStateRequest, snapshotName?: string, ) { expect(actual.content.sender).toEqual(expected.sender.toUint8Array()); diff --git a/e2e/node/utils/mock-replica.ts b/e2e/node/utils/mock-replica.ts index f717260df..2f66ef4c2 100644 --- a/e2e/node/utils/mock-replica.ts +++ b/e2e/node/utils/mock-replica.ts @@ -34,8 +34,8 @@ const NANOSECONDS_TO_MSECS = 1_000_000; export enum MockReplicaSpyType { CallV3 = 'CallV3', - ReadStateV2 = 'ReadStateV2', - QueryV2 = 'QueryV2', + ReadStateV3 = 'ReadStateV3', + QueryV3 = 'QueryV3', } export type MockReplicaRequest = Request<{ canisterId: string }, Uint8Array, Uint8Array>; @@ -46,8 +46,8 @@ export type MockReplicaSpy = Mock; export interface MockReplicaSpies { [MockReplicaSpyType.CallV3]?: MockReplicaSpy; - [MockReplicaSpyType.ReadStateV2]?: MockReplicaSpy; - [MockReplicaSpyType.QueryV2]?: MockReplicaSpy; + [MockReplicaSpyType.ReadStateV3]?: MockReplicaSpy; + [MockReplicaSpyType.QueryV3]?: MockReplicaSpy; } function fallbackSpyImpl(spyType: MockReplicaSpyType, canisterId: string): MockReplicaSpyImpl { @@ -74,11 +74,11 @@ export class MockReplica { ); app.post( '/api/v3/canister/:canisterId/read_state', - this.#createEndpointSpy(MockReplicaSpyType.ReadStateV2), + this.#createEndpointSpy(MockReplicaSpyType.ReadStateV3), ); app.post( '/api/v3/canister/:canisterId/query', - this.#createEndpointSpy(MockReplicaSpyType.QueryV2), + this.#createEndpointSpy(MockReplicaSpyType.QueryV3), ); } @@ -105,24 +105,24 @@ export class MockReplica { this.#setSpyImplOnce(canisterId, MockReplicaSpyType.CallV3, impl); } - public setV2ReadStateSpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { - this.#setSpyImplOnce(canisterId, MockReplicaSpyType.ReadStateV2, impl); + public setV3ReadStateSpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { + this.#setSpyImplOnce(canisterId, MockReplicaSpyType.ReadStateV3, impl); } - public setV2QuerySpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { - this.#setSpyImplOnce(canisterId, MockReplicaSpyType.QueryV2, impl); + public setV3QuerySpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { + this.#setSpyImplOnce(canisterId, MockReplicaSpyType.QueryV3, impl); } public getV3CallSpy(canisterId: string): MockReplicaSpy { return this.#getSpy(canisterId, MockReplicaSpyType.CallV3); } - public getV2ReadStateSpy(canisterId: string): MockReplicaSpy { - return this.#getSpy(canisterId, MockReplicaSpyType.ReadStateV2); + public getV3ReadStateSpy(canisterId: string): MockReplicaSpy { + return this.#getSpy(canisterId, MockReplicaSpyType.ReadStateV3); } - public getV2QuerySpy(canisterId: string): MockReplicaSpy { - return this.#getSpy(canisterId, MockReplicaSpyType.QueryV2); + public getV3QuerySpy(canisterId: string): MockReplicaSpy { + return this.#getSpy(canisterId, MockReplicaSpyType.QueryV3); } public getV3CallReq(canisterId: string, callNumber: number): Signed { @@ -131,14 +131,14 @@ export class MockReplica { return Cbor.decode>(req.body); } - public getV2ReadStateReq(canisterId: string, callNumber: number): UnSigned { - const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.ReadStateV2); + public getV3ReadStateReq(canisterId: string, callNumber: number): UnSigned { + const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.ReadStateV3); return Cbor.decode>(req.body); } - public getV2QueryReq(canisterId: string, callNumber: number): UnSigned { - const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.QueryV2); + public getV3QueryReq(canisterId: string, callNumber: number): UnSigned { + const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.QueryV3); return Cbor.decode>(req.body); } @@ -305,26 +305,26 @@ export async function prepareV3Response({ }; } -export interface V2ReadStateTimeOptions { +export interface V3ReadStateTimeOptions { keyPair?: KeyPair; date?: Date; } -export interface V2ReadStateResponse { +export interface V3ReadStateResponse { responseBody: Uint8Array; } /** * Prepares a version 2 read state time response. - * @param {V2ReadStateTimeOptions} options - The options for preparing the response. + * @param {V3ReadStateTimeOptions} options - The options for preparing the response. * @param {Date} options.date - The date for the response. * @param {KeyPair} options.keyPair - The key pair for signing. - * @returns {Promise} A promise that resolves to the prepared response. + * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV2ReadStateTimeResponse({ +export async function prepareV3ReadStateTimeResponse({ date, keyPair, -}: V2ReadStateTimeOptions): Promise { +}: V3ReadStateTimeOptions): Promise { keyPair = keyPair ?? randomKeyPair(); date = date ?? new Date(); @@ -344,7 +344,7 @@ export async function prepareV2ReadStateTimeResponse({ }; } -interface V2ReadStateSubnetOptions { +interface V3ReadStateSubnetOptions { nodeIdentity: Ed25519KeyIdentity; canisterRanges: Array<[Uint8Array, Uint8Array]>; keyPair?: KeyPair; @@ -353,19 +353,19 @@ interface V2ReadStateSubnetOptions { /** * Prepares a version 2 read state subnet response. - * @param {V2ReadStateSubnetOptions} options - The options for preparing the response. + * @param {V3ReadStateSubnetOptions} options - The options for preparing the response. * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {KeyPair} options.keyPair - The key pair for signing. * @param {Date} options.date - The date for the response. - * @returns {Promise} A promise that resolves to the prepared response. + * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV2ReadStateSubnetResponse({ +export async function prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges, keyPair, date, -}: V2ReadStateSubnetOptions): Promise { +}: V3ReadStateSubnetOptions): Promise { keyPair = keyPair ?? randomKeyPair(); date = date ?? new Date(); @@ -392,7 +392,7 @@ export async function prepareV2ReadStateSubnetResponse({ }; } -interface V2QueryResponseOptions { +interface V3QueryResponseOptions { canisterId: Principal | string; methodName: string; arg: Uint8Array; @@ -404,14 +404,14 @@ interface V2QueryResponseOptions { date?: Date; } -interface V2QueryResponse { +interface V3QueryResponse { responseBody: Uint8Array; requestId: RequestId; } /** * Prepares a version 2 query response. - * @param {V2QueryResponseOptions} options - The options for preparing the response. + * @param {V3QueryResponseOptions} options - The options for preparing the response. * @param {string} options.canisterId - The ID of the canister. * @param {string} options.methodName - The name of the method being called. * @param {Uint8Array} options.arg - The arguments for the method call. @@ -421,9 +421,9 @@ interface V2QueryResponse { * @param {number} options.timeDiffMsecs - The time difference in milliseconds. * @param {Uint8Array} options.reply - The reply payload. * @param {Date} options.date - The date for the response. - * @returns {Promise} A promise that resolves to the prepared response. + * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV2QueryResponse({ +export async function prepareV3QueryResponse({ canisterId, methodName, arg, @@ -433,7 +433,7 @@ export async function prepareV2QueryResponse({ timeDiffMsecs, reply, date, -}: V2QueryResponseOptions): Promise { +}: V3QueryResponseOptions): Promise { canisterId = Principal.from(canisterId); sender = Principal.from(sender); ingressExpiryInMinutes = ingressExpiryInMinutes ?? 5; @@ -530,17 +530,17 @@ export async function mockSyncTimeResponse({ canisterId, }: MockSyncTimeResponseOptions) { canisterId = Principal.from(canisterId).toText(); - const { responseBody: timeResponseBody } = await prepareV2ReadStateTimeResponse({ + const { responseBody: timeResponseBody } = await prepareV3ReadStateTimeResponse({ keyPair, date, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId, (_req, res) => { res.status(200).send(timeResponseBody); }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId, (_req, res) => { res.status(200).send(timeResponseBody); }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId, (_req, res) => { res.status(200).send(timeResponseBody); }); } From 8576b1057087b315927b979dea407a3559c9847e Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 2 Dec 2025 16:18:24 +0100 Subject: [PATCH 06/17] test: update snap --- .../basic/__snapshots__/syncTime.test.ts.snap | 46 +++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap index 3f71dcc20..fea997681 100644 --- a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap +++ b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap @@ -116,29 +116,6 @@ exports[`syncTime > on error > should not sync time by default > V3 call body 1` } `; -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V2 read state body one 1`] = ` -{ - "content": { - "ingress_expiry": 1746103140000000000n, - "paths": [ - [ - { - "data": [ - 116, - 105, - 109, - 101, - ], - "type": "Buffer", - }, - ], - ], - "request_type": "read_state", - "sender": Any, - }, -} -`; - exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 call body 1`] = ` { "content": { @@ -186,6 +163,29 @@ exports[`syncTime > on error > should sync time when the local time does not mat } `; +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body one 1`] = ` +{ + "content": { + "ingress_expiry": 1746103140000000000n, + "paths": [ + [ + { + "data": [ + 116, + 105, + 109, + 101, + ], + "type": "Buffer", + }, + ], + ], + "request_type": "read_state", + "sender": Any, + }, +} +`; + exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body three 1`] = ` { "content": { From 09c6c78f110a8918a67592732f1c080a9f09dda1 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Tue, 2 Dec 2025 16:27:09 +0100 Subject: [PATCH 07/17] style: newline at the end --- e2e/node/basic/__snapshots__/syncTime.test.ts.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap index 71ef44ff3..b085cbf0f 100644 --- a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap +++ b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap @@ -440,4 +440,4 @@ exports[`syncTime > on first call > should sync time when enabled > V4 read stat "sender": Any, }, } -`; \ No newline at end of file +`; From bcfd07fdfa82f54920fc3b037d3ad6bd931326cd Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 11:18:23 +0100 Subject: [PATCH 08/17] feat: subnet status module --- .../src/agent/canisterStatus/index.test.ts | 3 +- .../core/src/agent/canisterStatus/index.ts | 22 +- packages/core/src/agent/certificate.ts | 20 +- packages/core/src/agent/index.ts | 8 +- .../__snapshots__/index.test.ts.snap | 1666 +++++++++++++++++ .../core/src/agent/subnetStatus/index.test.ts | 212 +++ packages/core/src/agent/subnetStatus/index.ts | 270 +++ packages/core/src/agent/utils/readState.ts | 51 +- 8 files changed, 2200 insertions(+), 52 deletions(-) create mode 100644 packages/core/src/agent/subnetStatus/__snapshots__/index.test.ts.snap create mode 100644 packages/core/src/agent/subnetStatus/index.test.ts create mode 100644 packages/core/src/agent/subnetStatus/index.ts diff --git a/packages/core/src/agent/canisterStatus/index.test.ts b/packages/core/src/agent/canisterStatus/index.test.ts index 3d5b9076f..e471a9e70 100644 --- a/packages/core/src/agent/canisterStatus/index.test.ts +++ b/packages/core/src/agent/canisterStatus/index.test.ts @@ -158,7 +158,8 @@ describe('Canister Status utility', () => { ]); expect(status.get('time')).toMatchSnapshot(); // Expect null for a failed result - expect(status.get('asdf' as unknown as Path)).toBe(null); + expect(status.get('subnet')).toBe(null); + expect(status.get('asdf')).toBe(null); // Expect undefined for unset value expect(status.get('test123')).toBe(undefined); expect(consoleSpy).toBeCalledTimes(3); diff --git a/packages/core/src/agent/canisterStatus/index.ts b/packages/core/src/agent/canisterStatus/index.ts index 9bbd10ea7..bb70d4773 100644 --- a/packages/core/src/agent/canisterStatus/index.ts +++ b/packages/core/src/agent/canisterStatus/index.ts @@ -22,9 +22,8 @@ import * as cbor from '../cbor.ts'; import { decodeTime } from '../utils/leb.ts'; import { utf8ToBytes, bytesToHex } from '@noble/hashes/utils'; import { - type SubnetStatus, - type Status, - type StatusMap, + type BaseSubnetStatus, + type BaseStatus, CustomPath, decodeValue, decodeControllers, @@ -35,19 +34,18 @@ import { } from '../utils/readState.ts'; // Re-export shared types for backwards compatibility -export { - type SubnetStatus, - type Status, - type StatusMap, - type DecodeStrategy, - CustomPath, -} from '../utils/readState.ts'; +export { type DecodeStrategy, CustomPath } from '../utils/readState.ts'; + +export type SubnetStatus = BaseSubnetStatus; +export type Status = BaseStatus | SubnetStatus; /** * Pre-configured fields for canister status paths */ export type Path = 'time' | 'controllers' | 'subnet' | 'module_hash' | 'candid' | CustomPath; +export type StatusMap = Map; + export type CanisterStatusOptions = { /** * The effective canister ID to use in the underlying {@link HttpAgent.readState} call. @@ -83,7 +81,7 @@ export type CanisterStatusOptions = { * * const controllers = status.get('controllers'); */ -export const request = async (options: CanisterStatusOptions): Promise> => { +export const request = async (options: CanisterStatusOptions): Promise => { const { agent, paths, disableCertificateTimeVerification = false } = options; const canisterId = Principal.from(options.canisterId); @@ -200,7 +198,7 @@ export const fetchNodeKeys = ( certificate: Uint8Array, canisterId: Principal, root_key?: Uint8Array, -): SubnetStatus => { +): BaseSubnetStatus => { if (!canisterId._isPrincipal) { throw InputError.fromCode(new UnexpectedErrorCode('Invalid canisterId')); } diff --git a/packages/core/src/agent/certificate.ts b/packages/core/src/agent/certificate.ts index 9be376f26..f0a86adaa 100644 --- a/packages/core/src/agent/certificate.ts +++ b/packages/core/src/agent/certificate.ts @@ -866,12 +866,17 @@ function list_paths(path: Array, tree: HashTree): Array; + +/** + * Canister ranges in the form of an array of [start, end] principal tuples, + * usually decoded from the certificate. + */ +export type CanisterRanges = Array<[Principal, Principal]>; /** * Check if a canister ID falls within the canister ranges of a given subnet @@ -904,7 +909,7 @@ export function check_canister_ranges(params: CheckCanisterRangesParams): boolea * @see https://internetcomputer.org/docs/references/ic-interface-spec#http-read-state * @see https://internetcomputer.org/docs/references/ic-interface-spec#state-tree-canister-ranges */ -function lookupCanisterRanges(params: CheckCanisterRangesParams): Uint8Array { +export function lookupCanisterRanges(params: CheckCanisterRangesParams): Uint8Array { const { subnetId, tree, canisterId } = params; const canisterRangeShardsLookup = lookup_subtree( @@ -940,7 +945,7 @@ function lookupCanisterRanges(params: CheckCanisterRangesParams): Uint8Array { * @returns the encoded canister ranges. Use {@link decodeCanisterRanges} to decode them. * @see https://internetcomputer.org/docs/references/ic-interface-spec#http-read-state */ -function lookupCanisterRangesFallback(subnetId: Principal, tree: HashTree): Uint8Array { +export function lookupCanisterRangesFallback(subnetId: Principal, tree: HashTree): Uint8Array { const lookupResult = lookup_path(['subnet', subnetId.toUint8Array(), 'canister_ranges'], tree); if (lookupResult.status !== LookupPathStatus.Found) { throw ProtocolError.fromCode( @@ -953,7 +958,12 @@ function lookupCanisterRangesFallback(subnetId: Principal, tree: HashTree): Uint return lookupResult.value; } -function decodeCanisterRanges(lookupValue: Uint8Array): CanisterRanges { +/** + * Decode canister ranges from CBOR-encoded buffer + * @param lookupValue the CBOR-encoded value read from the certificate + * @returns an array of canister range tuples [start, end] + */ +export function decodeCanisterRanges(lookupValue: Uint8Array): CanisterRanges { const ranges_arr = cbor.decode>(lookupValue); const ranges: CanisterRanges = ranges_arr.map(v => [ Principal.fromUint8Array(v[0]), diff --git a/packages/core/src/agent/index.ts b/packages/core/src/agent/index.ts index efa17665f..ac915a1ac 100644 --- a/packages/core/src/agent/index.ts +++ b/packages/core/src/agent/index.ts @@ -112,6 +112,7 @@ export * from './utils/buffer.ts'; export * from './utils/random.ts'; export * as polling from './polling/index.ts'; import * as CanisterStatus from './canisterStatus/index.ts'; +import * as SubnetStatus from './subnetStatus/index.ts'; /** * The CanisterStatus utility is used to request structured data directly from the IC public API. This data can be accessed using agent.readState, but CanisterStatus provides a helpful abstraction with some known paths. * @@ -120,6 +121,11 @@ import * as CanisterStatus from './canisterStatus/index.ts'; * The primary method for this namespace is {@link CanisterStatus.request} */ export { CanisterStatus }; - +/** + * The SubnetStatus utility is used to request structured data directly from the IC public API. This data can be accessed using agent.readSubnetState, but SubnetStatus provides a helpful abstraction with some known paths. + * + * The primary method for this namespace is {@link SubnetStatus.request} + */ +export { SubnetStatus }; export { Cbor, ToCborValue } from './cbor.ts'; export * from './polling/index.ts'; diff --git a/packages/core/src/agent/subnetStatus/__snapshots__/index.test.ts.snap b/packages/core/src/agent/subnetStatus/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000..f33baa936 --- /dev/null +++ b/packages/core/src/agent/subnetStatus/__snapshots__/index.test.ts.snap @@ -0,0 +1,1666 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Subnet Status utility should query subnet node keys 1`] = ` +Map { + "5jbfj-wygyf-jzcjw-s6ali-nlazr-nbejd-sfw6h-v44fg-hh34c-khde2-lqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 34, + 192, + 11, + 173, + 160, + 10, + 187, + 248, + 131, + 125, + 148, + 173, + 151, + 22, + 85, + 128, + 89, + 213, + 50, + 71, + 32, + 40, + 211, + 26, + 6, + 112, + 163, + 37, + 30, + 201, + 35, + 168, + ], + "5zldm-3rijx-6pp6q-twp65-b34xz-6iitk-kqzov-flbdh-ekkx3-3an4a-eqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 76, + 124, + 143, + 201, + 223, + 231, + 204, + 253, + 32, + 48, + 149, + 54, + 83, + 43, + 237, + 51, + 176, + 77, + 101, + 223, + 123, + 161, + 140, + 166, + 201, + 180, + 64, + 42, + 10, + 25, + 176, + 193, + ], + "sqw45-bb5aa-rsstr-3lqro-2asg4-jqzqp-lp6az-2a2dr-tzil6-df7tw-gqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 49, + 168, + 237, + 43, + 46, + 106, + 38, + 66, + 166, + 220, + 122, + 110, + 120, + 155, + 74, + 234, + 78, + 7, + 222, + 43, + 229, + 202, + 162, + 100, + 42, + 24, + 247, + 64, + 48, + 200, + 218, + 170, + ], + "23jbm-6z6mi-ki2ut-fkz5x-yy4uy-6llls-yyodh-xv537-2qonk-2iod3-jae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 194, + 119, + 212, + 218, + 108, + 196, + 105, + 199, + 131, + 252, + 86, + 27, + 121, + 218, + 76, + 96, + 82, + 190, + 206, + 109, + 150, + 193, + 48, + 51, + 33, + 121, + 162, + 111, + 188, + 89, + 218, + 118, + ], + "chzgl-n2hjh-3coyq-fozyi-tipv7-autbh-ei22q-hr6gr-rvdxz-2zh4z-aqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 138, + 65, + 138, + 162, + 211, + 109, + 8, + 112, + 151, + 125, + 192, + 240, + 117, + 8, + 185, + 183, + 189, + 189, + 67, + 86, + 90, + 8, + 235, + 248, + 15, + 108, + 134, + 181, + 14, + 10, + 119, + 43, + ], + "agtd6-f2qxq-ciadf-bterj-vpcos-7fapt-co3cv-ypvhs-zy6mk-ijrto-uae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 41, + 38, + 160, + 181, + 154, + 64, + 57, + 191, + 115, + 251, + 61, + 164, + 226, + 224, + 99, + 192, + 118, + 141, + 85, + 31, + 212, + 196, + 3, + 233, + 17, + 47, + 166, + 9, + 118, + 166, + 90, + 140, + ], + "sa67b-rto5p-rvyts-ehk7m-dpsnk-2c3w2-pmcx7-mzvgr-4p6co-ampd6-pqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 18, + 30, + 87, + 253, + 231, + 252, + 167, + 188, + 108, + 233, + 157, + 176, + 249, + 17, + 205, + 0, + 118, + 94, + 116, + 81, + 246, + 217, + 190, + 52, + 39, + 104, + 13, + 99, + 54, + 29, + 205, + 232, + ], + "l7nbu-afo7y-adwcg-m6ivf-cooqw-v3pwd-jf4w3-kgsbr-tek3p-7n3dh-3ae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 147, + 186, + 244, + 192, + 93, + 41, + 40, + 159, + 213, + 238, + 78, + 74, + 232, + 218, + 169, + 42, + 46, + 233, + 123, + 93, + 244, + 124, + 204, + 93, + 48, + 36, + 72, + 247, + 56, + 172, + 142, + 168, + ], + "uset5-ffvx2-tsfys-n35qu-bbx7m-d3x3r-d5fcw-7d44p-sefly-4cg4h-vqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 32, + 233, + 100, + 99, + 117, + 150, + 115, + 222, + 156, + 24, + 76, + 126, + 209, + 123, + 100, + 248, + 75, + 68, + 58, + 213, + 253, + 64, + 66, + 2, + 123, + 244, + 34, + 103, + 233, + 168, + 223, + 100, + ], + "rv6cf-76ajy-qgcld-d7gil-e7v22-3sqfv-yc2zs-sn5r5-465el-pbym3-xae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 183, + 124, + 23, + 204, + 217, + 249, + 232, + 137, + 222, + 211, + 240, + 194, + 59, + 82, + 101, + 152, + 96, + 79, + 253, + 189, + 221, + 86, + 30, + 118, + 123, + 95, + 148, + 35, + 120, + 120, + 169, + 170, + ], + "wbz2k-b6che-eckjw-vdwro-gyxhj-276mr-lxijv-wvk6b-kdmqn-s7zzh-6qe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 12, + 138, + 84, + 193, + 153, + 23, + 238, + 55, + 28, + 8, + 51, + 59, + 153, + 251, + 65, + 100, + 0, + 144, + 254, + 55, + 106, + 203, + 198, + 59, + 198, + 164, + 89, + 185, + 69, + 110, + 95, + 28, + ], + "h3g2z-jgfid-7hvbw-jyfw7-4jiza-iyy4w-bvx44-nizgh-kct53-ffjtz-lae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 7, + 39, + 221, + 132, + 3, + 82, + 174, + 134, + 112, + 91, + 176, + 19, + 178, + 48, + 164, + 216, + 218, + 120, + 6, + 144, + 117, + 43, + 165, + 102, + 245, + 107, + 143, + 227, + 172, + 187, + 97, + 237, + ], + "d4ndk-jxgud-mf7j2-63lqc-2f64s-vouw3-l6k2k-akpwd-b4t4d-vur7h-jae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 161, + 199, + 204, + 174, + 192, + 203, + 11, + 133, + 36, + 96, + 239, + 142, + 85, + 105, + 127, + 131, + 22, + 46, + 227, + 7, + 88, + 56, + 50, + 211, + 152, + 34, + 40, + 245, + 151, + 145, + 32, + 200, + ], +} +`; + +exports[`Subnet Status utility should query subnet public key 1`] = ` +Uint8Array [ + 48, + 129, + 130, + 48, + 29, + 6, + 13, + 43, + 6, + 1, + 4, + 1, + 130, + 220, + 124, + 5, + 3, + 1, + 2, + 1, + 6, + 12, + 43, + 6, + 1, + 4, + 1, + 130, + 220, + 124, + 5, + 3, + 2, + 1, + 3, + 97, + 0, + 145, + 63, + 106, + 132, + 240, + 87, + 54, + 194, + 221, + 11, + 45, + 195, + 236, + 131, + 142, + 149, + 65, + 154, + 224, + 239, + 66, + 109, + 85, + 111, + 80, + 21, + 42, + 68, + 91, + 156, + 35, + 36, + 149, + 188, + 5, + 28, + 218, + 187, + 212, + 106, + 123, + 216, + 135, + 155, + 9, + 114, + 12, + 187, + 19, + 179, + 188, + 210, + 101, + 255, + 117, + 86, + 125, + 203, + 240, + 78, + 154, + 225, + 195, + 162, + 20, + 18, + 41, + 166, + 117, + 118, + 5, + 98, + 91, + 9, + 60, + 92, + 163, + 67, + 54, + 57, + 62, + 90, + 136, + 183, + 141, + 247, + 176, + 163, + 167, + 229, + 185, + 132, + 23, + 22, + 208, + 164, +] +`; + +exports[`Subnet Status utility should query the time 1`] = `2025-11-20T00:08:09.536Z`; + +exports[`Subnet Status utility should support multiple requests 1`] = `2025-11-20T00:08:09.536Z`; + +exports[`Subnet Status utility should support multiple requests 2`] = ` +Uint8Array [ + 48, + 129, + 130, + 48, + 29, + 6, + 13, + 43, + 6, + 1, + 4, + 1, + 130, + 220, + 124, + 5, + 3, + 1, + 2, + 1, + 6, + 12, + 43, + 6, + 1, + 4, + 1, + 130, + 220, + 124, + 5, + 3, + 2, + 1, + 3, + 97, + 0, + 145, + 63, + 106, + 132, + 240, + 87, + 54, + 194, + 221, + 11, + 45, + 195, + 236, + 131, + 142, + 149, + 65, + 154, + 224, + 239, + 66, + 109, + 85, + 111, + 80, + 21, + 42, + 68, + 91, + 156, + 35, + 36, + 149, + 188, + 5, + 28, + 218, + 187, + 212, + 106, + 123, + 216, + 135, + 155, + 9, + 114, + 12, + 187, + 19, + 179, + 188, + 210, + 101, + 255, + 117, + 86, + 125, + 203, + 240, + 78, + 154, + 225, + 195, + 162, + 20, + 18, + 41, + 166, + 117, + 118, + 5, + 98, + 91, + 9, + 60, + 92, + 163, + 67, + 54, + 57, + 62, + 90, + 136, + 183, + 141, + 247, + 176, + 163, + 167, + 229, + 185, + 132, + 23, + 22, + 208, + 164, +] +`; + +exports[`Subnet Status utility should support multiple requests with a failure 1`] = `2025-11-20T00:08:09.536Z`; + +exports[`Subnet Status utility should support valid custom paths 1`] = `1763597289536590252n`; + +exports[`Subnet Status utility should support valid custom paths 2`] = ` +Uint8Array [ + 172, + 163, + 174, + 141, + 193, + 204, + 227, + 188, + 24, +] +`; + +exports[`Subnet Status utility should support valid custom paths 3`] = `"aca3ae8dc1cce3bc18"`; + +exports[`decodeCanisterRanges should decode canister ranges correctly 1`] = ` +[ + [ + { + "__principal__": "rdmx6-jaaaa-aaaaa-aaadq-cai", + }, + { + "__principal__": "rdmx6-jaaaa-aaaaa-aaadq-cai", + }, + ], + [ + { + "__principal__": "uc7f6-kaaaa-aaaaq-qaaaa-cai", + }, + { + "__principal__": "ijz7v-ziaaa-aaaaq-7777q-cai", + }, + ], +] +`; + +exports[`lookupSubnetInfo should return the node keys from a mainnet subnet certificate 1`] = ` +Map { + "5jbfj-wygyf-jzcjw-s6ali-nlazr-nbejd-sfw6h-v44fg-hh34c-khde2-lqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 34, + 192, + 11, + 173, + 160, + 10, + 187, + 248, + 131, + 125, + 148, + 173, + 151, + 22, + 85, + 128, + 89, + 213, + 50, + 71, + 32, + 40, + 211, + 26, + 6, + 112, + 163, + 37, + 30, + 201, + 35, + 168, + ], + "5zldm-3rijx-6pp6q-twp65-b34xz-6iitk-kqzov-flbdh-ekkx3-3an4a-eqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 76, + 124, + 143, + 201, + 223, + 231, + 204, + 253, + 32, + 48, + 149, + 54, + 83, + 43, + 237, + 51, + 176, + 77, + 101, + 223, + 123, + 161, + 140, + 166, + 201, + 180, + 64, + 42, + 10, + 25, + 176, + 193, + ], + "sqw45-bb5aa-rsstr-3lqro-2asg4-jqzqp-lp6az-2a2dr-tzil6-df7tw-gqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 49, + 168, + 237, + 43, + 46, + 106, + 38, + 66, + 166, + 220, + 122, + 110, + 120, + 155, + 74, + 234, + 78, + 7, + 222, + 43, + 229, + 202, + 162, + 100, + 42, + 24, + 247, + 64, + 48, + 200, + 218, + 170, + ], + "23jbm-6z6mi-ki2ut-fkz5x-yy4uy-6llls-yyodh-xv537-2qonk-2iod3-jae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 194, + 119, + 212, + 218, + 108, + 196, + 105, + 199, + 131, + 252, + 86, + 27, + 121, + 218, + 76, + 96, + 82, + 190, + 206, + 109, + 150, + 193, + 48, + 51, + 33, + 121, + 162, + 111, + 188, + 89, + 218, + 118, + ], + "chzgl-n2hjh-3coyq-fozyi-tipv7-autbh-ei22q-hr6gr-rvdxz-2zh4z-aqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 138, + 65, + 138, + 162, + 211, + 109, + 8, + 112, + 151, + 125, + 192, + 240, + 117, + 8, + 185, + 183, + 189, + 189, + 67, + 86, + 90, + 8, + 235, + 248, + 15, + 108, + 134, + 181, + 14, + 10, + 119, + 43, + ], + "agtd6-f2qxq-ciadf-bterj-vpcos-7fapt-co3cv-ypvhs-zy6mk-ijrto-uae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 41, + 38, + 160, + 181, + 154, + 64, + 57, + 191, + 115, + 251, + 61, + 164, + 226, + 224, + 99, + 192, + 118, + 141, + 85, + 31, + 212, + 196, + 3, + 233, + 17, + 47, + 166, + 9, + 118, + 166, + 90, + 140, + ], + "sa67b-rto5p-rvyts-ehk7m-dpsnk-2c3w2-pmcx7-mzvgr-4p6co-ampd6-pqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 18, + 30, + 87, + 253, + 231, + 252, + 167, + 188, + 108, + 233, + 157, + 176, + 249, + 17, + 205, + 0, + 118, + 94, + 116, + 81, + 246, + 217, + 190, + 52, + 39, + 104, + 13, + 99, + 54, + 29, + 205, + 232, + ], + "l7nbu-afo7y-adwcg-m6ivf-cooqw-v3pwd-jf4w3-kgsbr-tek3p-7n3dh-3ae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 147, + 186, + 244, + 192, + 93, + 41, + 40, + 159, + 213, + 238, + 78, + 74, + 232, + 218, + 169, + 42, + 46, + 233, + 123, + 93, + 244, + 124, + 204, + 93, + 48, + 36, + 72, + 247, + 56, + 172, + 142, + 168, + ], + "uset5-ffvx2-tsfys-n35qu-bbx7m-d3x3r-d5fcw-7d44p-sefly-4cg4h-vqe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 32, + 233, + 100, + 99, + 117, + 150, + 115, + 222, + 156, + 24, + 76, + 126, + 209, + 123, + 100, + 248, + 75, + 68, + 58, + 213, + 253, + 64, + 66, + 2, + 123, + 244, + 34, + 103, + 233, + 168, + 223, + 100, + ], + "rv6cf-76ajy-qgcld-d7gil-e7v22-3sqfv-yc2zs-sn5r5-465el-pbym3-xae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 183, + 124, + 23, + 204, + 217, + 249, + 232, + 137, + 222, + 211, + 240, + 194, + 59, + 82, + 101, + 152, + 96, + 79, + 253, + 189, + 221, + 86, + 30, + 118, + 123, + 95, + 148, + 35, + 120, + 120, + 169, + 170, + ], + "wbz2k-b6che-eckjw-vdwro-gyxhj-276mr-lxijv-wvk6b-kdmqn-s7zzh-6qe" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 12, + 138, + 84, + 193, + 153, + 23, + 238, + 55, + 28, + 8, + 51, + 59, + 153, + 251, + 65, + 100, + 0, + 144, + 254, + 55, + 106, + 203, + 198, + 59, + 198, + 164, + 89, + 185, + 69, + 110, + 95, + 28, + ], + "h3g2z-jgfid-7hvbw-jyfw7-4jiza-iyy4w-bvx44-nizgh-kct53-ffjtz-lae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 7, + 39, + 221, + 132, + 3, + 82, + 174, + 134, + 112, + 91, + 176, + 19, + 178, + 48, + 164, + 216, + 218, + 120, + 6, + 144, + 117, + 43, + 165, + 102, + 245, + 107, + 143, + 227, + 172, + 187, + 97, + 237, + ], + "d4ndk-jxgud-mf7j2-63lqc-2f64s-vouw3-l6k2k-akpwd-b4t4d-vur7h-jae" => Uint8Array [ + 48, + 42, + 48, + 5, + 6, + 3, + 43, + 101, + 112, + 3, + 33, + 0, + 161, + 199, + 204, + 174, + 192, + 203, + 11, + 133, + 36, + 96, + 239, + 142, + 85, + 105, + 127, + 131, + 22, + 46, + 227, + 7, + 88, + 56, + 50, + 211, + 152, + 34, + 40, + 245, + 151, + 145, + 32, + 200, + ], +} +`; + +exports[`lookupSubnetInfo should return the node keys from a mainnet subnet certificate 2`] = ` +Uint8Array [ + 48, + 129, + 130, + 48, + 29, + 6, + 13, + 43, + 6, + 1, + 4, + 1, + 130, + 220, + 124, + 5, + 3, + 1, + 2, + 1, + 6, + 12, + 43, + 6, + 1, + 4, + 1, + 130, + 220, + 124, + 5, + 3, + 2, + 1, + 3, + 97, + 0, + 145, + 63, + 106, + 132, + 240, + 87, + 54, + 194, + 221, + 11, + 45, + 195, + 236, + 131, + 142, + 149, + 65, + 154, + 224, + 239, + 66, + 109, + 85, + 111, + 80, + 21, + 42, + 68, + 91, + 156, + 35, + 36, + 149, + 188, + 5, + 28, + 218, + 187, + 212, + 106, + 123, + 216, + 135, + 155, + 9, + 114, + 12, + 187, + 19, + 179, + 188, + 210, + 101, + 255, + 117, + 86, + 125, + 203, + 240, + 78, + 154, + 225, + 195, + 162, + 20, + 18, + 41, + 166, + 117, + 118, + 5, + 98, + 91, + 9, + 60, + 92, + 163, + 67, + 54, + 57, + 62, + 90, + 136, + 183, + 141, + 247, + 176, + 163, + 167, + 229, + 185, + 132, + 23, + 22, + 208, + 164, +] +`; diff --git a/packages/core/src/agent/subnetStatus/index.test.ts b/packages/core/src/agent/subnetStatus/index.test.ts new file mode 100644 index 000000000..6c281492c --- /dev/null +++ b/packages/core/src/agent/subnetStatus/index.test.ts @@ -0,0 +1,212 @@ +import { hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; +import { Principal } from '#principal'; +import { request, Path, lookupSubnetInfo, encodePath, IC_ROOT_SUBNET_ID } from './index.ts'; +import { HttpAgent } from '../agent/index.ts'; +import * as Cert from '../certificate.ts'; +import { goldenCertificates } from '../agent/http/__certificates__/goldenCertificates.ts'; +import { decode } from '../cbor.ts'; +import { decodeCanisterRanges } from '../certificate.ts'; + +// bypass bls verification so that an old certificate is accepted +jest.mock('../utils/bls', () => { + return { + blsVerify: jest.fn(() => Promise.resolve(true)), + }; +}); + +jest.useFakeTimers(); + +const certificateBytes = hexToBytes(goldenCertificates.mainnetApplicationO3ow2); +// Test subnet ID from golden certificate mainnetApplicationO3ow2 +const testSubnetId = Principal.fromText( + 'o3ow2-2ipam-6fcjo-3j5vt-fzbge-2g7my-5fz2m-p4o2t-dwlc4-gt2q7-5ae', +); +// Certificate time from mainnetApplicationO3ow2: 2025-11-20T00:07:23.446Z +const certificateTime = Date.parse('2025-11-20T00:07:23.446Z'); + +// Helper to get status using precomputed certificate +const getStatus = async (paths: Path[], subnetId: Principal = testSubnetId) => { + jest.setSystemTime(certificateTime); + + const agent = HttpAgent.createSync({ host: 'https://ic0.app' }); + agent.readSubnetState = jest.fn(() => Promise.resolve({ certificate: certificateBytes })); + + return await request({ + subnetId, + paths, + agent, + }); +}; + +describe('Subnet Status utility', () => { + beforeEach(() => { + jest.setSystemTime(certificateTime); + }); + + it('should query the time', async () => { + const status = await getStatus(['time']); + expect(status.get('time')).toMatchSnapshot(); + }); + + it('should query subnet public key', async () => { + const status = await getStatus(['publicKey']); + expect(status.get('publicKey')).toMatchSnapshot(); + }); + + it('should query subnet node keys', async () => { + const status = await getStatus(['nodeKeys']); + const nodeKeys = status.get('nodeKeys'); + expect(nodeKeys).toMatchSnapshot(); + }); + + it('should support valid custom paths', async () => { + const status = await getStatus([ + { + key: 'time', + path: [utf8ToBytes('time')], + decodeStrategy: 'leb128', + }, + ]); + const statusRaw = await getStatus([ + { + key: 'time', + path: [utf8ToBytes('time')], + decodeStrategy: 'raw', + }, + ]); + const statusHex = await getStatus([ + { + key: 'time', + path: [utf8ToBytes('time')], + decodeStrategy: 'hex', + }, + ]); + expect(status.get('time')).toMatchSnapshot(); + expect(statusRaw.get('time')).toMatchSnapshot(); + expect(statusHex.get('time')).toMatchSnapshot(); + }); + + it('should support multiple requests', async () => { + const status = await getStatus(['time', 'publicKey']); + expect(status.get('time')).toMatchSnapshot(); + expect(status.get('publicKey')).toMatchSnapshot(); + }); + + it('should support multiple requests with a failure', async () => { + // Deliberately requesting a bad value + const status = await getStatus([ + 'time', + // This arbitrary path should fail + { + key: 'asdf', + path: [utf8ToBytes('asdf')], + decodeStrategy: 'hex', + }, + ]); + expect(status.get('time')).toMatchSnapshot(); + // Expect null for a failed result + expect(status.get('asdf')).toBe(null); + // Expect undefined for unset value + expect(status.get('test123')).toBe(undefined); + }); +}); + +describe('lookupSubnetInfo', () => { + it('should return the node keys from a mainnet subnet certificate', async () => { + jest.setSystemTime(new Date(Date.parse('2025-11-20T00:07:23.446Z'))); + + const subnetInfo = lookupSubnetInfo(certificateBytes, testSubnetId); + expect(subnetInfo.subnetId).toBe(testSubnetId.toText()); + expect(subnetInfo.nodeKeys).toMatchSnapshot(); + expect(subnetInfo.publicKey).toMatchSnapshot(); + }); +}); + +describe('decodeCanisterRanges', () => { + it('should decode canister ranges correctly', () => { + const { mainnetApplication } = goldenCertificates; + const certificate = decode(hexToBytes(mainnetApplication)); + const subnetId = Principal.fromText( + 'uzr34-akd3s-xrdag-3ql62-ocgoh-ld2ao-tamcv-54e7j-krwgb-2gm4z-oqe', + ); + + // Look up the canister ranges from the certificate tree + const rangesResult = Cert.lookup_subtree( + ['canister_ranges', subnetId.toUint8Array()], + certificate.tree, + ); + + if (rangesResult.status !== Cert.LookupSubtreeStatus.Found) { + throw new Error('Could not find canister ranges'); + } + + const rangesValue = Cert.lookupCanisterRanges({ + subnetId, + tree: certificate.tree, + canisterId: Principal.fromText('rdmx6-jaaaa-aaaaa-aaadq-cai'), + }); + const ranges = decodeCanisterRanges(rangesValue); + expect(ranges.length).toBeGreaterThan(0); + // Each range should be a tuple of [start, end] principals + ranges.forEach(([start, end]) => { + expect(start).toBeInstanceOf(Principal); + expect(end).toBeInstanceOf(Principal); + }); + expect(ranges).toMatchSnapshot(); + }); +}); + +describe('encodePath', () => { + const subnetId = Principal.fromText( + 'o3ow2-2ipam-6fcjo-3j5vt-fzbge-2g7my-5fz2m-p4o2t-dwlc4-gt2q7-5ae', + ); + const subnetUint8Array = subnetId.toUint8Array(); + + it('should encode time path', () => { + const encoded = encodePath('time', subnetId); + expect(encoded).toEqual([utf8ToBytes('time')]); + }); + + it('should encode canisterRanges path', () => { + const encoded = encodePath('canisterRanges', subnetId); + expect(encoded).toEqual([utf8ToBytes('canister_ranges'), subnetUint8Array]); + }); + + it('should encode publicKey path', () => { + const encoded = encodePath('publicKey', subnetId); + expect(encoded).toEqual([utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('public_key')]); + }); + + it('should encode nodeKeys path', () => { + const encoded = encodePath('nodeKeys', subnetId); + expect(encoded).toEqual([utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('node')]); + }); + + it('should encode custom path with array', () => { + const customPath = { + key: 'custom', + path: [utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('custom_field')], + decodeStrategy: 'raw' as const, + }; + const encoded = encodePath(customPath, subnetId); + expect(encoded).toEqual([utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('custom_field')]); + }); + + it('should encode custom path with string', () => { + const customPath = { + key: 'custom', + path: 'custom_field', + decodeStrategy: 'raw' as const, + }; + const encoded = encodePath(customPath, subnetId); + expect(encoded).toEqual([utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('custom_field')]); + }); +}); + +describe('IC_ROOT_SUBNET_ID', () => { + it('should be the correct root subnet ID', () => { + expect(IC_ROOT_SUBNET_ID.toText()).toBe( + 'tdb26-jop6k-aogll-7ltgs-eruif-6kk7m-qpktf-gdiqx-mxtrf-vb5e6-eqe', + ); + }); +}); diff --git a/packages/core/src/agent/subnetStatus/index.ts b/packages/core/src/agent/subnetStatus/index.ts new file mode 100644 index 000000000..a143c0eb6 --- /dev/null +++ b/packages/core/src/agent/subnetStatus/index.ts @@ -0,0 +1,270 @@ +import { Principal } from '#principal'; +import { + CertificateVerificationErrorCode, + MissingRootKeyErrorCode, + ExternalError, + AgentError, + UnknownError, + UnexpectedErrorCode, + CertificateTimeErrorCode, + ProtocolError, + LookupErrorCode, +} from '../errors.ts'; +import { HttpAgent } from '../agent/http/index.ts'; +import { + type Cert, + type CanisterRanges, + Certificate, + lookupResultToBuffer, + decodeCanisterRanges, + lookup_path, + LookupPathStatus, +} from '../certificate.ts'; +import * as cbor from '../cbor.ts'; +import { decodeTime } from '../utils/leb.ts'; +import { utf8ToBytes } from '@noble/hashes/utils'; +import { + type BaseStatus, + type BaseSubnetStatus, + type SubnetNodeKeys, + CustomPath, + decodeValue, + isCustomPath, + lookupNodeKeysFromCertificate, + IC_ROOT_SUBNET_ID, +} from '../utils/readState.ts'; + +// Re-export shared types and functions +export { type DecodeStrategy, CustomPath, IC_ROOT_SUBNET_ID } from '../utils/readState.ts'; + +export type SubnetStatus = BaseSubnetStatus & { + /** + * The public key of the subnet + */ + publicKey: Uint8Array; +}; + +export type Status = BaseStatus | SubnetNodeKeys | CanisterRanges; + +/** + * Pre-configured fields for subnet status paths + */ +export type Path = 'time' | 'canisterRanges' | 'publicKey' | 'nodeKeys' | CustomPath; + +export type StatusMap = Map; + +export type SubnetStatusOptions = { + /** + * The subnet ID to query. Use {@link IC_ROOT_SUBNET_ID} for the IC mainnet root subnet. + * You can use {@link HttpAgent.getSubnetIdFromCanister} to get a subnet ID from a canister. + */ + subnetId: Principal; + /** + * The agent to use to make the subnet request. + */ + agent: HttpAgent; + /** + * The paths to request. + * @default [] + */ + paths?: Path[] | Set; + /** + * Whether to disable the certificate freshness checks. + * @default false + */ + disableCertificateTimeVerification?: boolean; +}; + +/** + * Requests information from a subnet's `read_state` endpoint. + * Can be used to request information about the subnet's time, canister ranges, public key, node keys, and metrics. + * @param {SubnetStatusOptions} options The configuration for the subnet status request. + * @see {@link SubnetStatusOptions} for detailed options. + * @returns {Promise} A map populated with data from the requested paths. Each path is a key in the map, and the value is the data obtained from the certificate for that path. + * @example + * const status = await subnetStatus.request({ + * subnetId: IC_ROOT_SUBNET_ID, + * paths: ['time', 'nodeKeys'], + * agent, + * }); + * + * const time = status.get('time'); + * const nodeKeys = status.get('nodeKeys'); + */ +export async function request(options: SubnetStatusOptions): Promise { + const { agent, paths, disableCertificateTimeVerification = false } = options; + const subnetId = Principal.from(options.subnetId); + + const uniquePaths = [...new Set(paths)]; + const status = new Map(); + + const promises = uniquePaths.map((path, index) => { + const encodedPath = encodePath(path, subnetId); + + return (async () => { + try { + if (agent.rootKey === null) { + throw ExternalError.fromCode(new MissingRootKeyErrorCode()); + } + + const rootKey = agent.rootKey; + + const response = await agent.readSubnetState(subnetId, { + paths: [encodedPath], + }); + + const certificate = await Certificate.create({ + certificate: response.certificate, + rootKey, + principal: { subnetId }, + disableTimeVerification: disableCertificateTimeVerification, + agent, + }); + + const lookup = (cert: Certificate, lookupPath: Path) => { + if (lookupPath === 'nodeKeys') { + // For node keys, we need to parse the certificate directly + const data = lookupNodeKeysFromCertificate(cert.cert, subnetId); + return { + path: lookupPath, + data, + }; + } else { + return { + path: lookupPath, + data: lookupResultToBuffer(cert.lookup_path(encodedPath)), + }; + } + }; + + const { path, data } = lookup(certificate, uniquePaths[index]); + if (!data) { + console.warn(`Expected to find result for path ${path}, but instead found nothing.`); + if (typeof path === 'string') { + status.set(path, null); + } else { + status.set(path.key, null); + } + } else { + switch (path) { + case 'time': { + status.set(path, decodeTime(data)); + break; + } + case 'canisterRanges': { + status.set(path, decodeCanisterRanges(data)); + break; + } + case 'publicKey': { + status.set(path, data); + break; + } + case 'nodeKeys': { + status.set(path, data); + break; + } + default: { + // Check for CustomPath signature + if (isCustomPath(path)) { + status.set(path.key, decodeValue(data, path.decodeStrategy)); + } + } + } + } + } catch (error) { + // Throw on certificate errors + if ( + error instanceof AgentError && + (error.hasCode(CertificateVerificationErrorCode) || + error.hasCode(CertificateTimeErrorCode)) + ) { + throw error; + } + if (isCustomPath(path)) { + status.set(path.key, null); + } else { + status.set(path, null); + } + console.group(); + console.warn(`Expected to find result for path ${path}, but instead found nothing.`); + console.warn(error); + console.groupEnd(); + } + })(); + }); + + // Fetch all values separately, as each option can fail + await Promise.all(promises); + + return status; +} + +/** + * Fetch subnet info including node keys from a certificate + * @param certificate the certificate bytes + * @param subnetId the subnet ID + * @returns SubnetStatus with subnet ID and node keys + */ +export function lookupSubnetInfo(certificate: Uint8Array, subnetId: Principal): SubnetStatus { + const cert = cbor.decode(certificate); + const nodeKeys = lookupNodeKeysFromCertificate(cert, subnetId); + const publicKey = lookupSubnetPublicKey(cert, subnetId); + + return { + subnetId: subnetId.toText(), + nodeKeys, + publicKey, + }; +} + +function lookupSubnetPublicKey(certificate: Cert, subnetId: Principal): Uint8Array { + const subnetLookupResult = lookup_path( + ['subnet', subnetId.toUint8Array(), 'public_key'], + certificate.tree, + ); + if (subnetLookupResult.status !== LookupPathStatus.Found) { + throw ProtocolError.fromCode( + new LookupErrorCode('Public key not found', subnetLookupResult.status), + ); + } + return subnetLookupResult.value; +} + +/** + * Encode a path for subnet state queries + * @param path the path to encode + * @param subnetId the subnet ID + * @returns the encoded path as an array of Uint8Arrays + */ +export function encodePath(path: Path, subnetId: Principal): Uint8Array[] { + const subnetUint8Array = subnetId.toUint8Array(); + switch (path) { + case 'time': + return [utf8ToBytes('time')]; + case 'canisterRanges': + return [utf8ToBytes('canister_ranges'), subnetUint8Array]; + case 'publicKey': + return [utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('public_key')]; + case 'nodeKeys': + return [utf8ToBytes('subnet'), subnetUint8Array, utf8ToBytes('node')]; + default: { + // Check for CustomPath signature + if (isCustomPath(path)) { + if (typeof path['path'] === 'string' || path['path'] instanceof Uint8Array) { + // For string paths, treat as a subnet path segment + const encoded = + typeof path['path'] === 'string' ? utf8ToBytes(path['path']) : path['path']; + return [utf8ToBytes('subnet'), subnetUint8Array, encoded]; + } else { + // For non-simple paths, return the provided custom path + return path['path']; + } + } + } + } + throw UnknownError.fromCode( + new UnexpectedErrorCode( + `Error while encoding your path for subnet status. Please ensure that your path ${path} was formatted correctly.`, + ), + ); +} diff --git a/packages/core/src/agent/utils/readState.ts b/packages/core/src/agent/utils/readState.ts index f3de0e3d3..87db176d8 100644 --- a/packages/core/src/agent/utils/readState.ts +++ b/packages/core/src/agent/utils/readState.ts @@ -27,46 +27,31 @@ export const IC_ROOT_SUBNET_ID = Principal.fromText( 'tdb26-jop6k-aogll-7ltgs-eruif-6kk7m-qpktf-gdiqx-mxtrf-vb5e6-eqe', ); +export type SubnetNodeKeys = Map; + /** * Represents the useful information about a subnet - * @param {string} subnetId the principal id of the canister's subnet - * @param {string[]} nodeKeys the keys of the individual nodes in the subnet */ -export type SubnetStatus = { - // Principal as a string +export type BaseSubnetStatus = { + /** + * The subnet ID + */ subnetId: string; - nodeKeys: Map; - metrics?: SubnetMetrics; -}; - -/** - * Subnet metrics data structure - */ -export type SubnetMetrics = { - num_canisters: bigint; - canister_state_bytes: bigint; - consumed_cycles_total: { - current: bigint; - deleted: bigint; - }; - update_transactions_total: bigint; + /** + * The node keys of the subnet + */ + nodeKeys: SubnetNodeKeys; + /** + * Not supported + */ + metrics?: never; }; /** - * Types of an entry on the status map. + * Base types of an entry on the status map. * An entry of null indicates that the request failed, due to lack of permissions or the result being missing. */ -export type Status = - | string - | Uint8Array - | Date - | Uint8Array[] - | Principal[] - | SubnetStatus - | bigint - | null; - -export type StatusMap

= Map

; +export type BaseStatus = string | Uint8Array | Date | Uint8Array[] | Principal[] | bigint | null; /** * Decode strategy for custom paths @@ -96,7 +81,7 @@ export class CustomPath implements CustomPath { * @param strategy the decode strategy to use * @returns the decoded value */ -export function decodeValue(data: Uint8Array, strategy: DecodeStrategy): Status { +export function decodeValue(data: Uint8Array, strategy: DecodeStrategy): BaseStatus { switch (strategy) { case 'raw': return data; @@ -156,7 +141,7 @@ export function isCustomPath(path: T): path is T & { key: string; path: unkno export function lookupNodeKeysFromCertificate( certificate: Cert, subnetId: Principal, -): Map { +): SubnetNodeKeys { const subnetLookupResult = lookup_subtree( ['subnet', subnetId.toUint8Array(), 'node'], certificate.tree, From 2dade095298900b5003260de346d6ec5598056cb Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 11:20:37 +0100 Subject: [PATCH 09/17] chore: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb79fdd06..2b7130a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - feat(auth-client)!: `@dfinity/auth-client` has been deprecated. Migrate to [`@icp-sdk/auth`](https://js.icp.build/auth/latest/upgrading/v4) - feat(agent): lookup canister ranges using the `/canister_ranges//` certificate path - feat(agent): introduce the `getSubnetIdFromCanister` and `readSubnetState` methods in the `HttpAgent` class +- feat(agent): introduce the `SubnetStatus` utility namespace to request subnet information directly from the IC public API - feat(agent): export `IC_STATE_ROOT_DOMAIN_SEPARATOR` constant - refactor(agent): only declare IC URLs once in the `HttpAgent` class - refactor(agent): split inner logic of `check_canister_ranges` into functions From 4e8b293d892061f72229492dd1cb7f6806d57311 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 12:01:26 +0100 Subject: [PATCH 10/17] docs: add deprecation warning --- packages/core/src/agent/canisterStatus/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/agent/canisterStatus/index.ts b/packages/core/src/agent/canisterStatus/index.ts index bb70d4773..43a2090d3 100644 --- a/packages/core/src/agent/canisterStatus/index.ts +++ b/packages/core/src/agent/canisterStatus/index.ts @@ -70,6 +70,7 @@ export type CanisterStatusOptions = { /** * Requests information from a canister's `read_state` endpoint. * Can be used to request information about the canister's controllers, time, module hash, candid interface, and more. + * @deprecated Requesting the `subnet` path from the canister status might be deprecated in the future. Use {@link https://js.icp.build/core/latest/libs/agent/api/namespaces/subnetstatus/functions/request | SubnetStatus.request} to fetch subnet information instead. * @param {CanisterStatusOptions} options The configuration for the canister status request. * @see {@link CanisterStatusOptions} for detailed options. * @returns {Promise} A map populated with data from the requested paths. Each path is a key in the map, and the value is the data obtained from the certificate for that path. From ce9c2a6e01772fd1bd485a3796c1252f614c8906 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 12:03:37 +0100 Subject: [PATCH 11/17] chore: update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7130a5c..0f8e3c825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - feat(assets)!: drops support for cjs for the `@dfinity/assets` package - feat(auth-client)!: `@dfinity/auth-client` has been deprecated. Migrate to [`@icp-sdk/auth`](https://js.icp.build/auth/latest/upgrading/v4) - feat(agent): lookup canister ranges using the `/canister_ranges//` certificate path +- feat(agent): introduce the `lookupCanisterRanges`, `lookupCanisterRangesFallback`, and `decodeCanisterRanges` utility functions to lookup canister ranges from certificate trees - feat(agent): introduce the `getSubnetIdFromCanister` and `readSubnetState` methods in the `HttpAgent` class - feat(agent): introduce the `SubnetStatus` utility namespace to request subnet information directly from the IC public API - feat(agent): export `IC_STATE_ROOT_DOMAIN_SEPARATOR` constant From 95c67e5dfcb7631ae64e49399b949b514dc015c6 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 12:07:33 +0100 Subject: [PATCH 12/17] docs: fix warning --- packages/core/src/agent/canisterStatus/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agent/canisterStatus/index.ts b/packages/core/src/agent/canisterStatus/index.ts index 43a2090d3..62a3ceba0 100644 --- a/packages/core/src/agent/canisterStatus/index.ts +++ b/packages/core/src/agent/canisterStatus/index.ts @@ -70,7 +70,10 @@ export type CanisterStatusOptions = { /** * Requests information from a canister's `read_state` endpoint. * Can be used to request information about the canister's controllers, time, module hash, candid interface, and more. - * @deprecated Requesting the `subnet` path from the canister status might be deprecated in the future. Use {@link https://js.icp.build/core/latest/libs/agent/api/namespaces/subnetstatus/functions/request | SubnetStatus.request} to fetch subnet information instead. + * + * > [!WARNING] + * > Requesting the `subnet` path from the canister status might be deprecated in the future. + * > Use {@link https://js.icp.build/core/latest/libs/agent/api/namespaces/subnetstatus/functions/request | SubnetStatus.request} to fetch subnet information instead. * @param {CanisterStatusOptions} options The configuration for the canister status request. * @see {@link CanisterStatusOptions} for detailed options. * @returns {Promise} A map populated with data from the requested paths. Each path is a key in the map, and the value is the data obtained from the certificate for that path. From 33021fa787bb11c0032490c43765ec1a02532717 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 12:14:53 +0100 Subject: [PATCH 13/17] fix: use subnet status to fetch node keys --- packages/core/src/agent/agent/http/index.ts | 60 ++++++++++++--------- 1 file changed, 35 insertions(+), 25 deletions(-) diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index 0af8ca007..24de82bc3 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -55,7 +55,9 @@ import { type ReadStateRequest, type HttpHeaderField, } from './types.ts'; -import { type SubnetStatus, request as canisterStatusRequest } from '../../canisterStatus/index.ts'; +import { request as canisterStatusRequest } from '../../canisterStatus/index.ts'; +import { request as subnetStatusRequest } from '../../subnetStatus/index.ts'; +import { type SubnetNodeKeys } from '../../utils/readState.ts'; import { Certificate, type HashTree, lookup_path, LookupPathStatus } from '../../certificate.ts'; import { ed25519 } from '@noble/curves/ed25519'; import { ExpirableMap } from '../../utils/expirableMap.ts'; @@ -315,7 +317,7 @@ export class HttpAgent implements Agent { #queryPipeline: HttpAgentRequestTransformFn[] = []; #updatePipeline: HttpAgentRequestTransformFn[] = []; - #subnetKeys: ExpirableMap = new ExpirableMap({ + #subnetKeys: ExpirableMap = new ExpirableMap({ expirationTime: 5 * MINUTE_TO_MSECS, }); #verifyQuerySignatures = true; @@ -933,17 +935,17 @@ export class HttpAgent implements Agent { }; }; - const getSubnetStatus = async (): Promise => { - const cachedSubnetStatus = this.#subnetKeys.get(ecid.toString()); - if (cachedSubnetStatus) { - return cachedSubnetStatus; + const getSubnetNodeKeys = async (): Promise => { + const cachedSubnetNodeKeys = this.#subnetKeys.get(ecid.toString()); + if (cachedSubnetNodeKeys) { + return cachedSubnetNodeKeys; } await this.fetchSubnetKeys(ecid.toString()); - const subnetStatus = this.#subnetKeys.get(ecid.toString()); - if (!subnetStatus) { + const subnetNodeKeys = this.#subnetKeys.get(ecid.toString()); + if (!subnetNodeKeys) { throw TrustError.fromCode(new MissingSignatureErrorCode()); } - return subnetStatus; + return subnetNodeKeys; }; try { @@ -953,16 +955,19 @@ export class HttpAgent implements Agent { } // Make query and fetch subnet keys in parallel - const [queryWithDetails, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]); + const [queryWithDetails, subnetNodeKeys] = await Promise.all([ + makeQuery(), + getSubnetNodeKeys(), + ]); try { - return this.#verifyQueryResponse(queryWithDetails, subnetStatus); + return this.#verifyQueryResponse(queryWithDetails, subnetNodeKeys); } catch { // In case the node signatures have changed, refresh the subnet keys and try again this.log.warn('Query response verification failed. Retrying with fresh subnet keys.'); this.#subnetKeys.delete(ecid.toString()); - const updatedSubnetStatus = await getSubnetStatus(); - return this.#verifyQueryResponse(queryWithDetails, updatedSubnetStatus); + const updatedSubnetNodeKeys = await getSubnetNodeKeys(); + return this.#verifyQueryResponse(queryWithDetails, updatedSubnetNodeKeys); } } catch (error) { let queryError: AgentError; @@ -986,12 +991,12 @@ export class HttpAgent implements Agent { /** * See https://internetcomputer.org/docs/current/references/ic-interface-spec/#http-query for details on validation * @param queryResponse - The response from the query - * @param subnetStatus - The subnet status, including all node keys + * @param subnetNodeKeys - The subnet node keys * @returns ApiQueryResponse */ #verifyQueryResponse = ( queryResponse: ApiQueryResponse, - subnetStatus: SubnetStatus, + subnetNodeKeys: SubnetNodeKeys, ): ApiQueryResponse => { if (this.#verifyQuerySignatures === false) { // This should not be called if the user has disabled verification @@ -1030,7 +1035,7 @@ export class HttpAgent implements Agent { const separatorWithHash = concatBytes(IC_RESPONSE_DOMAIN_SEPARATOR, hash); // FIX: check for match without verifying N times - const pubKey = subnetStatus.nodeKeys.get(nodeId); + const pubKey = subnetNodeKeys.get(nodeId); if (!pubKey) { throw ProtocolError.fromCode(new MalformedPublicKeyErrorCode()); } @@ -1362,21 +1367,25 @@ export class HttpAgent implements Agent { this.#identity = Promise.resolve(identity); } - public async fetchSubnetKeys(canisterId: Principal | string) { + public async fetchSubnetKeys( + canisterId: Principal | string, + ): Promise { const effectiveCanisterId: Principal = Principal.from(canisterId); await this.#asyncGuard(effectiveCanisterId); - const response = await canisterStatusRequest({ - canisterId: effectiveCanisterId, - paths: ['subnet'], + + const subnetId = await this.getSubnetIdFromCanister(effectiveCanisterId); + + const response = await subnetStatusRequest({ + subnetId, + paths: ['nodeKeys'], agent: this, }); - const subnetResponse = response.get('subnet'); - if (subnetResponse && typeof subnetResponse === 'object' && 'nodeKeys' in subnetResponse) { - this.#subnetKeys.set(effectiveCanisterId.toText(), subnetResponse as SubnetStatus); - return subnetResponse as SubnetStatus; + const nodeKeys = response.get('nodeKeys') as SubnetNodeKeys | undefined; + if (nodeKeys) { + this.#subnetKeys.set(effectiveCanisterId.toText(), nodeKeys); + return nodeKeys; } - // If the subnet status is not returned, return undefined return undefined; } @@ -1398,6 +1407,7 @@ export class HttpAgent implements Agent { rootKey: this.rootKey!, principal: { canisterId: effectiveCanisterId }, agent: this, + disableTimeVerification: true, // avoid extra calls to syncTime }); if (!canisterCertificate.cert.delegation) { From 693958d655ec7b19aa4e224aabc100cf5af7195d Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 15:51:20 +0100 Subject: [PATCH 14/17] fix: do not verify canister ranges twice --- .../core/src/agent/canisterStatus/index.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/packages/core/src/agent/canisterStatus/index.ts b/packages/core/src/agent/canisterStatus/index.ts index 62a3ceba0..50792e6c3 100644 --- a/packages/core/src/agent/canisterStatus/index.ts +++ b/packages/core/src/agent/canisterStatus/index.ts @@ -2,9 +2,7 @@ import { Principal } from '#principal'; import { CertificateVerificationErrorCode, MissingRootKeyErrorCode, - CertificateNotAuthorizedErrorCode, ExternalError, - TrustError, AgentError, UnknownError, UnexpectedErrorCode, @@ -12,12 +10,7 @@ import { CertificateTimeErrorCode, } from '../errors.ts'; import { HttpAgent } from '../agent/http/index.ts'; -import { - type Cert, - Certificate, - check_canister_ranges, - lookupResultToBuffer, -} from '../certificate.ts'; +import { type Cert, Certificate, lookupResultToBuffer } from '../certificate.ts'; import * as cbor from '../cbor.ts'; import { decodeTime } from '../utils/leb.ts'; import { utf8ToBytes, bytesToHex } from '@noble/hashes/utils'; @@ -198,6 +191,14 @@ export const request = async (options: CanisterStatusOptions): Promise(certificate); - const { delegation, tree } = cert; + const { delegation } = cert; let subnetId: Principal; if (delegation && delegation.subnet_id) { subnetId = Principal.fromUint8Array(new Uint8Array(delegation.subnet_id)); @@ -219,11 +220,6 @@ export const fetchNodeKeys = ( subnetId = IC_ROOT_SUBNET_ID; } - const canisterInRange = check_canister_ranges({ canisterId, subnetId, tree }); - if (!canisterInRange) { - throw TrustError.fromCode(new CertificateNotAuthorizedErrorCode(canisterId, subnetId)); - } - const nodeKeys = lookupNodeKeysFromCertificate(cert, subnetId); return { From b79b0f50e10be9cc7b0eb07869c566d73e72cf7d Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 16:48:31 +0100 Subject: [PATCH 15/17] test: update tests (wip) --- .../basic/__snapshots__/syncTime.test.ts.snap | 27 ++- e2e/node/basic/queryExpiry.test.ts | 94 +++++++-- e2e/node/basic/syncTime.test.ts | 33 +++- e2e/node/utils/mock-replica.ts | 182 ++++++++++++------ e2e/node/utils/tree.ts | 14 +- 5 files changed, 262 insertions(+), 88 deletions(-) diff --git a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap index b085cbf0f..6f3fb12bf 100644 --- a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap +++ b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap @@ -46,6 +46,29 @@ exports[`syncTime > on async creation > should sync time on when enabled > V4 re } `; +exports[`syncTime > on async creation > should sync time on when enabled > V4 read state body two 1`] = ` +{ + "content": { + "ingress_expiry": 1746103140000000000n, + "paths": [ + [ + { + "data": [ + 116, + 105, + 109, + 101, + ], + "type": "Buffer", + }, + ], + ], + "request_type": "read_state", + "sender": Any, + }, +} +`; + exports[`syncTime > on error > should not sync time by default > V4 call body 1`] = ` { "content": { @@ -93,7 +116,7 @@ exports[`syncTime > on error > should not sync time by default > V4 call body 1` } `; -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body one 1`] = ` +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V2 read state body one 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -116,7 +139,7 @@ exports[`syncTime > on error > should sync time when the local time does not mat } `; -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V2 read state body one 1`] = ` +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body one 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, diff --git a/e2e/node/basic/queryExpiry.test.ts b/e2e/node/basic/queryExpiry.test.ts index 2df3967fb..3dd25f629 100644 --- a/e2e/node/basic/queryExpiry.test.ts +++ b/e2e/node/basic/queryExpiry.test.ts @@ -3,6 +3,7 @@ import { MockReplica, mockSyncTimeResponse, prepareV3QueryResponse, + prepareV3ReadStateResponse, prepareV3ReadStateSubnetResponse, } from '../utils/mock-replica.ts'; import { IDL } from '@icp-sdk/core/candid'; @@ -65,13 +66,26 @@ describe('queryExpiry', () => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + res.status(200).send(readStateResponseBody); + }); + + // Get node key from subnet + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + keyPair: subnetKeyPair, + date: now, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); }); const actorResponse = await actor[greetMethodName](greetReq); @@ -79,6 +93,7 @@ describe('queryExpiry', () => { expect(actorResponse).toEqual(greetRes); expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); @@ -112,17 +127,19 @@ describe('queryExpiry', () => { mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ + + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: futureDate, // make sure the certificate is fresh for this call }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + res.status(200).send(readStateResponseBody); }); - expect.assertions(4); + expect.assertions(5); try { await actor[greetMethodName](greetReq); @@ -131,6 +148,8 @@ describe('queryExpiry', () => { } expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + // Early promise failure stops these requests, even though the agent makes them + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); }); it('should retry and fail if the timestamp is outside the max ingress expiry (with retry)', async () => { @@ -162,16 +181,29 @@ describe('queryExpiry', () => { // advance to go over the max ingress expiry (5 minutes) advanceTimeByMilliseconds(timeDiffMsecs); - const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: now, }); - // fetch subnet keys, fails for certificate freshness checks mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + res.status(200).send(readStateResponseBody); }); + + // Get node key from subnet, fails for certificate freshness checks + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + keyPair: subnetKeyPair, + date: futureDate, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); + }); + // sync time, keeping a date in the future to make sure the agent still has outdated time await mockSyncTimeResponse({ mockReplica, @@ -180,7 +212,7 @@ describe('queryExpiry', () => { canisterId, }); - expect.assertions(5); + expect.assertions(6); try { await actor[greetMethodName](greetReq); @@ -193,6 +225,7 @@ describe('queryExpiry', () => { expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); }); it('should not retry if the timestamp is outside the max ingress expiry (verifyQuerySignatures=false)', async () => { @@ -222,10 +255,6 @@ describe('queryExpiry', () => { // advance to go over the max ingress expiry (5 minutes) advanceTimeByMilliseconds(6 * MINUTE_TO_MSECS); - mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(500).send('Should not be called'); - }); - const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); @@ -271,14 +300,27 @@ describe('queryExpiry', () => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + res.status(200).send(readStateResponseBody); + }); + + // Get node key from subnet + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + keyPair: subnetKeyPair, + date: replicaDate, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); }); const actorResponse = await actor[greetMethodName](greetReq); @@ -286,6 +328,7 @@ describe('queryExpiry', () => { expect(actorResponse).toEqual(greetRes); expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); @@ -331,7 +374,7 @@ describe('queryExpiry', () => { expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); - it('should succeed if clock is drifted by more than 5 minutes in the future without syncing it', async () => { + it.only('should succeed if clock is drifted by more than 5 minutes in the future without syncing it', async () => { const timeDiffMsecs = 6 * MINUTE_TO_MSECS; const replicaDate = new Date(now.getTime() + timeDiffMsecs); @@ -359,15 +402,29 @@ describe('queryExpiry', () => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + res.status(200).send(readStateResponseBody); }); + + // Get node key from subnet + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + keyPair: subnetKeyPair, + date: replicaDate, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); + }); + await mockSyncTimeResponse({ mockReplica, keyPair: subnetKeyPair, @@ -380,6 +437,7 @@ describe('queryExpiry', () => { expect(actorResponse).toEqual(greetRes); expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); diff --git a/e2e/node/basic/syncTime.test.ts b/e2e/node/basic/syncTime.test.ts index 8997894b3..55c7d61f4 100644 --- a/e2e/node/basic/syncTime.test.ts +++ b/e2e/node/basic/syncTime.test.ts @@ -19,7 +19,7 @@ import { createActor } from '../canisters/counter.ts'; import { MockReplica, mockSyncTimeResponse, - prepareV3ReadStateTimeResponse, + prepareV3ReadStateResponse, prepareV4Response, } from '../utils/mock-replica.ts'; import { randomIdentity, randomKeyPair } from '../utils/identity.ts'; @@ -31,9 +31,15 @@ const INVALID_EXPIRY_ERROR = describe('syncTime', () => { const date = new Date('2025-05-01T12:34:56.789Z'); const canisterId = Principal.fromText('uxrrr-q7777-77774-qaaaq-cai'); + const canisterRanges: Array<[Uint8Array, Uint8Array]> = [ + [canisterId.toUint8Array(), canisterId.toUint8Array()], + ]; const nonce = makeNonce(); const ICP_LEDGER = 'ryjl3-tyaaa-aaaaa-aaaba-cai'; + const icpLedgerCanisterRanges: Array<[Uint8Array, Uint8Array]> = [ + [Principal.fromText(ICP_LEDGER).toUint8Array(), Principal.fromText(ICP_LEDGER).toUint8Array()], + ]; const greetMethodName = 'greet'; const greetReq = 'world'; @@ -43,6 +49,7 @@ describe('syncTime', () => { const keyPair = randomKeyPair(); const identity = randomIdentity(); + const nodeIdentity = randomIdentity(); const anonIdentity = new AnonymousIdentity(); let mockReplica: MockReplica; @@ -222,8 +229,10 @@ describe('syncTime', () => { describe('on async creation', () => { it('should sync time on when enabled', async () => { - const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateResponse({ keyPair, + nodeIdentity, + canisterRanges: icpLedgerCanisterRanges, }); mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); @@ -267,8 +276,10 @@ describe('syncTime', () => { }); it('should not sync time by default', async () => { - const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateResponse({ keyPair, + nodeIdentity, + canisterRanges: icpLedgerCanisterRanges, }); mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); @@ -286,8 +297,10 @@ describe('syncTime', () => { }); it('should not sync time when explicitly disabled', async () => { - const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateResponse({ keyPair, + nodeIdentity, + canisterRanges: icpLedgerCanisterRanges, }); mockReplica.setV3ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { res.status(200).send(readStateResponse); @@ -315,8 +328,10 @@ describe('syncTime', () => { }); const actor = await createActor(canisterId, { agent }); - const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateResponse({ keyPair, + nodeIdentity, + canisterRanges, }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); @@ -393,8 +408,10 @@ describe('syncTime', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateResponse({ keyPair, + nodeIdentity, + canisterRanges, }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); @@ -444,8 +461,10 @@ describe('syncTime', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody: readStateResponse } = await prepareV3ReadStateTimeResponse({ + const { responseBody: readStateResponse } = await prepareV3ReadStateResponse({ keyPair, + nodeIdentity, + canisterRanges, }); mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(readStateResponse); diff --git a/e2e/node/utils/mock-replica.ts b/e2e/node/utils/mock-replica.ts index d4e2df02c..6a2a24613 100644 --- a/e2e/node/utils/mock-replica.ts +++ b/e2e/node/utils/mock-replica.ts @@ -26,8 +26,8 @@ import { import { Principal } from '@icp-sdk/core/principal'; import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; import { Mock, vi } from 'vitest'; -import { createReplyTree, createSubnetTree, createTimeTree } from './tree.ts'; -import { randomKeyPair, signBls, KeyPair } from './identity.ts'; +import { createReplyTree, createSubnetTree } from './tree.ts'; +import { randomKeyPair, signBls, KeyPair, randomIdentity } from './identity.ts'; import { concatBytes, toBytes } from '@noble/hashes/utils'; const NANOSECONDS_TO_MSECS = 1_000_000; @@ -35,10 +35,13 @@ const NANOSECONDS_TO_MSECS = 1_000_000; export enum MockReplicaSpyType { CallV4 = 'CallV4', ReadStateV3 = 'ReadStateV3', + ReadSubnetStateV3 = 'ReadSubnetStateV3', QueryV3 = 'QueryV3', } -export type MockReplicaRequest = Request<{ canisterId: string }, Uint8Array, Uint8Array>; +type MockReplicaRequestParams = { canisterId: string } | { subnetId: string }; + +export type MockReplicaRequest = Request; export type MockReplicaResponse = Response; export type MockReplicaSpyImpl = (req: MockReplicaRequest, res: MockReplicaResponse) => void; @@ -47,15 +50,16 @@ export type MockReplicaSpy = Mock; export interface MockReplicaSpies { [MockReplicaSpyType.CallV4]?: MockReplicaSpy; [MockReplicaSpyType.ReadStateV3]?: MockReplicaSpy; + [MockReplicaSpyType.ReadSubnetStateV3]?: MockReplicaSpy; [MockReplicaSpyType.QueryV3]?: MockReplicaSpy; } -function fallbackSpyImpl(spyType: MockReplicaSpyType, canisterId: string): MockReplicaSpyImpl { +function fallbackSpyImpl(spyType: MockReplicaSpyType, principal: string): MockReplicaSpyImpl { return (req, res) => { res .status(500) .send( - `No implementation defined for ${spyType} spy on canisterId: ${canisterId}. Requested path: ${req.path}`, + `No implementation defined for ${spyType} spy on principal: ${principal}. Requested path: ${req.path}`, ); }; } @@ -76,6 +80,10 @@ export class MockReplica { '/api/v3/canister/:canisterId/read_state', this.#createEndpointSpy(MockReplicaSpyType.ReadStateV3), ); + app.post( + '/api/v3/subnet/:subnetId/read_state', + this.#createEndpointSpy(MockReplicaSpyType.ReadSubnetStateV3), + ); app.post( '/api/v3/canister/:canisterId/query', this.#createEndpointSpy(MockReplicaSpyType.QueryV3), @@ -109,6 +117,10 @@ export class MockReplica { this.#setSpyImplOnce(canisterId, MockReplicaSpyType.ReadStateV3, impl); } + public setV3ReadSubnetStateSpyImplOnce(subnetId: string, impl: MockReplicaSpyImpl): void { + this.#setSpyImplOnce(subnetId, MockReplicaSpyType.ReadSubnetStateV3, impl); + } + public setV3QuerySpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { this.#setSpyImplOnce(canisterId, MockReplicaSpyType.QueryV3, impl); } @@ -121,6 +133,10 @@ export class MockReplica { return this.#getSpy(canisterId, MockReplicaSpyType.ReadStateV3); } + public getV3ReadSubnetStateSpy(subnetId: string): MockReplicaSpy { + return this.#getSpy(subnetId, MockReplicaSpyType.ReadSubnetStateV3); + } + public getV3QuerySpy(canisterId: string): MockReplicaSpy { return this.#getSpy(canisterId, MockReplicaSpyType.QueryV3); } @@ -137,6 +153,12 @@ export class MockReplica { return Cbor.decode>(req.body); } + public getV3ReadSubnetStateReq(subnetId: string, callNumber: number): UnSigned { + const [req] = this.#getCallParams(subnetId, callNumber, MockReplicaSpyType.ReadSubnetStateV3); + + return Cbor.decode>(req.body); + } + public getV3QueryReq(canisterId: string, callNumber: number): UnSigned { const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.QueryV3); @@ -145,74 +167,80 @@ export class MockReplica { #createEndpointSpy(spyType: MockReplicaSpyType): MockReplicaSpyImpl { return (req, res) => { - const { canisterId } = req.params; + let principal: string; + if ('canisterId' in req.params) { + principal = req.params.canisterId; + } else if ('subnetId' in req.params) { + principal = req.params.subnetId; + } else { + res.status(500).send('No canisterId or subnetId found in request.'); + return; + } - const canisterSpies = this.#listeners.get(canisterId); - if (!canisterSpies) { - res.status(500).send(`No listeners defined for canisterId: ${canisterId}.`); + const principalSpies = this.#listeners.get(principal); + if (!principalSpies) { + res.status(500).send(`No listeners defined for principal: ${principal}.`); return; } - const spy = canisterSpies[spyType]; + const spy = principalSpies[spyType]; if (!spy) { - res.status(500).send(`No ${spyType} spy defined for canisterId: ${canisterId}.`); + res.status(500).send(`No ${spyType} spy defined for principal: ${principal}.`); return; } // add fallback implementation to return 500 if the spy runs out of implementations - spy.mockImplementation(fallbackSpyImpl(spyType, canisterId)); + spy.mockImplementation(fallbackSpyImpl(spyType, principal)); spy(req, res); }; } - #setSpyImplOnce(canisterId: string, spyType: MockReplicaSpyType, impl: MockReplicaSpyImpl): void { - const map: MockReplicaSpies = this.#listeners.get(canisterId.toString()) ?? {}; + #setSpyImplOnce(principal: string, spyType: MockReplicaSpyType, impl: MockReplicaSpyImpl): void { + const map: MockReplicaSpies = this.#listeners.get(principal.toString()) ?? {}; const spy = map[spyType] ?? vi.fn(); spy.mockImplementationOnce(impl); map[spyType] = spy; - this.#listeners.set(canisterId.toString(), map); + this.#listeners.set(principal.toString(), map); } - #getSpy(canisterId: string, spyType: MockReplicaSpyType): MockReplicaSpy { - const canisterSpies = this.#listeners.get(canisterId); - if (!canisterSpies) { - throw new Error(`No listeners defined for canisterId: ${canisterId}.`); + #getSpy(principal: string, spyType: MockReplicaSpyType): MockReplicaSpy { + const principalSpies = this.#listeners.get(principal); + if (!principalSpies) { + throw new Error(`No listeners defined for principal: ${principal}.`); } - const spy = canisterSpies[spyType]; + const spy = principalSpies[spyType]; if (!spy) { - throw new Error(`No ${spyType} spy defined for canisterId: ${canisterId}.`); + throw new Error(`No ${spyType} spy defined for principal: ${principal}.`); } return spy; } #getCallParams( - canisterId: string, + principal: string, callNumber: number, spyType: MockReplicaSpyType, ): [MockReplicaRequest, MockReplicaResponse] { - const spy = this.#getSpy(canisterId, spyType); + const spy = this.#getSpy(principal, spyType); if (!spy.mock.calls.length) { - throw new Error(`No calls found for canisterId: ${canisterId}.`); + throw new Error(`No calls found for principal: ${principal}.`); } const callParams = spy.mock.calls[callNumber]; if (!callParams) { throw new Error( - `No call params found for canisterId: ${canisterId}, callNumber: ${callNumber}. Actual number of calls is ${spy.mock.calls.length}.`, + `No call params found for principal: ${principal}, callNumber: ${callNumber}. Actual number of calls is ${spy.mock.calls.length}.`, ); } if (!callParams[0]) { - throw new Error(`No request found for canisterId: ${canisterId}, callNumber: ${callNumber}.`); + throw new Error(`No request found for principal: ${principal}, callNumber: ${callNumber}.`); } if (!callParams[1]) { - throw new Error( - `No response found for canisterId: ${canisterId}, callNumber: ${callNumber}.`, - ); + throw new Error(`No response found for principal: ${principal}, callNumber: ${callNumber}.`); } return callParams; @@ -305,35 +333,52 @@ export async function prepareV4Response({ }; } -export interface V3ReadStateTimeOptions { - keyPair?: KeyPair; - date?: Date; -} - export interface V3ReadStateResponse { responseBody: Uint8Array; } +interface V3ReadStateOptions { + nodeIdentity: Ed25519KeyIdentity; + canisterRanges: Array<[Uint8Array, Uint8Array]>; + keyPair?: KeyPair; + date?: Date; +} + /** - * Prepares a version 2 read state time response. - * @param {V3ReadStateTimeOptions} options - The options for preparing the response. - * @param {Date} options.date - The date for the response. + * Prepares a version 3 read state subnet response. + * @param {V3ReadStateOptions} options - The options for preparing the response. + * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. + * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {KeyPair} options.keyPair - The key pair for signing. + * @param {Date} options.date - The date for the response. * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV3ReadStateTimeResponse({ - date, +export async function prepareV3ReadStateResponse({ + nodeIdentity, + canisterRanges, keyPair, -}: V3ReadStateTimeOptions): Promise { + date, +}: V3ReadStateOptions): Promise { keyPair = keyPair ?? randomKeyPair(); date = date ?? new Date(); - const tree = createTimeTree(date); + const subnetId = Principal.selfAuthenticating(keyPair.publicKeyDer).toUint8Array(); + + const tree = createSubnetTree({ + subnetId, + subnetPublicKey: keyPair.publicKeyDer, + nodeIdentity, + canisterRanges, + date, + }); const signature = await signTree(tree, keyPair); + // We pass the same key pair for signature, even though in reality the delegation would be signed by the root subnet + const delegation = await createDelegationCertificate(subnetId, keyPair, canisterRanges, date); const cert: Cert = { tree, signature, + delegation, }; const responseBody: ReadStateResponse = { certificate: Cbor.encode(cert), @@ -344,16 +389,9 @@ export async function prepareV3ReadStateTimeResponse({ }; } -interface V3ReadStateSubnetOptions { - nodeIdentity: Ed25519KeyIdentity; - canisterRanges: Array<[Uint8Array, Uint8Array]>; - keyPair?: KeyPair; - date?: Date; -} - /** - * Prepares a version 2 read state subnet response. - * @param {V3ReadStateSubnetOptions} options - The options for preparing the response. + * Prepares a version 3 read state subnet response. + * @param {V3ReadStateOptions} options - The options for preparing the response. * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {KeyPair} options.keyPair - The key pair for signing. @@ -365,14 +403,14 @@ export async function prepareV3ReadStateSubnetResponse({ canisterRanges, keyPair, date, -}: V3ReadStateSubnetOptions): Promise { +}: V3ReadStateOptions): Promise { keyPair = keyPair ?? randomKeyPair(); date = date ?? new Date(); - const subnetId = Principal.selfAuthenticating(keyPair.publicKeyDer).toUint8Array(); const tree = createSubnetTree({ subnetId, + subnetPublicKey: keyPair.publicKeyDer, nodeIdentity, canisterRanges, date, @@ -529,18 +567,48 @@ export async function mockSyncTimeResponse({ date, canisterId, }: MockSyncTimeResponseOptions) { - canisterId = Principal.from(canisterId).toText(); - const { responseBody: timeResponseBody } = await prepareV3ReadStateTimeResponse({ + canisterId = Principal.from(canisterId); + const { responseBody: timeResponseBody } = await prepareV3ReadStateResponse({ keyPair, date, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + nodeIdentity: randomIdentity(), }); - mockReplica.setV3ReadStateSpyImplOnce(canisterId, (_req, res) => { + const canisterIdString = canisterId.toString(); + mockReplica.setV3ReadStateSpyImplOnce(canisterIdString, (_req, res) => { res.status(200).send(timeResponseBody); }); - mockReplica.setV3ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterIdString, (_req, res) => { res.status(200).send(timeResponseBody); }); - mockReplica.setV3ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterIdString, (_req, res) => { res.status(200).send(timeResponseBody); }); } + +async function createDelegationCertificate( + subnetId: Uint8Array, + keyPair: KeyPair, + canisterRanges: Array<[Uint8Array, Uint8Array]>, + date?: Date, +): Promise> { + date = date ?? new Date(); + const tree = createSubnetTree({ + subnetId, + subnetPublicKey: keyPair.publicKeyDer, + nodeIdentity: randomIdentity(), + canisterRanges, + date, + }); + const signature = await signTree(tree, keyPair); + + const cert: Cert = { + tree, + signature, + }; + + return { + subnet_id: subnetId, + certificate: Cbor.encode(cert), + }; +} diff --git a/e2e/node/utils/tree.ts b/e2e/node/utils/tree.ts index 8a1ae1a55..f2772af19 100644 --- a/e2e/node/utils/tree.ts +++ b/e2e/node/utils/tree.ts @@ -116,6 +116,7 @@ export function createTimeTree(date: Date): HashTree { interface SubnetTreeOptions { subnetId: Uint8Array; + subnetPublicKey: Uint8Array; nodeIdentity: Ed25519KeyIdentity; canisterRanges: Array<[Uint8Array, Uint8Array]>; date: Date; @@ -125,6 +126,7 @@ interface SubnetTreeOptions { * Creates a subnet hash tree. * @param {SubnetTreeOptions} options - The options for the subnet tree. * @param {Uint8Array} options.subnetId - The ID of the subnet. + * @param {Uint8Array} options.subnetPublicKey - The DER-encoded public key of the subnet. * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {Date} options.date - The timestamp for the tree. @@ -132,6 +134,7 @@ interface SubnetTreeOptions { */ export function createSubnetTree({ subnetId, + subnetPublicKey, nodeIdentity, canisterRanges, date, @@ -141,12 +144,15 @@ export function createSubnetTree({ labeled('subnet', labeled(subnetId, fork( - labeled('canister_ranges', leaf(Cbor.encode(canisterRanges))), - labeled('node', - labeled(nodeIdentity.getPrincipal().toUint8Array(), - labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())), + fork( + labeled('canister_ranges', leaf(Cbor.encode(canisterRanges))), + labeled('node', + labeled(nodeIdentity.getPrincipal().toUint8Array(), + labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())), + ), ), ), + labeled('public_key', leaf(subnetPublicKey)), ), ), ), From 6c271a9e4a081b905509b8b7737b3be149b742f6 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 17:58:00 +0100 Subject: [PATCH 16/17] test: run all tests --- e2e/node/basic/queryExpiry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/node/basic/queryExpiry.test.ts b/e2e/node/basic/queryExpiry.test.ts index 3dd25f629..62e7154bb 100644 --- a/e2e/node/basic/queryExpiry.test.ts +++ b/e2e/node/basic/queryExpiry.test.ts @@ -374,7 +374,7 @@ describe('queryExpiry', () => { expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); - it.only('should succeed if clock is drifted by more than 5 minutes in the future without syncing it', async () => { + it('should succeed if clock is drifted by more than 5 minutes in the future without syncing it', async () => { const timeDiffMsecs = 6 * MINUTE_TO_MSECS; const replicaDate = new Date(now.getTime() + timeDiffMsecs); From 7d4c565edd51c2bad8f27eb50d4b50257a4c7329 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Thu, 4 Dec 2025 18:55:32 +0100 Subject: [PATCH 17/17] feat(agent): `syncTimeWithSubnet` method for `HttpAgent` --- CHANGELOG.md | 1 + packages/core/src/agent/agent/http/index.ts | 79 +++++++++++++++++---- packages/core/src/agent/certificate.ts | 11 ++- 3 files changed, 72 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f8e3c825..38699798f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - feat(agent): lookup canister ranges using the `/canister_ranges//` certificate path - feat(agent): introduce the `lookupCanisterRanges`, `lookupCanisterRangesFallback`, and `decodeCanisterRanges` utility functions to lookup canister ranges from certificate trees - feat(agent): introduce the `getSubnetIdFromCanister` and `readSubnetState` methods in the `HttpAgent` class +- feat(agent): introduce the `syncTimeWithSubnet` method in the `HttpAgent` class to sync the time with a particular subnet - feat(agent): introduce the `SubnetStatus` utility namespace to request subnet information directly from the IC public API - feat(agent): export `IC_STATE_ROOT_DOMAIN_SEPARATOR` constant - refactor(agent): only declare IC URLs once in the `HttpAgent` class diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index e95381e6f..afa37123e 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -56,6 +56,7 @@ import { type HttpHeaderField, } from './types.ts'; import { type SubnetStatus, request as canisterStatusRequest } from '../../canisterStatus/index.ts'; +import { request as subnetStatusRequest } from '../../subnetStatus/index.ts'; import { Certificate, type HashTree, lookup_path, LookupPathStatus } from '../../certificate.ts'; import { ed25519 } from '@noble/curves/ed25519'; import { ExpirableMap } from '../../utils/expirableMap.ts'; @@ -1221,7 +1222,7 @@ export class HttpAgent implements Agent { } /** - * Allows agent to sync its time with the network. Can be called during intialization or mid-lifecycle if the device's clock has drifted away from the network time. This is necessary to set the Expiry for a request + * Allows agent to sync its time with the network. Can be called during initialization or mid-lifecycle if the device's clock has drifted away from the network time. This is necessary to set the Expiry for a request * @param {Principal} canisterIdOverride - Pass a canister ID if you need to sync the time with a particular subnet. Uses the ICP ledger canister by default. */ public async syncTime(canisterIdOverride?: Principal): Promise { @@ -1266,18 +1267,7 @@ export class HttpAgent implements Agent { }, []), ); - const maxReplicaTime = replicaTimes.reduce((max, current) => { - return typeof current === 'number' && current > max ? current : max; - }, 0); - - if (maxReplicaTime > 0) { - this.#timeDiffMsecs = maxReplicaTime - callTime; - this.#hasSyncedTime = true; - this.log.notify({ - message: `Syncing time: offset of ${this.#timeDiffMsecs}`, - level: 'info', - }); - } + this.#setTimeDiffMsecs(callTime, replicaTimes); } catch (error) { const syncTimeError = error instanceof AgentError @@ -1294,6 +1284,69 @@ export class HttpAgent implements Agent { }); } + /** + * Allows agent to sync its time with the network. + * @param {Principal} subnetId - Pass the subnet ID you need to sync the time with. + */ + public async syncTimeWithSubnet(subnetId: Principal): Promise { + await this.#rootKeyGuard(); + const callTime = Date.now(); + + try { + const anonymousAgent = HttpAgent.createSync({ + identity: new AnonymousIdentity(), + host: this.host.toString(), + fetch: this.#fetch, + retryTimes: 0, + rootKey: this.rootKey ?? undefined, + shouldSyncTime: false, + }); + + const replicaTimes = await Promise.all( + Array(3) + .fill(null) + .map(async () => { + const status = await subnetStatusRequest({ + subnetId, + agent: anonymousAgent, + paths: ['time'], + disableCertificateTimeVerification: true, // avoid recursive calls to syncTime + }); + + const date = status.get('time'); + if (date instanceof Date) { + return date.getTime(); + } + }, []), + ); + + this.#setTimeDiffMsecs(callTime, replicaTimes); + } catch (error) { + const syncTimeError = + error instanceof AgentError + ? error + : UnknownError.fromCode(new UnexpectedErrorCode(error)); + this.log.error('Caught exception while attempting to sync time with subnet', syncTimeError); + + throw syncTimeError; + } + } + + #setTimeDiffMsecs(callTime: number, replicaTimes: Array): void { + const maxReplicaTime = replicaTimes.reduce((max, current) => { + return typeof current === 'number' && current > max ? current : max; + }, 0); + + if (maxReplicaTime > 0) { + this.#timeDiffMsecs = maxReplicaTime - callTime; + this.#hasSyncedTime = true; + this.log.notify({ + message: `Syncing time: offset of ${this.#timeDiffMsecs}`, + level: 'info', + }); + } + } + public async status(): Promise { const headers: Record = this.#credentials ? { diff --git a/packages/core/src/agent/certificate.ts b/packages/core/src/agent/certificate.ts index f0a86adaa..ad2eccfb0 100644 --- a/packages/core/src/agent/certificate.ts +++ b/packages/core/src/agent/certificate.ts @@ -204,7 +204,7 @@ export interface CreateCertificateOptions { /** * The agent used to sync time with the IC network, if the certificate fails the freshness check. - * If the agent does not implement the {@link HttpAgent.getTimeDiffMsecs}, {@link HttpAgent.hasSyncedTime} and {@link HttpAgent.syncTime} methods, + * If the agent does not implement the {@link HttpAgent.getTimeDiffMsecs}, {@link HttpAgent.hasSyncedTime}, {@link HttpAgent.syncTime} and {@link HttpAgent.syncTimeWithSubnet} methods, * time will not be synced in case of a freshness check failure. * @default undefined */ @@ -214,7 +214,7 @@ export interface CreateCertificateOptions { export class Certificate { public cert: Cert; #disableTimeVerification: boolean = false; - #agent: Pick | undefined = + #agent: Pick | undefined = undefined; /** @@ -253,8 +253,8 @@ export class Certificate { this.#disableTimeVerification = disableTimeVerification; this.cert = cbor.decode(certificate); - if (agent && 'getTimeDiffMsecs' in agent && 'hasSyncedTime' in agent && 'syncTime' in agent) { - this.#agent = agent as Pick; + if (agent && 'getTimeDiffMsecs' in agent && 'hasSyncedTime' in agent && 'syncTime' in agent && 'syncTimeWithSubnet' in agent) { + this.#agent = agent as Pick; } } @@ -412,8 +412,7 @@ export class Certificate { if (isCanisterPrincipal(this._principal)) { await this.#agent.syncTime(this._principal.canisterId); } else { - // TODO: sync time with subnet once the agent supports it - await this.#agent.syncTime(); + await this.#agent.syncTimeWithSubnet(this._principal.subnetId); } } }