From 36d3742fb0ae25e822e5e8b1fb00eb2bb1d32688 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Thu, 15 Aug 2024 15:32:05 -0700 Subject: [PATCH 1/6] call working, query has subnetStatus disabled --- packages/agent/src/agent/api.ts | 1 + .../agent/http/calculateReplicaTime.test.ts | 6 + .../src/agent/http/calculateReplicaTime.ts | 22 ++ packages/agent/src/agent/http/http.test.ts | 27 ++- packages/agent/src/agent/http/index.ts | 121 ++++++++--- packages/agent/src/agent/http/transforms.ts | 5 +- packages/agent/src/canisterStatus/index.ts | 193 +++++++++--------- packages/agent/src/certificate.ts | 50 +++-- 8 files changed, 280 insertions(+), 145 deletions(-) create mode 100644 packages/agent/src/agent/http/calculateReplicaTime.test.ts create mode 100644 packages/agent/src/agent/http/calculateReplicaTime.ts diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index 58e204d2f..d827470a7 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -119,6 +119,7 @@ export interface CallOptions { export interface ReadStateResponse { certificate: ArrayBuffer; + replicaTime?: Date; } export interface SubmitResponse { diff --git a/packages/agent/src/agent/http/calculateReplicaTime.test.ts b/packages/agent/src/agent/http/calculateReplicaTime.test.ts new file mode 100644 index 000000000..37f0c3563 --- /dev/null +++ b/packages/agent/src/agent/http/calculateReplicaTime.test.ts @@ -0,0 +1,6 @@ +import { calculateReplicaTime } from './calculateReplicaTime'; +const exampleMessage = `Specified ingress_expiry not within expected range: Minimum allowed expiry: 2024-08-13 22:49:30.148075776 UTC, Maximum allowed expiry: 2024-08-13 22:55:00.148075776 UTC, Provided expiry: 2021-01-01 00:04:00 UTC`; + +test.only('calculateReplicaTime', () => { + calculateReplicaTime(exampleMessage); //? +}); diff --git a/packages/agent/src/agent/http/calculateReplicaTime.ts b/packages/agent/src/agent/http/calculateReplicaTime.ts new file mode 100644 index 000000000..6983506d7 --- /dev/null +++ b/packages/agent/src/agent/http/calculateReplicaTime.ts @@ -0,0 +1,22 @@ +/** + * Parse the expiry from the message + * @param message an error message + * @returns diff in milliseconds + */ +export const calculateReplicaTime = (message: string): Date => { + const [min, max] = message.split('UTC'); + + const minsplit = min.trim().split(' ').reverse(); + + const minDateString = `${minsplit[1]} ${minsplit[0]} UTC`; + + const maxsplit = max.trim().split(' ').reverse(); + + const maxDateString = `${maxsplit[1]} ${maxsplit[0]} UTC`; + + return new Date(minDateString); +}; + +function midwayBetweenDates(date1: Date, date2: Date) { + return new Date((date1.getTime() + date2.getTime()) / 2); +} diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index c040dae7d..77298ca30 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -13,10 +13,11 @@ import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; import { JSDOM } from 'jsdom'; -import { AnonymousIdentity, SignIdentity, toHex } from '../..'; +import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { AgentError } from '../../errors'; import { AgentHTTPResponseError } from './errors'; +import { IDL } from '@dfinity/candid'; const { window } = new JSDOM(`

Hello world

`); window.fetch = global.fetch; (global as any).window = window; @@ -810,3 +811,27 @@ test('it should log errors to console if the option is set', async () => { const agent = new HttpAgent({ host: HTTP_AGENT_HOST, fetch: jest.fn(), logToConsole: true }); await agent.syncTime(); }); +jest.setTimeout(5000); +test.only('it should sync time with the replica', async () => { + const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; + const idlFactory = () => { + return IDL.Service({ + whoami: IDL.Func([], [IDL.Principal], ['query']), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as unknown as any; + }; + jest.useFakeTimers(); + + // set date to long ago + jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + + const agent = await HttpAgent.create({ host: 'https://icp-api.io' }); + + const actor = Actor.createActor(idlFactory, { + agent, + canisterId, + }); + + const result = await actor.whoami(); + expect(Principal.from(result)).toBeInstanceOf(Principal); +}); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index b32a91525..8a3d7016c 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -41,6 +41,7 @@ import { Ed25519PublicKey } from '../../public_key'; import { decodeTime } from '../../utils/leb'; import { ObservableLog } from '../../observable'; import { BackoffStrategy, BackoffStrategyFactory, ExponentialBackoff } from '../../polling/backoff'; +import { calculateReplicaTime } from './calculateReplicaTime'; export * from './transforms'; export { Nonce, makeNonce } from './types'; @@ -238,7 +239,7 @@ export class HttpAgent implements Agent { readonly #fetch: typeof fetch; readonly #fetchOptions?: Record; readonly #callOptions?: Record; - #timeDiffMsecs = 0; + replicaTime = new Date(Date.now()); readonly host: URL; readonly #credentials: string | undefined; #rootKeyFetched = false; @@ -423,8 +424,9 @@ export class HttpAgent implements Agent { let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); // If the value is off by more than 30 seconds, reconcile system time with the network - if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) { - ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + this.#timeDiffMsecs); + const timeDiffMsecs = this.replicaTime && this.replicaTime.getTime() - Date.now(); + if (Math.abs(timeDiffMsecs) > 1_000 * 30) { + ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + timeDiffMsecs); } const submit: CallRequest = { @@ -669,6 +671,13 @@ export class HttpAgent implements Agent { } const responseText = await response.clone().text(); + + response; + + if (response.status === 400 && responseText.includes('ingress_expiry')) { + throw new AgentError(responseText); + } + const errorMessage = `Server returned an error:\n` + ` Code: ${response.status} (${response.statusText})\n` + @@ -685,6 +694,8 @@ export class HttpAgent implements Agent { }); } + #errorTimes = 0; + public async query( canisterId: Principal | string, fields: QueryFields, @@ -708,13 +719,21 @@ export class HttpAgent implements Agent { const canister = Principal.from(canisterId); const sender = id?.getPrincipal() || Principal.anonymous(); + let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); + + // If the value is off by more than 30 seconds, reconcile system time with the network + const timeDiffMsecs = this.replicaTime && this.replicaTime.getTime() - Date.now(); + if (Math.abs(timeDiffMsecs) > 1_000 * 30) { + ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + timeDiffMsecs); + } + const request: QueryRequest = { request_type: ReadRequestType.Query, canister_id: canister, method_name: fields.methodName, arg: fields.arg, sender, - ingress_expiry: new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS), + ingress_expiry, }; const requestId = await requestIdOf(request); @@ -767,7 +786,8 @@ export class HttpAgent implements Agent { }; // Attempt to make the query i=retryTimes times // Make query and fetch subnet keys in parallel - const [queryResult, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]); + // const [queryResult, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]); + const [queryResult] = await Promise.all([makeQuery()]); const { requestDetails, query } = queryResult; const queryWithDetails = { @@ -782,6 +802,8 @@ export class HttpAgent implements Agent { } try { + this.replicaTime; //? + return queryWithDetails; return this.#verifyQueryResponse(queryWithDetails, subnetStatus); } catch { // In case the node signatures have changed, refresh the subnet keys and try again @@ -888,7 +910,18 @@ export class HttpAgent implements Agent { // TODO: remove this any. This can be a Signed or UnSigned request. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const transformedRequest: any = await this._transform({ + + let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); + + // If the value is off by more than 30 seconds, reconcile system time with the network + const timeDiffMsecs = this.replicaTime && this.replicaTime.getTime() - Date.now(); + if (Math.abs(timeDiffMsecs) > 1_000 * 30) { + ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + timeDiffMsecs); + + ingress_expiry; //? + } + + const transformedRequest = await this._transform({ request: { method: 'POST', headers: { @@ -901,7 +934,7 @@ export class HttpAgent implements Agent { request_type: ReadRequestType.ReadState, paths: fields.paths, sender, - ingress_expiry: new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS), + ingress_expiry, }, }); @@ -914,10 +947,12 @@ export class HttpAgent implements Agent { fields: ReadStateOptions, identity?: Identity | Promise, // eslint-disable-next-line - request?: any, + request?: Request, ): Promise { const canister = typeof canisterId === 'string' ? Principal.fromText(canisterId) : canisterId; + this.replicaTime; //? + const transformedRequest = request ?? (await this.createReadStateRequest(fields, identity)); const body = cbor.encode(transformedRequest.body); @@ -928,34 +963,52 @@ export class HttpAgent implements Agent { // TODO - https://dfinity.atlassian.net/browse/SDK-1092 const backoff = this.#backoffStrategy(); - const response = await this.#requestAndRetry({ - request: () => - this.#fetch('' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), { - ...this.#fetchOptions, - ...transformedRequest.request, - body, - }), - backoff, - tries: 0, - }); + try { + const response = await this.#requestAndRetry({ + request: () => + this.#fetch( + '' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), + { + ...this.#fetchOptions, + ...transformedRequest.request, + body, + }, + ), + backoff, + tries: 0, + }); - if (!response.ok) { - throw new Error( - `Server returned an error:\n` + - ` Code: ${response.status} (${response.statusText})\n` + - ` Body: ${await response.text()}\n`, - ); - } - const decodedResponse: ReadStateResponse = cbor.decode(await response.arrayBuffer()); + if (!response.ok) { + throw new Error( + `Server returned an error:\n` + + ` Code: ${response.status} (${response.statusText})\n` + + ` Body: ${await response.text()}\n`, + ); + } + const decodedResponse: ReadStateResponse = cbor.decode(await response.arrayBuffer()); - this.log.print('Read state response:', decodedResponse); - const parsedTime = await this.parseTimeFromResponse(decodedResponse); - if (parsedTime > 0) { - this.log.print('Read state response time:', parsedTime); - this.#waterMark = parsedTime; + this.log.print('Read state response:', decodedResponse); + const parsedTime = await this.parseTimeFromResponse(decodedResponse); + if (parsedTime > 0) { + this.log.print('Read state response time:', parsedTime); + this.#waterMark = parsedTime; + } + return decodedResponse; + } catch (error) { + this.#errorTimes++; //? + const message = (error as AgentError).message ?? ''; + message; + if (message?.includes('ingress_expiry')) { + { + const replicaTime = calculateReplicaTime(message); //? + if (replicaTime) { + this.replicaTime = replicaTime; + } + return await this.readState(canisterId, fields, identity); + } + } + throw error; } - - return decodedResponse; } public async parseTimeFromResponse(response: ReadStateResponse): Promise { @@ -1007,7 +1060,7 @@ export class HttpAgent implements Agent { const replicaTime = status.get('time'); if (replicaTime) { - this.#timeDiffMsecs = Number(replicaTime as bigint) - Number(callTime); + this.replicaTime = new Date(Number(replicaTime as bigint)); } } catch (error) { this.log.error('Caught exception while attempting to sync time', error as AgentError); diff --git a/packages/agent/src/agent/http/transforms.ts b/packages/agent/src/agent/http/transforms.ts index 934dfbfb4..d21a74837 100644 --- a/packages/agent/src/agent/http/transforms.ts +++ b/packages/agent/src/agent/http/transforms.ts @@ -17,18 +17,21 @@ export class Expiry { private readonly _value: bigint; constructor(deltaInMSec: number) { + deltaInMSec; // Use bigint because it can overflow the maximum number allowed in a double float. const raw_value = BigInt(Math.floor(Date.now() + deltaInMSec - REPLICA_PERMITTED_DRIFT_MILLISECONDS)) * NANOSECONDS_PER_MILLISECONDS; + new Date(Number(raw_value / NANOSECONDS_PER_MILLISECONDS)); //? + // round down to the nearest second const ingress_as_seconds = raw_value / BigInt(1_000_000_000); // round down to nearest minute const ingress_as_minutes = ingress_as_seconds / BigInt(60); - const rounded_down_nanos = ingress_as_minutes * BigInt(60) * BigInt(1_000_000_000); + const rounded_down_nanos = ingress_as_minutes * BigInt(60) * BigInt(1_000_000_000); //? this._value = rounded_down_nanos; } diff --git a/packages/agent/src/canisterStatus/index.ts b/packages/agent/src/canisterStatus/index.ts index c46ba2ca6..7c621072e 100644 --- a/packages/agent/src/canisterStatus/index.ts +++ b/packages/agent/src/canisterStatus/index.ts @@ -139,112 +139,111 @@ export const request = async (options: { }); const status = new Map(); - const promises = uniquePaths.map((path, index) => { - return (async () => { - try { - const response = await agent.readState(canisterId, { - paths: [encodedPaths[index]], - }); - const cert = await Certificate.create({ - certificate: response.certificate, - rootKey: agent.rootKey, - canisterId: canisterId, - }); - - const lookup = (cert: Certificate, path: Path) => { - if (path === 'subnet') { - const data = fetchNodeKeys(response.certificate, canisterId, agent.rootKey); - return { - path: path, - data, - }; - } else { - return { - path: path, - data: lookupResultToBuffer(cert.lookup(encodePath(path, canisterId))), - }; - } + try { + const response = await agent.readState(canisterId, { + paths: encodedPaths, + }); + + agent.replicaTime; //? + + const cert = await Certificate.create({ + certificate: response.certificate, + rootKey: agent.rootKey, + canisterId: canisterId, + certTime: agent.replicaTime, + }); + + const lookup = (cert: Certificate, path: Path) => { + if (path === 'subnet') { + const data = fetchNodeKeys(response.certificate, canisterId, agent.rootKey); + return { + path: path, + data, + }; + } else { + return { + path: path, + data: lookupResultToBuffer(cert.lookup(encodePath(path, canisterId))), }; + } + }; - // must pass in the rootKey if we have no delegation - const { path, data } = lookup(cert, uniquePaths[index]); - if (!data) { - // Typically, the cert lookup will throw - 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); - } + // must pass in the rootKey if we have no delegation + uniquePaths.forEach(unique => { + const { path, data } = lookup(cert, unique); + if (!data) { + // Typically, the cert lookup will throw + console.warn(`Expected to find result for path ${path}, but instead found nothing.`); + if (typeof path === 'string') { + status.set(path, null); } else { - switch (path) { - case 'time': { - status.set(path, decodeTime(data)); - break; - } - case 'controllers': { - status.set(path, decodeControllers(data)); - break; - } - case 'module_hash': { - status.set(path, decodeHex(data)); - break; - } - case 'subnet': { - status.set(path, data); - break; - } - case 'candid': { - status.set(path, new TextDecoder().decode(data)); - break; - } - default: { - // Check for CustomPath signature - if (typeof path !== 'string' && 'key' in path && 'path' in path) { - switch (path.decodeStrategy) { - case 'raw': - status.set(path.key, data); - break; - case 'leb128': { - status.set(path.key, decodeLeb128(data)); - break; - } - case 'cbor': { - status.set(path.key, decodeCbor(data)); - break; - } - case 'hex': { - status.set(path.key, decodeHex(data)); - break; - } - case 'utf-8': { - status.set(path.key, decodeUtf8(data)); - } + status.set(path.key, null); + } + } else { + switch (path) { + case 'time': { + status.set(path, decodeTime(data)); + break; + } + case 'controllers': { + status.set(path, decodeControllers(data)); + break; + } + case 'module_hash': { + status.set(path, decodeHex(data)); + break; + } + case 'subnet': { + status.set(path, data); + break; + } + case 'candid': { + status.set(path, new TextDecoder().decode(data)); + break; + } + default: { + // Check for CustomPath signature + if (typeof path !== 'string' && 'key' in path && 'path' in path) { + switch (path.decodeStrategy) { + case 'raw': + status.set(path.key, data); + break; + case 'leb128': { + status.set(path.key, decodeLeb128(data)); + break; + } + case 'cbor': { + status.set(path.key, decodeCbor(data)); + break; + } + case 'hex': { + status.set(path.key, decodeHex(data)); + break; + } + case 'utf-8': { + status.set(path.key, decodeUtf8(data)); } } } } } - } catch (error) { - // Break on signature verification errors - if ((error as AgentError)?.message?.includes('Invalid certificate')) { - throw new AgentError((error as AgentError).message); - } - if (typeof path !== 'string' && 'key' in path && 'path' in 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); + }); + } catch (error) { + // Break on signature verification errors + if ((error as AgentError)?.message?.includes('Invalid certificate')) { + throw new AgentError((error as AgentError).message); + } + // if (typeof path !== 'string' && 'key' in path && 'path' in 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(); + } return status; }; diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index a8cdf5516..326dbaebe 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -40,7 +40,7 @@ export type HashTree = /** * Make a human readable string out of a hash tree. - * @param tree + * @param tree - the tree to stringify */ export function hashTreeToString(tree: HashTree): string { const indent = (s: string) => @@ -52,7 +52,7 @@ export function hashTreeToString(tree: HashTree): string { const decoder = new TextDecoder(undefined, { fatal: true }); try { return JSON.stringify(decoder.decode(label)); - } catch (e) { + } catch { return `data(...${label.byteLength} bytes)`; } } @@ -146,10 +146,16 @@ export interface CreateCertificateOptions { * older than the specified age, it will fail verification. */ maxAgeInMinutes?: number; + + /** + * For comparing the time of the certificate to an expected date instead of the result of Date.now. + */ + certTime?: Date; } export class Certificate { private readonly cert: Cert; + #certTime?: Date; /** * Create a new instance of a certificate, automatically verifying it. Throws a @@ -180,6 +186,7 @@ export class Certificate { options.canisterId, blsVerify, options.maxAgeInMinutes, + options.certTime, ); } @@ -190,8 +197,10 @@ export class Certificate { private _blsVerify: VerifyFunc, // Default to 5 minutes private _maxAgeInMinutes: number = 5, + certTime?: Date, ) { this.cert = cbor.decode(new Uint8Array(certificate)); + this.#certTime = certTime; } public lookup(path: Array): LookupResult { @@ -220,8 +229,10 @@ export class Certificate { const FIVE_MINUTES_IN_MSEC = 5 * 60 * 1000; const MAX_AGE_IN_MSEC = this._maxAgeInMinutes * 60 * 1000; const now = Date.now(); - const earliestCertificateTime = now - MAX_AGE_IN_MSEC; - const fiveMinutesFromNow = now + FIVE_MINUTES_IN_MSEC; + // Use a provided time in case `Date.now()` is inaccurate + const compareTime = this.#certTime || new Date(now); + const earliestCertificateTime = compareTime.getTime() - MAX_AGE_IN_MSEC; + const fiveMinutesFromNow = compareTime.getTime() + FIVE_MINUTES_IN_MSEC; const certTime = decodeTime(lookupTime); @@ -230,20 +241,20 @@ export class Certificate { `Certificate is signed more than ${this._maxAgeInMinutes} minutes in the past. Certificate time: ` + certTime.toISOString() + ' Current time: ' + - new Date(now).toISOString(), + compareTime.toISOString(), ); } else if (certTime.getTime() > fiveMinutesFromNow) { throw new CertificateVerificationError( 'Certificate is signed more than 5 minutes in the future. Certificate time: ' + certTime.toISOString() + ' Current time: ' + - new Date(now).toISOString(), + compareTime.toISOString(), ); } try { sigVer = await this._blsVerify(new Uint8Array(key), new Uint8Array(sig), new Uint8Array(msg)); - } catch (err) { + } catch { sigVer = false; } if (!sigVer) { @@ -335,7 +346,7 @@ export function lookupResultToBuffer(result: LookupResult): ArrayBuffer | undefi } /** - * @param t + * @param t - the tree to reconstruct */ export async function reconstruct(t: HashTree): Promise { switch (t[0]) { @@ -408,6 +419,12 @@ interface LookupResultLess { type LabelLookupResult = LookupResult | LookupResultGreater | LookupResultLess; +/** + * Lookup a path in a tree + * @param path - the path to look up + * @param tree - the tree to search + * @returns LookupResult + */ export function lookup_path(path: Array, tree: HashTree): LookupResult { if (path.length === 0) { switch (tree[0]) { @@ -482,6 +499,12 @@ export function flatten_forks(t: HashTree): HashTree[] { } } +/** + * Find a label in a tree + * @param label - the label to find + * @param tree - the tree to search + * @returns LabelLookupResult + */ export function find_label(label: ArrayBuffer, tree: HashTree): LabelLookupResult { switch (tree[0]) { // if we have a labelled node, compare the node's label to the one we are @@ -514,6 +537,7 @@ export function find_label(label: ArrayBuffer, tree: HashTree): LabelLookupResul // if we have a fork node, we need to search both sides, starting with the left case NodeType.Fork: // search in the left node + // eslint-disable-next-line no-case-declarations const leftLookupResult = find_label(label, tree[1]); switch (leftLookupResult.status) { @@ -538,7 +562,7 @@ export function find_label(label: ArrayBuffer, tree: HashTree): LabelLookupResul // if the left node returns an uncertain result, we need to search the // right node case LookupStatus.Unknown: { - let rightLookupResult = find_label(label, tree[2]); + const rightLookupResult = find_label(label, tree[2]); // if the label we're searching for is less than the right node lookup, // then we also need to return an uncertain result @@ -580,9 +604,11 @@ export function find_label(label: ArrayBuffer, tree: HashTree): LabelLookupResul /** * Check if a canister falls within a range of canisters - * @param canisterId Principal - * @param ranges [Principal, Principal][] - * @returns + * @param params - the parameters to check + * @param params.canisterId Principal + * @param params.subnetId Principal + * @param params.tree HashTree + * @returns boolean */ export function check_canister_ranges(params: { canisterId: Principal; From 5c2030b2aedb61edf07aa60038effedb1b4fffd5 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Mon, 9 Sep 2024 14:03:09 -0700 Subject: [PATCH 2/6] wip --- packages/agent/src/agent/http/errors.ts | 19 ++++++- packages/agent/src/agent/http/http.test.ts | 63 ++++++++++++++++++++-- packages/agent/src/agent/http/index.ts | 62 ++++++++++++--------- packages/agent/src/canisterStatus/index.ts | 3 +- 4 files changed, 114 insertions(+), 33 deletions(-) diff --git a/packages/agent/src/agent/http/errors.ts b/packages/agent/src/agent/http/errors.ts index 8874e14d6..4f701379f 100644 --- a/packages/agent/src/agent/http/errors.ts +++ b/packages/agent/src/agent/http/errors.ts @@ -1,10 +1,27 @@ +import { HttpAgent } from '.'; import { AgentError } from '../../errors'; import { HttpDetailsResponse } from '../api'; export class AgentHTTPResponseError extends AgentError { - constructor(message: string, public readonly response: HttpDetailsResponse) { + constructor( + message: string, + public readonly response: HttpDetailsResponse, + ) { super(message); this.name = this.constructor.name; Object.setPrototypeOf(this, new.target.prototype); } } + +export class ReplicaTimeError extends AgentError { + public readonly replicaTime: Date; + public readonly agent: HttpAgent; + + constructor(message: string, replicaTime: Date, agent: HttpAgent) { + super(message); + this.name = 'ReplicaTimeError'; + this.replicaTime = replicaTime; + this.agent = agent; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 77298ca30..f315715ec 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -16,7 +16,7 @@ import { JSDOM } from 'jsdom'; import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { AgentError } from '../../errors'; -import { AgentHTTPResponseError } from './errors'; +import { AgentHTTPResponseError, ReplicaTimeError } from './errors'; import { IDL } from '@dfinity/candid'; const { window } = new JSDOM(`

Hello world

`); window.fetch = global.fetch; @@ -812,7 +812,7 @@ test('it should log errors to console if the option is set', async () => { await agent.syncTime(); }); jest.setTimeout(5000); -test.only('it should sync time with the replica', async () => { +test('it should sync time with the replica for a query', async () => { const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; const idlFactory = () => { return IDL.Service({ @@ -820,10 +820,13 @@ test.only('it should sync time with the replica', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as unknown as any; }; - jest.useFakeTimers(); + jest.useRealTimers(); // set date to long ago - jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + jest.spyOn(Date, 'now').mockImplementation(() => { + return new Date('2021-01-01T00:00:00Z').getTime(); + }); + // jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); const agent = await HttpAgent.create({ host: 'https://icp-api.io' }); @@ -831,7 +834,57 @@ test.only('it should sync time with the replica', async () => { agent, canisterId, }); - + try { + // should throw an error + await actor.whoami(); + } catch (err) { + // handle the replica time error + if (err.name === 'ReplicaTimeError') { + const error = err as ReplicaTimeError; + // use the replica time to sync the agent + error.agent.replicaTime = error.replicaTime; + } + } + // retry the call const result = await actor.whoami(); expect(Principal.from(result)).toBeInstanceOf(Principal); }); +test.only('it should sync time with the replica for an update', async () => { + const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; + const idlFactory = () => { + return IDL.Service({ + whoami: IDL.Func([], [IDL.Principal], []), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as unknown as any; + }; + jest.useRealTimers(); + + // set date to long ago + jest.spyOn(Date, 'now').mockImplementation(() => { + return new Date('2021-01-01T00:00:00Z').getTime(); + }); + // jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); + + const agent = await HttpAgent.create({ host: 'https://icp-api.io' }); + + const actor = Actor.createActor(idlFactory, { + agent, + canisterId, + }); + try { + // should throw an error + await actor.whoami(); + } catch (err) { + // handle the replica time error + if (err.name === 'ReplicaTimeError') { + const error = err as ReplicaTimeError; + error; + // use the replica time to sync the agent + error.agent.replicaTime = error.replicaTime; + error.agent.replicaTime; //? + const result = await actor.whoami(); + expect(Principal.from(result)).toBeInstanceOf(Principal); + } + } + // retry the call +}); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 8a3d7016c..0586e951b 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -27,7 +27,7 @@ import { ReadRequestType, SubmitRequestType, } from './types'; -import { AgentHTTPResponseError } from './errors'; +import { AgentHTTPResponseError, ReplicaTimeError } from './errors'; import { SubnetStatus, request } from '../../canisterStatus'; import { CertificateVerificationError, @@ -139,6 +139,10 @@ export interface HttpAgentOptions { * Whether to log to the console. Defaults to false. */ logToConsole?: boolean; + /** + * Provide an expected replica time. This can be used to set the baseline for the time to use when making requests against the replica. + */ + replicaTime?: Date; } function getDefaultFetch(): typeof fetch { @@ -239,7 +243,6 @@ export class HttpAgent implements Agent { readonly #fetch: typeof fetch; readonly #fetchOptions?: Record; readonly #callOptions?: Record; - replicaTime = new Date(Date.now()); readonly host: URL; readonly #credentials: string | undefined; #rootKeyFetched = false; @@ -253,6 +256,20 @@ export class HttpAgent implements Agent { // The UTC time in milliseconds when the latest request was made #waterMark = 0; + // Manage the time offset between the client and the replica + #initialClientTime: Date = new Date(Date.now()); + #initialReplicaTime: Date = new Date(Date.now()); + get replicaTime(): Date { + const offset = Date.now() - this.#initialClientTime.getTime(); + return new Date(this.#initialReplicaTime.getTime() + offset); + } + + set replicaTime(replicaTime: Date) { + replicaTime; + this.#initialClientTime = new Date(Date.now()); + this.#initialReplicaTime = replicaTime; + } + get waterMark(): number { return this.#waterMark; } @@ -423,6 +440,7 @@ export class HttpAgent implements Agent { let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); + this.replicaTime; //? // If the value is off by more than 30 seconds, reconcile system time with the network const timeDiffMsecs = this.replicaTime && this.replicaTime.getTime() - Date.now(); if (Math.abs(timeDiffMsecs) > 1_000 * 30) { @@ -576,6 +594,7 @@ export class HttpAgent implements Agent { ); } } catch (error) { + this.#handleReplicaTimeError(error as AgentError); if (tries < this.#retryTimes) { this.log.warn( `Caught exception while attempting to make query:\n` + @@ -672,10 +691,8 @@ export class HttpAgent implements Agent { const responseText = await response.clone().text(); - response; - if (response.status === 400 && responseText.includes('ingress_expiry')) { - throw new AgentError(responseText); + this.#handleReplicaTimeError(new AgentError(responseText)); } const errorMessage = @@ -802,9 +819,7 @@ export class HttpAgent implements Agent { } try { - this.replicaTime; //? - return queryWithDetails; - return this.#verifyQueryResponse(queryWithDetails, subnetStatus); + return this.#verifyQueryResponse(queryWithDetails, await getSubnetStatus()); } 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.'); @@ -917,8 +932,6 @@ export class HttpAgent implements Agent { const timeDiffMsecs = this.replicaTime && this.replicaTime.getTime() - Date.now(); if (Math.abs(timeDiffMsecs) > 1_000 * 30) { ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + timeDiffMsecs); - - ingress_expiry; //? } const transformedRequest = await this._transform({ @@ -951,8 +964,6 @@ export class HttpAgent implements Agent { ): Promise { const canister = typeof canisterId === 'string' ? Principal.fromText(canisterId) : canisterId; - this.replicaTime; //? - const transformedRequest = request ?? (await this.createReadStateRequest(fields, identity)); const body = cbor.encode(transformedRequest.body); @@ -995,22 +1006,22 @@ export class HttpAgent implements Agent { } return decodedResponse; } catch (error) { - this.#errorTimes++; //? - const message = (error as AgentError).message ?? ''; - message; - if (message?.includes('ingress_expiry')) { - { - const replicaTime = calculateReplicaTime(message); //? - if (replicaTime) { - this.replicaTime = replicaTime; - } - return await this.readState(canisterId, fields, identity); - } - } - throw error; + this.#errorTimes++; + this.#handleReplicaTimeError(error as AgentError); } + throw new AgentError('Failed to read state'); } + #handleReplicaTimeError = (error: AgentError): void => { + const message = error.message; + if (message?.includes('ingress_expiry')) { + { + const replicaTime = calculateReplicaTime(message); + throw new ReplicaTimeError(message, replicaTime, this); + } + } + }; + public async parseTimeFromResponse(response: ReadStateResponse): Promise { let tree: HashTree; if (response.certificate) { @@ -1044,7 +1055,6 @@ export class HttpAgent implements Agent { */ public async syncTime(canisterId?: Principal): Promise { const CanisterStatus = await import('../../canisterStatus'); - const callTime = Date.now(); try { if (!canisterId) { this.log.print( diff --git a/packages/agent/src/canisterStatus/index.ts b/packages/agent/src/canisterStatus/index.ts index 7c621072e..fdf4e26a3 100644 --- a/packages/agent/src/canisterStatus/index.ts +++ b/packages/agent/src/canisterStatus/index.ts @@ -137,6 +137,7 @@ export const request = async (options: { const encodedPaths = uniquePaths.map(path => { return encodePath(path, canisterId); }); + const status = new Map(); try { @@ -240,7 +241,7 @@ export const request = async (options: { // status.set(path, null); // } console.group(); - console.warn(`Expected to find result for path ${path}, but instead found nothing.`); + // console.warn(`Expected to find result for path ${path}, but instead found nothing.`); console.warn(error); console.groupEnd(); } From 857544eb99cbb9050661575c5544e2c4c069260b Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Mon, 9 Sep 2024 14:50:09 -0700 Subject: [PATCH 3/6] sync time through error handling working --- .../agent/src/agent/http/calculateReplicaTime.test.ts | 2 +- packages/agent/src/agent/http/http.test.ts | 10 ++++------ packages/agent/src/canisterStatus/index.ts | 7 ------- packages/agent/src/certificate.ts | 2 +- packages/agent/src/polling/index.ts | 9 ++++++++- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/packages/agent/src/agent/http/calculateReplicaTime.test.ts b/packages/agent/src/agent/http/calculateReplicaTime.test.ts index 37f0c3563..1e350a0ad 100644 --- a/packages/agent/src/agent/http/calculateReplicaTime.test.ts +++ b/packages/agent/src/agent/http/calculateReplicaTime.test.ts @@ -1,6 +1,6 @@ import { calculateReplicaTime } from './calculateReplicaTime'; const exampleMessage = `Specified ingress_expiry not within expected range: Minimum allowed expiry: 2024-08-13 22:49:30.148075776 UTC, Maximum allowed expiry: 2024-08-13 22:55:00.148075776 UTC, Provided expiry: 2021-01-01 00:04:00 UTC`; -test.only('calculateReplicaTime', () => { +test('calculateReplicaTime', () => { calculateReplicaTime(exampleMessage); //? }); diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index f315715ec..a2f119094 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -828,7 +828,7 @@ test('it should sync time with the replica for a query', async () => { }); // jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); - const agent = await HttpAgent.create({ host: 'https://icp-api.io' }); + const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch }); const actor = Actor.createActor(idlFactory, { agent, @@ -849,7 +849,7 @@ test('it should sync time with the replica for a query', async () => { const result = await actor.whoami(); expect(Principal.from(result)).toBeInstanceOf(Principal); }); -test.only('it should sync time with the replica for an update', async () => { +test('it should sync time with the replica for an update', async () => { const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; const idlFactory = () => { return IDL.Service({ @@ -865,7 +865,7 @@ test.only('it should sync time with the replica for an update', async () => { }); // jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); - const agent = await HttpAgent.create({ host: 'https://icp-api.io' }); + const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch }); const actor = Actor.createActor(idlFactory, { agent, @@ -878,13 +878,11 @@ test.only('it should sync time with the replica for an update', async () => { // handle the replica time error if (err.name === 'ReplicaTimeError') { const error = err as ReplicaTimeError; - error; // use the replica time to sync the agent error.agent.replicaTime = error.replicaTime; - error.agent.replicaTime; //? + // retry the call const result = await actor.whoami(); expect(Principal.from(result)).toBeInstanceOf(Principal); } } - // retry the call }); diff --git a/packages/agent/src/canisterStatus/index.ts b/packages/agent/src/canisterStatus/index.ts index fdf4e26a3..2977ceeef 100644 --- a/packages/agent/src/canisterStatus/index.ts +++ b/packages/agent/src/canisterStatus/index.ts @@ -145,8 +145,6 @@ export const request = async (options: { paths: encodedPaths, }); - agent.replicaTime; //? - const cert = await Certificate.create({ certificate: response.certificate, rootKey: agent.rootKey, @@ -235,11 +233,6 @@ export const request = async (options: { if ((error as AgentError)?.message?.includes('Invalid certificate')) { throw new AgentError((error as AgentError).message); } - // if (typeof path !== 'string' && 'key' in path && 'path' in 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); diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index 326dbaebe..e87ac4a5c 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -170,7 +170,6 @@ export class Certificate { */ public static async create(options: CreateCertificateOptions): Promise { const cert = Certificate.createUnverified(options); - await cert.verify(); return cert; } @@ -272,6 +271,7 @@ export class Certificate { rootKey: this._rootKey, canisterId: this._canisterId, blsVerify: this._blsVerify, + certTime: this.#certTime, // Do not check max age for delegation certificates maxAgeInMinutes: Infinity, }); diff --git a/packages/agent/src/polling/index.ts b/packages/agent/src/polling/index.ts index e50c4f7e4..e9eeaea56 100644 --- a/packages/agent/src/polling/index.ts +++ b/packages/agent/src/polling/index.ts @@ -1,5 +1,5 @@ import { Principal } from '@dfinity/principal'; -import { Agent, RequestStatusResponseStatus } from '../agent'; +import { Agent, HttpAgent, RequestStatusResponseStatus } from '../agent'; import { Certificate, CreateCertificateOptions, lookupResultToBuffer } from '../certificate'; import { RequestId } from '../request_id'; import { toHex } from '../utils/buffer'; @@ -39,10 +39,17 @@ export async function pollForResponse( const currentRequest = request ?? (await agent.createReadStateRequest?.({ paths: [path] })); const state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest); if (agent.rootKey == null) throw new Error('Agent root key not initialized before polling'); + + // if agent has replicaTime, otherwise omit + const certTime = (agent as HttpAgent)?.replicaTime + ? (agent as HttpAgent)?.replicaTime + : undefined; + const cert = await Certificate.create({ certificate: state.certificate, rootKey: agent.rootKey, canisterId: canisterId, + certTime: certTime, blsVerify, }); From 6e8f206694d2747e65ae43f216023b8262f7331f Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Mon, 9 Sep 2024 15:19:38 -0700 Subject: [PATCH 4/6] cleaning up --- .../calculateReplicaTime.test.ts.snap | 3 +++ .../src/agent/http/calculateReplicaTime.test.ts | 3 ++- packages/agent/src/agent/http/index.ts | 16 +++------------- packages/agent/src/agent/http/transforms.ts | 5 +---- 4 files changed, 9 insertions(+), 18 deletions(-) create mode 100644 packages/agent/src/agent/http/__snapshots__/calculateReplicaTime.test.ts.snap diff --git a/packages/agent/src/agent/http/__snapshots__/calculateReplicaTime.test.ts.snap b/packages/agent/src/agent/http/__snapshots__/calculateReplicaTime.test.ts.snap new file mode 100644 index 000000000..6f7dd4b3f --- /dev/null +++ b/packages/agent/src/agent/http/__snapshots__/calculateReplicaTime.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`calculateReplicaTime 1`] = `2024-08-13T22:49:30.148Z`; diff --git a/packages/agent/src/agent/http/calculateReplicaTime.test.ts b/packages/agent/src/agent/http/calculateReplicaTime.test.ts index 1e350a0ad..ba770587c 100644 --- a/packages/agent/src/agent/http/calculateReplicaTime.test.ts +++ b/packages/agent/src/agent/http/calculateReplicaTime.test.ts @@ -2,5 +2,6 @@ import { calculateReplicaTime } from './calculateReplicaTime'; const exampleMessage = `Specified ingress_expiry not within expected range: Minimum allowed expiry: 2024-08-13 22:49:30.148075776 UTC, Maximum allowed expiry: 2024-08-13 22:55:00.148075776 UTC, Provided expiry: 2021-01-01 00:04:00 UTC`; test('calculateReplicaTime', () => { - calculateReplicaTime(exampleMessage); //? + const parsedTime = calculateReplicaTime(exampleMessage); + expect(parsedTime).toMatchSnapshot(); }); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 9bc864248..aa7203045 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -270,7 +270,6 @@ export class HttpAgent implements Agent { } set replicaTime(replicaTime: Date) { - replicaTime; this.#initialClientTime = new Date(Date.now()); this.#initialReplicaTime = replicaTime; } @@ -448,7 +447,6 @@ export class HttpAgent implements Agent { let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); - this.replicaTime; //? // If the value is off by more than 30 seconds, reconcile system time with the network const timeDiffMsecs = this.replicaTime && this.replicaTime.getTime() - Date.now(); if (Math.abs(timeDiffMsecs) > 1_000 * 30) { @@ -519,7 +517,6 @@ export class HttpAgent implements Agent { }); }; - const request = this.#requestAndRetry({ request: callSync ? requestSync : requestAsync, backoff, @@ -642,6 +639,7 @@ export class HttpAgent implements Agent { ); } } catch (error) { + this.log.error('Caught exception while attempting to read state', error as AgentError); this.#handleReplicaTimeError(error as AgentError); if (tries < this.#retryTimes) { this.log.warn( @@ -768,8 +766,6 @@ export class HttpAgent implements Agent { }); } - #errorTimes = 0; - public async query( canisterId: Principal | string, fields: QueryFields, @@ -860,8 +856,7 @@ export class HttpAgent implements Agent { }; // Attempt to make the query i=retryTimes times // Make query and fetch subnet keys in parallel - // const [queryResult, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]); - const [queryResult] = await Promise.all([makeQuery()]); + const [queryResult, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]); const { requestDetails, query } = queryResult; const queryWithDetails = { @@ -876,7 +871,7 @@ export class HttpAgent implements Agent { } try { - return this.#verifyQueryResponse(queryWithDetails, await getSubnetStatus()); + return this.#verifyQueryResponse(queryWithDetails, subnetStatus); } 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.'); @@ -980,9 +975,6 @@ export class HttpAgent implements Agent { } const sender = id?.getPrincipal() || Principal.anonymous(); - // TODO: remove this any. This can be a Signed or UnSigned request. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); // If the value is off by more than 30 seconds, reconcile system time with the network @@ -1063,13 +1055,11 @@ export class HttpAgent implements Agent { } return decodedResponse; } catch (error) { - this.#errorTimes++; this.#handleReplicaTimeError(error as AgentError); } throw new AgentError('Failed to read state'); } - #handleReplicaTimeError = (error: AgentError): void => { const message = error.message; if (message?.includes('ingress_expiry')) { diff --git a/packages/agent/src/agent/http/transforms.ts b/packages/agent/src/agent/http/transforms.ts index d21a74837..934dfbfb4 100644 --- a/packages/agent/src/agent/http/transforms.ts +++ b/packages/agent/src/agent/http/transforms.ts @@ -17,21 +17,18 @@ export class Expiry { private readonly _value: bigint; constructor(deltaInMSec: number) { - deltaInMSec; // Use bigint because it can overflow the maximum number allowed in a double float. const raw_value = BigInt(Math.floor(Date.now() + deltaInMSec - REPLICA_PERMITTED_DRIFT_MILLISECONDS)) * NANOSECONDS_PER_MILLISECONDS; - new Date(Number(raw_value / NANOSECONDS_PER_MILLISECONDS)); //? - // round down to the nearest second const ingress_as_seconds = raw_value / BigInt(1_000_000_000); // round down to nearest minute const ingress_as_minutes = ingress_as_seconds / BigInt(60); - const rounded_down_nanos = ingress_as_minutes * BigInt(60) * BigInt(1_000_000_000); //? + const rounded_down_nanos = ingress_as_minutes * BigInt(60) * BigInt(1_000_000_000); this._value = rounded_down_nanos; } From eec61ee82a33f56d3ff486e63a2620a2f1566d7c Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Mon, 9 Sep 2024 15:28:44 -0700 Subject: [PATCH 5/6] reverts canisterStatus changes --- .../agent/src/canisterStatus/index.test.ts | 1 - packages/agent/src/canisterStatus/index.ts | 187 +++++++++--------- 2 files changed, 97 insertions(+), 91 deletions(-) diff --git a/packages/agent/src/canisterStatus/index.test.ts b/packages/agent/src/canisterStatus/index.test.ts index 340966767..cac1fe0a3 100644 --- a/packages/agent/src/canisterStatus/index.test.ts +++ b/packages/agent/src/canisterStatus/index.test.ts @@ -63,7 +63,6 @@ const getRealStatus = async () => { const agent = new HttpAgent({ host: 'http://127.0.0.1:4943', fetch, identity }); await agent.fetchRootKey(); const canisterBuffer = new DataView(testPrincipal.toUint8Array().buffer).buffer; - canisterBuffer; const response = await agent.readState( testPrincipal, // Note: subnet is not currently working due to a bug diff --git a/packages/agent/src/canisterStatus/index.ts b/packages/agent/src/canisterStatus/index.ts index 2977ceeef..c46ba2ca6 100644 --- a/packages/agent/src/canisterStatus/index.ts +++ b/packages/agent/src/canisterStatus/index.ts @@ -137,107 +137,114 @@ export const request = async (options: { const encodedPaths = uniquePaths.map(path => { return encodePath(path, canisterId); }); - const status = new Map(); - try { - const response = await agent.readState(canisterId, { - paths: encodedPaths, - }); - - const cert = await Certificate.create({ - certificate: response.certificate, - rootKey: agent.rootKey, - canisterId: canisterId, - certTime: agent.replicaTime, - }); - - const lookup = (cert: Certificate, path: Path) => { - if (path === 'subnet') { - const data = fetchNodeKeys(response.certificate, canisterId, agent.rootKey); - return { - path: path, - data, - }; - } else { - return { - path: path, - data: lookupResultToBuffer(cert.lookup(encodePath(path, canisterId))), + const promises = uniquePaths.map((path, index) => { + return (async () => { + try { + const response = await agent.readState(canisterId, { + paths: [encodedPaths[index]], + }); + const cert = await Certificate.create({ + certificate: response.certificate, + rootKey: agent.rootKey, + canisterId: canisterId, + }); + + const lookup = (cert: Certificate, path: Path) => { + if (path === 'subnet') { + const data = fetchNodeKeys(response.certificate, canisterId, agent.rootKey); + return { + path: path, + data, + }; + } else { + return { + path: path, + data: lookupResultToBuffer(cert.lookup(encodePath(path, canisterId))), + }; + } }; - } - }; - // must pass in the rootKey if we have no delegation - uniquePaths.forEach(unique => { - const { path, data } = lookup(cert, unique); - if (!data) { - // Typically, the cert lookup will throw - 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 'controllers': { - status.set(path, decodeControllers(data)); - break; - } - case 'module_hash': { - status.set(path, decodeHex(data)); - break; + // must pass in the rootKey if we have no delegation + const { path, data } = lookup(cert, uniquePaths[index]); + if (!data) { + // Typically, the cert lookup will throw + 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); } - case 'subnet': { - status.set(path, data); - break; - } - case 'candid': { - status.set(path, new TextDecoder().decode(data)); - break; - } - default: { - // Check for CustomPath signature - if (typeof path !== 'string' && 'key' in path && 'path' in path) { - switch (path.decodeStrategy) { - case 'raw': - status.set(path.key, data); - break; - case 'leb128': { - status.set(path.key, decodeLeb128(data)); - break; - } - case 'cbor': { - status.set(path.key, decodeCbor(data)); - break; - } - case 'hex': { - status.set(path.key, decodeHex(data)); - break; - } - case 'utf-8': { - status.set(path.key, decodeUtf8(data)); + } else { + switch (path) { + case 'time': { + status.set(path, decodeTime(data)); + break; + } + case 'controllers': { + status.set(path, decodeControllers(data)); + break; + } + case 'module_hash': { + status.set(path, decodeHex(data)); + break; + } + case 'subnet': { + status.set(path, data); + break; + } + case 'candid': { + status.set(path, new TextDecoder().decode(data)); + break; + } + default: { + // Check for CustomPath signature + if (typeof path !== 'string' && 'key' in path && 'path' in path) { + switch (path.decodeStrategy) { + case 'raw': + status.set(path.key, data); + break; + case 'leb128': { + status.set(path.key, decodeLeb128(data)); + break; + } + case 'cbor': { + status.set(path.key, decodeCbor(data)); + break; + } + case 'hex': { + status.set(path.key, decodeHex(data)); + break; + } + case 'utf-8': { + status.set(path.key, decodeUtf8(data)); + } } } } } } + } catch (error) { + // Break on signature verification errors + if ((error as AgentError)?.message?.includes('Invalid certificate')) { + throw new AgentError((error as AgentError).message); + } + if (typeof path !== 'string' && 'key' in path && 'path' in 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(); } - }); - } catch (error) { - // Break on signature verification errors - if ((error as AgentError)?.message?.includes('Invalid certificate')) { - throw new AgentError((error as AgentError).message); - } - 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; }; From 113f352ce06ba8b55fd382a728885cafaa369457 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Tue, 10 Sep 2024 16:56:22 -0700 Subject: [PATCH 6/6] all certificates now read from `agent.replicaTime` --- e2e/node/basic/mainnet.test.ts | 80 +++++++++++++++++++++- packages/agent/src/actor.ts | 9 ++- packages/agent/src/agent/http/http.test.ts | 74 -------------------- packages/agent/src/agent/http/index.ts | 1 + packages/agent/src/canisterStatus/index.ts | 2 + packages/assets/src/index.ts | 6 ++ 6 files changed, 95 insertions(+), 77 deletions(-) diff --git a/e2e/node/basic/mainnet.test.ts b/e2e/node/basic/mainnet.test.ts index b3a530ad2..bc1ee7235 100644 --- a/e2e/node/basic/mainnet.test.ts +++ b/e2e/node/basic/mainnet.test.ts @@ -7,6 +7,7 @@ import { fromHex, polling, requestIdOf, + ReplicaTimeError, } from '@dfinity/agent'; import { IDL } from '@dfinity/candid'; import { Ed25519KeyIdentity } from '@dfinity/identity'; @@ -21,7 +22,7 @@ const createWhoamiActor = async (identity: Identity) => { const idlFactory = () => { return IDL.Service({ whoami: IDL.Func([], [IDL.Principal], ['query']), - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as unknown as any; }; vi.useFakeTimers(); @@ -142,7 +143,6 @@ describe('call forwarding', () => { }, 15_000); }); - test('it should allow you to set an incorrect root key', async () => { const agent = HttpAgent.createSync({ rootKey: new Uint8Array(31), @@ -159,3 +159,79 @@ test('it should allow you to set an incorrect root key', async () => { expect(actor.whoami).rejects.toThrowError(`Invalid certificate:`); }); + +test('it should throw an error when the clock is out of sync during a query', async () => { + const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; + const idlFactory = () => { + return IDL.Service({ + whoami: IDL.Func([], [IDL.Principal], ['query']), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as unknown as any; + }; + vi.useRealTimers(); + + // set date to long ago + vi.spyOn(Date, 'now').mockImplementation(() => { + return new Date('2021-01-01T00:00:00Z').getTime(); + }); + // vi.setSystemTime(new Date('2021-01-01T00:00:00Z')); + + const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch }); + + const actor = Actor.createActor(idlFactory, { + agent, + canisterId, + }); + try { + // should throw an error + await actor.whoami(); + } catch (err) { + // handle the replica time error + if (err.name === 'ReplicaTimeError') { + const error = err as ReplicaTimeError; + // use the replica time to sync the agent + error.agent.replicaTime = error.replicaTime; + } + } + // retry the call + const result = await actor.whoami(); + expect(Principal.from(result)).toBeInstanceOf(Principal); +}); + +test('it should throw an error when the clock is out of sync during an update', async () => { + const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; + const idlFactory = () => { + return IDL.Service({ + whoami: IDL.Func([], [IDL.Principal], []), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as unknown as any; + }; + vi.useRealTimers(); + + // set date to long ago + vi.spyOn(Date, 'now').mockImplementation(() => { + return new Date('2021-01-01T00:00:00Z').getTime(); + }); + // vi.setSystemTime(new Date('2021-01-01T00:00:00Z')); + + const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch }); + + const actor = Actor.createActor(idlFactory, { + agent, + canisterId, + }); + try { + // should throw an error + await actor.whoami(); + } catch (err) { + // handle the replica time error + if (err.name === 'ReplicaTimeError') { + const error = err as ReplicaTimeError; + // use the replica time to sync the agent + error.agent.replicaTime = error.replicaTime; + // retry the call + const result = await actor.whoami(); + expect(Principal.from(result)).toBeInstanceOf(Principal); + } + } +}); diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index f347f6fab..f447da3ed 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -2,6 +2,7 @@ import { Buffer } from 'buffer/'; import { Agent, getDefaultAgent, + HttpAgent, HttpDetailsResponse, QueryResponseRejected, QueryResponseStatus, @@ -535,13 +536,19 @@ function _createActorMethod( }); let reply: ArrayBuffer | undefined; let certificate: Certificate | undefined; + const certTime = (agent as HttpAgent).replicaTime + ? (agent as HttpAgent).replicaTime + : undefined; + + certTime; + if (response.body && response.body.certificate) { const cert = response.body.certificate; certificate = await Certificate.create({ certificate: bufFromBufLike(cert), rootKey: agent.rootKey, canisterId: Principal.from(canisterId), - blsVerify, + certTime, }); const path = [new TextEncoder().encode('request_status'), requestId]; const status = new TextDecoder().decode( diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 2a5089ef1..0cf25bc9e 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -815,77 +815,3 @@ test('it should log errors to console if the option is set', async () => { }); jest.setTimeout(5000); -test('it should sync time with the replica for a query', async () => { - const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; - const idlFactory = () => { - return IDL.Service({ - whoami: IDL.Func([], [IDL.Principal], ['query']), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as unknown as any; - }; - jest.useRealTimers(); - - // set date to long ago - jest.spyOn(Date, 'now').mockImplementation(() => { - return new Date('2021-01-01T00:00:00Z').getTime(); - }); - // jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); - - const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch }); - - const actor = Actor.createActor(idlFactory, { - agent, - canisterId, - }); - try { - // should throw an error - await actor.whoami(); - } catch (err) { - // handle the replica time error - if (err.name === 'ReplicaTimeError') { - const error = err as ReplicaTimeError; - // use the replica time to sync the agent - error.agent.replicaTime = error.replicaTime; - } - } - // retry the call - const result = await actor.whoami(); - expect(Principal.from(result)).toBeInstanceOf(Principal); -}); -test('it should sync time with the replica for an update', async () => { - const canisterId = 'ivcos-eqaaa-aaaab-qablq-cai'; - const idlFactory = () => { - return IDL.Service({ - whoami: IDL.Func([], [IDL.Principal], []), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as unknown as any; - }; - jest.useRealTimers(); - - // set date to long ago - jest.spyOn(Date, 'now').mockImplementation(() => { - return new Date('2021-01-01T00:00:00Z').getTime(); - }); - // jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); - - const agent = await HttpAgent.create({ host: 'https://icp-api.io', fetch: globalThis.fetch }); - - const actor = Actor.createActor(idlFactory, { - agent, - canisterId, - }); - try { - // should throw an error - await actor.whoami(); - } catch (err) { - // handle the replica time error - if (err.name === 'ReplicaTimeError') { - const error = err as ReplicaTimeError; - // use the replica time to sync the agent - error.agent.replicaTime = error.replicaTime; - // retry the call - const result = await actor.whoami(); - expect(Principal.from(result)).toBeInstanceOf(Principal); - } - } -}); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index aa7203045..07c0b9562 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -1100,6 +1100,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 * @param {Principal} canisterId - Pass a canister ID if you need to sync the time with a particular replica. Uses the management canister by default + * @throws {ReplicaTimeError} - this method is not guaranteed to work if the device's clock is off by more than 30 seconds. In such cases, the agent will throw an error. */ public async syncTime(canisterId?: Principal): Promise { const CanisterStatus = await import('../../canisterStatus'); diff --git a/packages/agent/src/canisterStatus/index.ts b/packages/agent/src/canisterStatus/index.ts index c46ba2ca6..a0f9b5fe1 100644 --- a/packages/agent/src/canisterStatus/index.ts +++ b/packages/agent/src/canisterStatus/index.ts @@ -145,10 +145,12 @@ export const request = async (options: { const response = await agent.readState(canisterId, { paths: [encodedPaths[index]], }); + const certTime = agent.replicaTime ? agent.replicaTime : undefined; const cert = await Certificate.create({ certificate: response.certificate, rootKey: agent.rootKey, canisterId: canisterId, + certTime, }); const lookup = (cert: Certificate, path: Path) => { diff --git a/packages/assets/src/index.ts b/packages/assets/src/index.ts index db2187bfe..73e6fd273 100644 --- a/packages/assets/src/index.ts +++ b/packages/assets/src/index.ts @@ -7,6 +7,7 @@ import { compare, getDefaultAgent, HashTree, + HttpAgent, lookup_path, lookupResultToBuffer, LookupStatus, @@ -530,10 +531,15 @@ class Asset { return false; } + const replicaTime = (agent as HttpAgent).replicaTime + ? (agent as HttpAgent).replicaTime + : undefined; + const cert = await Certificate.create({ certificate: new Uint8Array(certificate), rootKey: agent.rootKey, canisterId, + certTime: replicaTime, }).catch(() => Promise.resolve()); if (!cert) {