From 9d0ab85467c1f66cfe820f209a0c15476e8af19e Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Fri, 4 Oct 2024 15:19:45 -0700 Subject: [PATCH 1/6] feat: introduces callAndPoll utility returning the full undecoded certificate from the response --- docs/CHANGELOG.md | 4 ++ e2e/node/basic/callAndPoll.test.ts | 17 ++++++++ packages/agent/src/certificate.ts | 2 + packages/agent/src/index.ts | 1 + packages/agent/src/utils/callAndPoll.ts | 54 +++++++++++++++++++++++++ 5 files changed, 78 insertions(+) create mode 100644 e2e/node/basic/callAndPoll.test.ts create mode 100644 packages/agent/src/utils/callAndPoll.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c63cf0dd..e2f8cd0e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- feat: new `callAndPoll` function for an agent to call a canister and poll for the response + ## [2.1.2] - 2024-09-30 - fix: revert https://github.com/dfinity/agent-js/pull/923 allow option to set agent replica time diff --git a/e2e/node/basic/callAndPoll.test.ts b/e2e/node/basic/callAndPoll.test.ts new file mode 100644 index 00000000..9befe1da --- /dev/null +++ b/e2e/node/basic/callAndPoll.test.ts @@ -0,0 +1,17 @@ +import { HttpAgent, fromHex, callAndPoll } from '@dfinity/agent'; +import { expect, describe, it, vi } from 'vitest'; +describe('call and poll', () => { + it('should handle call and poll', async () => { + vi.useRealTimers(); + + const options = { + canisterId: 'tnnnb-2yaaa-aaaab-qaiiq-cai', + methodName: 'inc_read', + agent: await HttpAgent.create({ host: 'https://icp-api.io' }), + arg: fromHex('4449444c0000'), + }; + + const certificate = await callAndPoll(options); + expect(certificate instanceof ArrayBuffer).toBe(true); + }); +}); diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index 8ddac8ca..9303a4fd 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -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 = certificate; this.cert = cbor.decode(new Uint8Array(certificate)); } 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..2a4521d3 --- /dev/null +++ b/packages/agent/src/utils/callAndPoll.ts @@ -0,0 +1,54 @@ +import { Principal } from '@dfinity/principal'; +import { Agent, Certificate, bufFromBufLike, polling } from '..'; +import { AgentError } from '../errors'; + +/** + * 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.canisterId - The canister id to call + * @param options.methodName - 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: { + canisterId: Principal | string; + methodName: string; + agent: Agent; + arg: ArrayBuffer; +}): Promise { + const { canisterId, methodName, agent, arg } = options; + const cid = Principal.from(options.canisterId); + + const { defaultStrategy } = polling.strategy; + + if (agent.rootKey == null) throw new Error('Agent root key not initialized before making call'); + + const { requestId, response } = await agent.call(cid, { + methodName, + arg, + effectiveCanisterId: cid, + }); + + let certificate: Certificate; + if (response.body && response.body.certificate) { + const cert = response.body.certificate; + // Create certificate to validate the responses + certificate = await Certificate.create({ + certificate: bufFromBufLike(cert), + rootKey: agent.rootKey, + canisterId: Principal.from(canisterId), + }); + } else { + throw new AgentError('unexpected call error: no certificate in response'); + } + // Fall back to polling if we recieve an Accepted response code + 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); + certificate = response.certificate; + } + + return certificate.rawCert; +} From d983a9581e3ea1845b99f440c83fc9a2e95fa128 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Fri, 11 Oct 2024 14:52:52 -0700 Subject: [PATCH 2/6] merging in trap changes --- e2e/node/basic/trap.test.ts | 9 ++++++++- packages/agent/src/utils/callAndPoll.ts | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/e2e/node/basic/trap.test.ts b/e2e/node/basic/trap.test.ts index d6bee77e..160d3160 100644 --- a/e2e/node/basic/trap.test.ts +++ b/e2e/node/basic/trap.test.ts @@ -4,7 +4,14 @@ import util from 'util'; import exec from 'child_process'; const execAsync = util.promisify(exec.exec); -const { stdout } = await execAsync('dfx canister id trap'); +// eslint-disable-next-line prefer-const +let stdout; +try { + ({ stdout } = await execAsync('dfx canister id trap')); +} catch { + await execAsync('dfx deploy trap'); + ({ stdout } = await execAsync('dfx canister id trap')); +} export const idlFactory = ({ IDL }) => { return IDL.Service({ diff --git a/packages/agent/src/utils/callAndPoll.ts b/packages/agent/src/utils/callAndPoll.ts index 2a4521d3..52ae8157 100644 --- a/packages/agent/src/utils/callAndPoll.ts +++ b/packages/agent/src/utils/callAndPoll.ts @@ -1,5 +1,5 @@ import { Principal } from '@dfinity/principal'; -import { Agent, Certificate, bufFromBufLike, polling } from '..'; +import { Agent, Certificate, bufFromBufLike, polling, v3ResponseBody } from '..'; import { AgentError } from '../errors'; /** @@ -31,8 +31,8 @@ export async function callAndPoll(options: { }); let certificate: Certificate; - if (response.body && response.body.certificate) { - const cert = response.body.certificate; + if (response.body && (response.body as v3ResponseBody).certificate) { + const cert = (response.body as v3ResponseBody).certificate; // Create certificate to validate the responses certificate = await Certificate.create({ certificate: bufFromBufLike(cert), From a559435d8646013d35510b223031863ad44f5b83 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Tue, 15 Oct 2024 14:10:38 -0700 Subject: [PATCH 3/6] chore: reverts change to trap test --- e2e/node/basic/trap.test.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/e2e/node/basic/trap.test.ts b/e2e/node/basic/trap.test.ts index 160d3160..d6bee77e 100644 --- a/e2e/node/basic/trap.test.ts +++ b/e2e/node/basic/trap.test.ts @@ -4,14 +4,7 @@ import util from 'util'; import exec from 'child_process'; const execAsync = util.promisify(exec.exec); -// eslint-disable-next-line prefer-const -let stdout; -try { - ({ stdout } = await execAsync('dfx canister id trap')); -} catch { - await execAsync('dfx deploy trap'); - ({ stdout } = await execAsync('dfx canister id trap')); -} +const { stdout } = await execAsync('dfx canister id trap'); export const idlFactory = ({ IDL }) => { return IDL.Service({ From 059905a55c7b5806d88ef785ef6e6e817d8f459a Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Tue, 15 Oct 2024 15:02:18 -0700 Subject: [PATCH 4/6] feat: check for 200 response status, reveals additional details in new AgentCallError class --- packages/agent/src/errors.ts | 22 ++++++++++ packages/agent/src/utils/callAndPoll.ts | 56 ++++++++++++++++++------- 2 files changed, 63 insertions(+), 15 deletions(-) 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/utils/callAndPoll.ts b/packages/agent/src/utils/callAndPoll.ts index 52ae8157..08fe0833 100644 --- a/packages/agent/src/utils/callAndPoll.ts +++ b/packages/agent/src/utils/callAndPoll.ts @@ -1,6 +1,7 @@ import { Principal } from '@dfinity/principal'; import { Agent, Certificate, bufFromBufLike, polling, v3ResponseBody } from '..'; -import { AgentError } from '../errors'; +import { AgentCallError, AgentError } from '../errors'; +import { isArrayBuffer } from 'util/types'; /** * Call a canister using the v3 api and either return the response or fall back to polling @@ -30,25 +31,50 @@ export async function callAndPoll(options: { effectiveCanisterId: cid, }); - let certificate: Certificate; - if (response.body && (response.body as v3ResponseBody).certificate) { - const cert = (response.body as v3ResponseBody).certificate; - // Create certificate to validate the responses - certificate = await Certificate.create({ - certificate: bufFromBufLike(cert), - rootKey: agent.rootKey, - canisterId: Principal.from(canisterId), - }); - } else { - throw new AgentError('unexpected call error: no certificate in response'); + 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(canisterId), + }); + return certificate.rawCert; + } else { + throw new AgentCallError( + 'unexpected call error: no certificate in response', + response, + requestId, + ); + } } // Fall back to polling if we recieve an Accepted response code - if (response.status === 202) { + 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); - certificate = response.certificate; + return response.certificate.rawCert; + } 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'); } +} - return certificate.rawCert; +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 (!isArrayBuffer(body['certificate'])) { + throw new AgentError('unexpected call error: certificate is not an ArrayBuffer'); + } } From 5befbdb98b1edcee5313dee46912f4ec6c516960 Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Tue, 15 Oct 2024 16:32:02 -0700 Subject: [PATCH 5/6] wip --- packages/agent/src/agent/http/index.ts | 16 ++++++++++----- packages/agent/src/agent/http/types.ts | 10 +++++++++ packages/agent/src/utils/callAndPoll.ts | 27 +++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index b6c47bdf..baf4f742 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -412,12 +412,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.", @@ -430,11 +431,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 = { @@ -758,7 +764,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..baafb260 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; diff --git a/packages/agent/src/utils/callAndPoll.ts b/packages/agent/src/utils/callAndPoll.ts index 08fe0833..6a8617e0 100644 --- a/packages/agent/src/utils/callAndPoll.ts +++ b/packages/agent/src/utils/callAndPoll.ts @@ -1,5 +1,13 @@ import { Principal } from '@dfinity/principal'; -import { Agent, Certificate, bufFromBufLike, polling, v3ResponseBody } from '..'; +import { + Agent, + Certificate, + ContentMap, + Expiry, + bufFromBufLike, + polling, + v3ResponseBody, +} from '..'; import { AgentCallError, AgentError } from '../errors'; import { isArrayBuffer } from 'util/types'; @@ -17,7 +25,11 @@ export async function callAndPoll(options: { methodName: string; agent: Agent; arg: ArrayBuffer; -}): Promise { + ingressExpiry?: Expiry; +}): Promise<{ + certificate: ArrayBuffer; + contentMap: ContentMap; +}> { const { canisterId, methodName, agent, arg } = options; const cid = Principal.from(options.canisterId); @@ -25,11 +37,21 @@ export async function callAndPoll(options: { if (agent.rootKey == null) throw new Error('Agent root key not initialized before making call'); + const ingress_expiry = options.ingressExpiry ?? new Expiry(DEFAULT); + const { requestId, response } = await agent.call(cid, { methodName, arg, effectiveCanisterId: cid, }); + const contentMap: ContentMap = { + canister_id: Principal.from(canisterId), + request_type: 'call', + method_name: methodName, + arg, + sender: await agent.getPrincipal(), + ingress_expiry, + }; if (response.status === 200) { if ('body' in response) { @@ -43,6 +65,7 @@ export async function callAndPoll(options: { rootKey: agent.rootKey, canisterId: Principal.from(canisterId), }); + return certificate.rawCert; } else { throw new AgentCallError( From 8843eeb0fc35c7a59c19876059aa7a53a4d5538a Mon Sep 17 00:00:00 2001 From: Kai Peacock Date: Tue, 15 Oct 2024 17:12:49 -0700 Subject: [PATCH 6/6] wip --- e2e/node/basic/callAndPoll.test.ts | 8 ++- packages/agent/src/agent/api.ts | 6 ++ packages/agent/src/agent/http/index.ts | 1 + packages/agent/src/agent/http/types.ts | 3 + packages/agent/src/certificate.ts | 4 +- packages/agent/src/utils/callAndPoll.ts | 81 ++++++++++++++++++------- 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/e2e/node/basic/callAndPoll.test.ts b/e2e/node/basic/callAndPoll.test.ts index 9befe1da..7cfe2f03 100644 --- a/e2e/node/basic/callAndPoll.test.ts +++ b/e2e/node/basic/callAndPoll.test.ts @@ -1,17 +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 = { - canisterId: 'tnnnb-2yaaa-aaaab-qaiiq-cai', - methodName: 'inc_read', + 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 = await callAndPoll(options); + 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 51f181f9..ab0afc99 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -522,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, }); diff --git a/packages/agent/src/agent/http/types.ts b/packages/agent/src/agent/http/types.ts index baafb260..4dbb2a50 100644 --- a/packages/agent/src/agent/http/types.ts +++ b/packages/agent/src/agent/http/types.ts @@ -131,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 9303a4fd..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'; @@ -192,7 +192,7 @@ export class Certificate { // Default to 5 minutes private _maxAgeInMinutes: number = 5, ) { - this.rawCert = certificate; + this.rawCert = bufFromBufLike(certificate); this.cert = cbor.decode(new Uint8Array(certificate)); } diff --git a/packages/agent/src/utils/callAndPoll.ts b/packages/agent/src/utils/callAndPoll.ts index 6a8617e0..8ef5e98c 100644 --- a/packages/agent/src/utils/callAndPoll.ts +++ b/packages/agent/src/utils/callAndPoll.ts @@ -4,54 +4,60 @@ import { 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.canisterId - The canister id to call - * @param options.methodName - The method name to call + * @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: { - canisterId: Principal | string; - methodName: string; - agent: Agent; - arg: ArrayBuffer; - ingressExpiry?: Expiry; -}): Promise<{ +export async function callAndPoll(options: CallAndPollOptions): Promise<{ certificate: ArrayBuffer; contentMap: ContentMap; }> { - const { canisterId, methodName, agent, arg } = options; - const cid = Principal.from(options.canisterId); + 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.ingressExpiry ?? new Expiry(DEFAULT); + const ingress_expiry = + options.ingress_expiry ?? new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); - const { requestId, response } = await agent.call(cid, { - methodName, - arg, - effectiveCanisterId: cid, - }); const contentMap: ContentMap = { - canister_id: Principal.from(canisterId), + canister_id: Principal.from(canister_id), request_type: 'call', - method_name: methodName, + 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) { @@ -63,10 +69,13 @@ export async function callAndPoll(options: { const certificate = await Certificate.create({ certificate: bufFromBufLike(cert), rootKey: agent.rootKey, - canisterId: Principal.from(canisterId), + canisterId: Principal.from(canister_id), }); - return certificate.rawCert; + return { + certificate: certificate.rawCert, + contentMap, + }; } else { throw new AgentCallError( 'unexpected call error: no certificate in response', @@ -80,7 +89,10 @@ export async function callAndPoll(options: { const pollStrategy = defaultStrategy(); // Contains the certificate and the reply from the boundary node const response = await polling.pollForResponse(agent, cid, requestId, pollStrategy); - return response.certificate.rawCert; + return { + certificate: response.certificate.rawCert, + contentMap, + }; } else { console.error('The network returned a response but the result could not be determined.', { response, @@ -90,6 +102,21 @@ export async function callAndPoll(options: { } } +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'); @@ -97,7 +124,15 @@ function assertV3ResponseBody(body: unknown): asserts body is v3ResponseBody { if (!('certificate' in body)) { throw new AgentError('unexpected call error: no certificate in response'); } - if (!isArrayBuffer(body['certificate'])) { + 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); + } }