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); } } }