From 41d9286cb4872621cd9c6ae8ff3eed6457471129 Mon Sep 17 00:00:00 2001 From: ilbertt Date: Wed, 26 Nov 2025 15:22:10 +0100 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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;