diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 93c6d18f..87cc058b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,12 +2,13 @@ ## [Unreleased] +### Added + +- feat: new `callAndPoll` function for an agent to call a canister and poll for the response + ## Changed - fix: recalculates body to use a fresh `Expiry` when polling for `read_state` requests. This prevents the request from exceeding the `maximum_ingress_expiry` when the replica is slow to respond. - -## [2.1.2] - 2024-09-30 -- fix: revert https://github.com/dfinity/agent-js/pull/923 allow option to set agent replica time - fix: handle v3 traps correctly, pulling the reject_code and message from the certificate in the error response like v2. Example trap error message: ```txt @@ -21,6 +22,9 @@ AgentError: Call failed: ``` - feat: the `UpdateCallRejected` error now exposes `reject_code: ReplicaRejectCode`, `reject_message: string`, and `error_code?: string` properties directly on the error object. +## [2.1.2] - 2024-09-30 +- fix: revert https://github.com/dfinity/agent-js/pull/923 allow option to set agent replica time + ## [2.1.1] - 2024-09-13 ### Added diff --git a/e2e/node/basic/callAndPoll.test.ts b/e2e/node/basic/callAndPoll.test.ts new file mode 100644 index 00000000..7cfe2f03 --- /dev/null +++ b/e2e/node/basic/callAndPoll.test.ts @@ -0,0 +1,19 @@ +import { HttpAgent, fromHex, callAndPoll } from '@dfinity/agent'; +import { Principal } from '@dfinity/principal'; +import { expect, describe, it, vi } from 'vitest'; +describe('call and poll', () => { + it('should handle call and poll', async () => { + vi.useRealTimers(); + + const options = { + canister_id: Principal.from('tnnnb-2yaaa-aaaab-qaiiq-cai'), + method_name: 'inc_read', + agent: await HttpAgent.create({ host: 'https://icp-api.io' }), + arg: fromHex('4449444c0000'), + }; + + const { certificate, contentMap } = await callAndPoll(options); + expect(certificate instanceof ArrayBuffer).toBe(true); + expect(contentMap).toMatchInlineSnapshot(); + }); +}); diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index b521116b..3a097071 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -3,6 +3,7 @@ import { RequestId } from '../request_id'; import { JsonObject } from '@dfinity/candid'; import { Identity } from '../auth'; import { CallRequest, HttpHeaderField, QueryRequest } from './http/types'; +import { Expiry } from './http'; /** * Codes used by the replica for rejecting a message. @@ -115,6 +116,11 @@ export interface CallOptions { * @see https://internetcomputer.org/docs/current/references/ic-interface-spec/#http-effective-canister-id */ effectiveCanisterId: Principal | string; + + /** + * The expiry for the ingress message. Defaults to 4 minutes, rounded down to the nearest minute. + */ + ingressExpiry?: Expiry; } export interface ReadStateResponse { diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index f3aa840e..ab0afc99 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -410,12 +410,13 @@ export class HttpAgent implements Agent { arg: ArrayBuffer; effectiveCanisterId?: Principal | string; callSync?: boolean; + ingressExpiry?: Expiry; }, identity?: Identity | Promise, ): Promise { // TODO - restore this value const callSync = options.callSync ?? true; - const id = await(identity !== undefined ? await identity : await this.#identity); + const id = await (identity !== undefined ? await identity : await this.#identity); if (!id) { throw new IdentityInvalidError( "This identity has expired due this application's security policy. Please refresh your authentication.", @@ -428,11 +429,16 @@ export class HttpAgent implements Agent { const sender: Principal = id.getPrincipal() || Principal.anonymous(); - let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); + let ingress_expiry: Expiry; - // If the value is off by more than 30 seconds, reconcile system time with the network - if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) { + // use a provided ingress expiry if it is defined + if (options.ingressExpiry) { + ingress_expiry = options.ingressExpiry; + } else if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) { + // If the value is off by more than 30 seconds, reconcile system time with the network ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + this.#timeDiffMsecs); + } else { + ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); } const submit: CallRequest = { @@ -516,6 +522,7 @@ export class HttpAgent implements Agent { // Update the watermark with the latest time from consensus if (responseBody && 'certificate' in (responseBody as v3ResponseBody)) { + responseBody['certificate'] = bufFromBufLike(responseBody['certificate']); const time = await this.parseTimeFromResponse({ certificate: (responseBody as v3ResponseBody).certificate, }); @@ -756,7 +763,7 @@ export class HttpAgent implements Agent { this.log.print(`ecid ${ecid.toString()}`); this.log.print(`canisterId ${canisterId.toString()}`); const makeQuery = async () => { - const id = await(identity !== undefined ? identity : this.#identity); + const id = await (identity !== undefined ? identity : this.#identity); if (!id) { throw new IdentityInvalidError( "This identity has expired due this application's security policy. Please refresh your authentication.", diff --git a/packages/agent/src/agent/http/types.ts b/packages/agent/src/agent/http/types.ts index f245c5cc..4dbb2a50 100644 --- a/packages/agent/src/agent/http/types.ts +++ b/packages/agent/src/agent/http/types.ts @@ -57,6 +57,16 @@ export interface HttpAgentRequestTransformFn { priority?: number; } +export interface ContentMap { + request_type: 'call' | 'query' | 'read_state'; + canister_id: Principal; + method_name: string; + arg: ArrayBuffer; + sender: Uint8Array | Principal; + ingress_expiry: Expiry; + nonce?: Nonce; +} + // The fields in a "call" submit request. export interface CallRequest extends Record { request_type: SubmitRequestType.Call; @@ -121,3 +131,6 @@ export function makeNonce(): Nonce { return buffer as Nonce; } + +export type Omit = Pick>; +export type PartialBy = Omit & Partial>; diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index 8ddac8ca..f5a546e9 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -1,7 +1,7 @@ import * as cbor from './cbor'; import { AgentError } from './errors'; import { hash } from './request_id'; -import { bufEquals, concat, fromHex, toHex } from './utils/buffer'; +import { bufEquals, bufFromBufLike, concat, fromHex, toHex } from './utils/buffer'; import { Principal } from '@dfinity/principal'; import * as bls from './utils/bls'; import { decodeTime } from './utils/leb'; @@ -150,6 +150,7 @@ export interface CreateCertificateOptions { export class Certificate { public cert: Cert; + public readonly rawCert: ArrayBuffer; /** * Create a new instance of a certificate, automatically verifying it. Throws a @@ -191,6 +192,7 @@ export class Certificate { // Default to 5 minutes private _maxAgeInMinutes: number = 5, ) { + this.rawCert = bufFromBufLike(certificate); this.cert = cbor.decode(new Uint8Array(certificate)); } diff --git a/packages/agent/src/errors.ts b/packages/agent/src/errors.ts index d2e759f9..32d5e7ef 100644 --- a/packages/agent/src/errors.ts +++ b/packages/agent/src/errors.ts @@ -1,3 +1,7 @@ +import { v2ResponseBody, v3ResponseBody } from './agent'; +import { HttpHeaderField } from './agent/http/types'; +import { RequestId } from './request_id'; + /** * An error that happens in the Agent. This is the root of all errors and should be used * everywhere in the Agent code (this package). @@ -10,3 +14,21 @@ export class AgentError extends Error { Object.setPrototypeOf(this, AgentError.prototype); } } + +export class AgentCallError extends AgentError { + public name = 'AgentCallError'; + constructor( + public readonly message: string, + public readonly response: { + ok: boolean; + status: number; + statusText: string; + body: v2ResponseBody | v3ResponseBody | null; + headers: HttpHeaderField[]; + }, + public readonly requestId: RequestId, + ) { + super(message); + Object.setPrototypeOf(this, AgentCallError.prototype); + } +} diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index cf911b7f..4465fcdb 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -11,6 +11,7 @@ export * from './der'; export * from './fetch_candid'; export * from './public_key'; export * from './request_id'; +export * from './utils/callAndPoll'; export * from './utils/bls'; export * from './utils/buffer'; export * from './utils/random'; diff --git a/packages/agent/src/utils/callAndPoll.ts b/packages/agent/src/utils/callAndPoll.ts new file mode 100644 index 00000000..8ef5e98c --- /dev/null +++ b/packages/agent/src/utils/callAndPoll.ts @@ -0,0 +1,138 @@ +import { Principal } from '@dfinity/principal'; +import { + Agent, + Certificate, + ContentMap, + Expiry, + PartialBy, + bufFromBufLike, + polling, + v3ResponseBody, +} from '..'; +import { AgentCallError, AgentError } from '../errors'; +import { isArrayBuffer } from 'util/types'; +import { DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS } from '../constants'; + +export type CallAndPollOptions = PartialBy< + Omit, + 'ingress_expiry' +> & { + agent: Agent; +}; + +/** + * Call a canister using the v3 api and either return the response or fall back to polling + * @param options - The options to use when calling the canister + * @param options.canister_id - The canister id to call + * @param options.method_name - The method name to call + * @param options.agent - The agent to use to make the call + * @param options.arg - The argument to pass to the canister + * @returns The certificate response from the canister (which includes the reply) + */ +export async function callAndPoll(options: CallAndPollOptions): Promise<{ + certificate: ArrayBuffer; + contentMap: ContentMap; +}> { + assertContentMap(options); + const { canister_id, method_name, agent, arg } = options; + const cid = Principal.from(options.canister_id); + + const { defaultStrategy } = polling.strategy; + + if (agent.rootKey == null) throw new Error('Agent root key not initialized before making call'); + + const ingress_expiry = + options.ingress_expiry ?? new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); + + const contentMap: ContentMap = { + canister_id: Principal.from(canister_id), + request_type: 'call', + method_name: method_name, + arg, + sender: await agent.getPrincipal(), + ingress_expiry, + }; + const { requestId, response } = await agent.call(cid, { + methodName: method_name, + arg, + effectiveCanisterId: cid, + ingressExpiry: ingress_expiry, + }); + + if (response.status === 200) { + if ('body' in response) { + // Ensure the response body is a v3 response + assertV3ResponseBody(response.body); + + const cert = response.body.certificate; + // Create certificate to validate the responses + const certificate = await Certificate.create({ + certificate: bufFromBufLike(cert), + rootKey: agent.rootKey, + canisterId: Principal.from(canister_id), + }); + + return { + certificate: certificate.rawCert, + contentMap, + }; + } else { + throw new AgentCallError( + 'unexpected call error: no certificate in response', + response, + requestId, + ); + } + } + // Fall back to polling if we recieve an Accepted response code + else if (response.status === 202) { + const pollStrategy = defaultStrategy(); + // Contains the certificate and the reply from the boundary node + const response = await polling.pollForResponse(agent, cid, requestId, pollStrategy); + return { + certificate: response.certificate.rawCert, + contentMap, + }; + } else { + console.error('The network returned a response but the result could not be determined.', { + response, + requestId, + }); + throw new AgentError('unexpected call error: no certificate in response'); + } +} + +function assertContentMap(contentMap: unknown): asserts contentMap is ContentMap { + if (!contentMap || typeof contentMap !== 'object') { + throw new AgentError('unexpected call error: no contentMap provided for call'); + } + if (!('canister_id' in contentMap)) { + throw new AgentError('unexpected call error: no canister_id provided for call'); + } + if (!('method_name' in contentMap)) { + throw new AgentError('unexpected call error: no method_name provided for call'); + } + if (!('arg' in contentMap)) { + throw new AgentError('unexpected call error: no arg provided for call'); + } +} + +function assertV3ResponseBody(body: unknown): asserts body is v3ResponseBody { + if (!body || typeof body !== 'object') { + throw new AgentError('unexpected call error: no body in response'); + } + if (!('certificate' in body)) { + throw new AgentError('unexpected call error: no certificate in response'); + } + if (body['certificate'] === undefined || body['certificate'] === null) { + throw new AgentError('unexpected call error: certificate is not an ArrayBuffer'); + } + try { + const cert = bufFromBufLike(body['certificate'] as ArrayBufferLike); + if (!isArrayBuffer(cert)) { + throw new AgentError('unexpected call error: certificate is not an ArrayBuffer'); + } + } catch (error) { + throw new AgentError('unexpected call error: while presenting certificate: ' + error); + } +}