From b25b40e6bd82a53c91dacabb45ea410f84278bd5 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 11 Mar 2026 16:57:32 +0000 Subject: [PATCH 01/10] feat(agent): allow specifying identity within `pollForResponse` --- packages/core/src/agent/agent/http/index.ts | 6 +++--- packages/core/src/agent/polling/index.ts | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index 7609a5c04..8e09220f3 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -1075,7 +1075,7 @@ export class HttpAgent implements Agent { public async readState( canisterId: Principal | string, fields: ReadStateOptions, - _identity?: Identity | Promise, + identity?: Identity | Promise, // eslint-disable-next-line request?: any, ): Promise { @@ -1104,8 +1104,8 @@ export class HttpAgent implements Agent { requestId = getRequestId(fields); // Always create a fresh request with the current identity - const identity = await this.#identity; - if (!identity) { + const id = await (identity ?? this.#identity); + if (!id) { throw ExternalError.fromCode(new IdentityInvalidErrorCode()); } transformedRequest = await this.createReadStateRequest(fields, identity); diff --git a/packages/core/src/agent/polling/index.ts b/packages/core/src/agent/polling/index.ts index 3d05a0395..f58994607 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -23,6 +23,7 @@ import { defaultStrategy } from './strategy.ts'; import { ReadRequestType, type ReadStateRequest } from '../agent/http/types.ts'; import { RequestStatusResponseStatus } from '../agent/index.ts'; import { utf8ToBytes } from '@noble/hashes/utils'; +import { Identity } from '../auth.ts'; export { defaultStrategy } from './strategy.ts'; export type PollStrategy = ( @@ -122,12 +123,14 @@ function isSignedReadStateRequestWithExpiry( * @param canisterId The effective canister ID. * @param requestId The Request ID to poll status for. * @param options polling options to control behavior + * @param identity - (Optional) The identity to use for the call. If not provided, the agent's current identity will be used. */ export async function pollForResponse( agent: Agent, canisterId: Principal, requestId: RequestId, options: PollingOptions = {}, + identity?: Identity | Promise, ): Promise<{ certificate: Certificate; reply: Uint8Array; @@ -147,7 +150,7 @@ export async function pollForResponse( state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest); } else { // If preSignReadStateRequest is false, we use the default strategy and sign the request each time - state = await agent.readState(canisterId, { paths: [path] }); + state = await agent.readState(canisterId, { paths: [path] }, await identity); } if (agent.rootKey == null) { @@ -190,7 +193,7 @@ export async function pollForResponse( // Pass over either the strategy already provided or the new one created above strategy, request: currentRequest, - }); + }, identity); } case RequestStatusResponseStatus.Rejected: { From b4fe59a82f13222b2a2700c49863c94e43f1fa3b Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 11 Mar 2026 17:04:00 +0000 Subject: [PATCH 02/10] Fix import --- packages/core/src/agent/polling/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agent/polling/index.ts b/packages/core/src/agent/polling/index.ts index f58994607..41eb6b1e4 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -23,7 +23,7 @@ import { defaultStrategy } from './strategy.ts'; import { ReadRequestType, type ReadStateRequest } from '../agent/http/types.ts'; import { RequestStatusResponseStatus } from '../agent/index.ts'; import { utf8ToBytes } from '@noble/hashes/utils'; -import { Identity } from '../auth.ts'; +import type { Identity } from '../auth.ts'; export { defaultStrategy } from './strategy.ts'; export type PollStrategy = ( From 266f10d5390c27aafb84efce54bf67127ce027c8 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 11 Mar 2026 17:13:50 +0000 Subject: [PATCH 03/10] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e0444188..fd64e004a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - chore: remove unused dependencies (`fake-indexeddb`, `@peculiar/webcrypto`, `@eslint/eslintrc`) - refactor(agent): extract `readCertifiedReject` helper to deduplicate certified reject logic - refactor(agent): make `CallContext.httpDetails` optional for polling error paths +- feat(agent): allow passing identity into `pollForResponse` ## [5.0.0] - 2025-12-18 From 021b9f253a74562620b520bf0448310bb9a04558 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 11 Mar 2026 17:14:43 +0000 Subject: [PATCH 04/10] Update comment --- packages/core/src/agent/polling/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/agent/polling/index.ts b/packages/core/src/agent/polling/index.ts index 41eb6b1e4..64063f82a 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -123,7 +123,7 @@ function isSignedReadStateRequestWithExpiry( * @param canisterId The effective canister ID. * @param requestId The Request ID to poll status for. * @param options polling options to control behavior - * @param identity - (Optional) The identity to use for the call. If not provided, the agent's current identity will be used. + * @param identity - (Optional) The identity to use for the polling requests. If not provided, the agent's current identity will be used. */ export async function pollForResponse( agent: Agent, From c42375b63b85ecc0912f9007cbd9f53548f2405b Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 25 Mar 2026 13:50:42 +0000 Subject: [PATCH 05/10] Apply same changes to `update`, `createReadStateRequest` and `readSubnetState` --- packages/core/src/agent/agent/api.ts | 12 ++++++++---- packages/core/src/agent/agent/http/index.ts | 17 +++++++---------- packages/core/src/agent/polling/index.ts | 2 +- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/core/src/agent/agent/api.ts b/packages/core/src/agent/agent/api.ts index 44dae98c0..cf844b2cd 100644 --- a/packages/core/src/agent/agent/api.ts +++ b/packages/core/src/agent/agent/api.ts @@ -94,7 +94,7 @@ export interface QueryFields { /** * Overrides canister id for path to fetch. This is used for management canister calls. */ - effectiveCanisterId?: Principal; + effectiveCanisterId?: Principal | string; } /** @@ -115,7 +115,7 @@ export interface CallOptions { * An effective canister ID, used for routing. Usually the canister ID, except for management canister calls. * @see https://internetcomputer.org/docs/current/references/ic-interface-spec/#http-effective-canister-id */ - effectiveCanisterId: Principal | string; + effectiveCanisterId?: Principal | string; /** * An optional nonce to use for the call, used to prevent replay attacks. @@ -199,7 +199,10 @@ export interface Agent { * `readState` uses this internally. * Useful to avoid signing the same request multiple times. */ - createReadStateRequest?(options: ReadStateOptions, identity?: Identity): Promise; + createReadStateRequest?( + options: ReadStateOptions, + identity?: Identity | Promise, + ): Promise; /** * Send a read state query to the replica. This includes a list of paths to return, @@ -213,7 +216,7 @@ export interface Agent { readState( effectiveCanisterId: Principal | string, options: ReadStateOptions, - identity?: Identity, + identity?: Identity | Promise, request?: unknown, ): Promise; @@ -230,6 +233,7 @@ export interface Agent { canisterId: Principal | string, fields: CallOptions, pollingOptions?: PollingOptions, + identity?: Identity | Promise, ): Promise; /** diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index f5ebb92ce..dbb3d466b 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -673,9 +673,10 @@ export class HttpAgent implements Agent { canisterId: Principal | string, fields: CallOptions, pollingOptions: PollingOptions = {}, + identity?: Identity | Promise, ): Promise { - const effectiveCanisterId = Principal.from(fields.effectiveCanisterId); - const { requestId, response, requestDetails } = await this.call(canisterId, fields); + const effectiveCanisterId = Principal.from(fields.effectiveCanisterId ?? canisterId); + const { requestId, response, requestDetails } = await this.call(canisterId, fields, identity); const { body, ...httpDetails } = response; if (isV4ResponseBody(body)) { @@ -701,6 +702,7 @@ export class HttpAgent implements Agent { effectiveCanisterId, requestId, pollingOptions, + identity, ); return { ...pollResult, requestDetails, callResponse: response }; } @@ -1021,9 +1023,7 @@ export class HttpAgent implements Agent { identity?: Identity | Promise, ): Promise { const backoff = this.#backoffStrategy(); - const ecid = fields.effectiveCanisterId - ? Principal.from(fields.effectiveCanisterId) - : Principal.from(canisterId); + const ecid = Principal.from(fields.effectiveCanisterId ?? canisterId); await this.#asyncGuard(ecid); this.log.print(`ecid ${ecid.toString()}`); @@ -1269,10 +1269,6 @@ export class HttpAgent implements Agent { requestId = getRequestId(fields); // Always create a fresh request with the current identity - const id = await (identity ?? this.#identity); - if (!id) { - throw ExternalError.fromCode(new IdentityInvalidErrorCode()); - } transformedRequest = await this.createReadStateRequest(fields, identity); } @@ -1290,6 +1286,7 @@ export class HttpAgent implements Agent { public async readSubnetState( subnetId: Principal | string, options: ReadStateOptions, + identity?: Identity | Promise, ): Promise { await this.#rootKeyGuard(); const subnet = Principal.from(subnetId); @@ -1297,7 +1294,7 @@ export class HttpAgent implements Agent { const url = new URL(`/api/v3/subnet/${subnet.toString()}/read_state`, this.host); const transformedRequest: ReadStateRequest = await this.createReadStateRequest( options, - this.#identity ?? undefined, + identity, ); return await this.#readStateInner(url, { subnetId: subnet }, transformedRequest); diff --git a/packages/core/src/agent/polling/index.ts b/packages/core/src/agent/polling/index.ts index ee49bc14e..bf4b7e062 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -147,7 +147,7 @@ export async function pollForResponse( state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest); } else { // If preSignReadStateRequest is false, we use the default strategy and sign the request each time - state = await agent.readState(canisterId, { paths: [path] }, await identity); + state = await agent.readState(canisterId, { paths: [path] }, identity); } if (agent.rootKey == null) { From 16adc09afa93b0a8e9626161779ea0dfd6ecbc6d Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 25 Mar 2026 14:02:31 +0000 Subject: [PATCH 06/10] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38752268f..79051bcd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ ### Feat -- **agent**: allow passing identity into `pollForResponse` +- **agent**: allow passing identity into all canister requests exposed by `HttpAgent` ## v5.2.0 (2026-03-24) From 4a3334ac452de85e99a79a3ab4439991c6613716 Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 25 Mar 2026 14:22:30 +0000 Subject: [PATCH 07/10] Add test --- .../src/agent/agent/http/http-update.test.ts | 19 +++++++++++++++---- packages/core/src/agent/polling/index.ts | 2 +- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/packages/core/src/agent/agent/http/http-update.test.ts b/packages/core/src/agent/agent/http/http-update.test.ts index 3681b3029..b0412c0bd 100644 --- a/packages/core/src/agent/agent/http/http-update.test.ts +++ b/packages/core/src/agent/agent/http/http-update.test.ts @@ -6,6 +6,7 @@ import type { LookupPathResultFound, LookupPathStatus } from '../../certificate. import { ExternalError, RejectError, UnknownError } from '../../errors.ts'; import type { Expiry } from './transforms.ts'; import { type CallRequest, SubmitRequestType } from './types.ts'; +import { ECDSAKeyIdentity } from '../../../identity'; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -191,7 +192,7 @@ describe('HttpAgent.update', () => { await agent.update(canisterId, callFields); - expect(agent.call).toHaveBeenCalledWith(canisterId, callFields); + expect(agent.call).toHaveBeenCalledWith(canisterId, callFields, undefined); }); it('throws RejectError with reject details when polling encounters a rejection', async () => { @@ -230,7 +231,7 @@ describe('HttpAgent.update', () => { await agent.update(canisterId, fieldsWithEcid); - expect(agent.call).toHaveBeenCalledWith(canisterId, fieldsWithEcid); + expect(agent.call).toHaveBeenCalledWith(canisterId, fieldsWithEcid, undefined); }); it('accepts canisterId as a string', async () => { @@ -240,7 +241,7 @@ describe('HttpAgent.update', () => { const result = await agent.update(canisterId.toText(), callFields); expect(result.reply).toEqual(new Uint8Array([42])); - expect(agent.call).toHaveBeenCalledWith(canisterId.toText(), callFields); + expect(agent.call).toHaveBeenCalledWith(canisterId.toText(), callFields, undefined); }); it('passes nonce through to agent.call', async () => { @@ -251,7 +252,17 @@ describe('HttpAgent.update', () => { const fieldsWithNonce = { ...callFields, nonce }; await agent.update(canisterId, fieldsWithNonce); - expect(agent.call).toHaveBeenCalledWith(canisterId, fieldsWithNonce); + expect(agent.call).toHaveBeenCalledWith(canisterId, fieldsWithNonce, undefined); + }); + + it('passes identity through to agent.call', async () => { + const agent = createAgentWithCallMock(); + replyByRequestKey.set(requestId, new Uint8Array([42])); + + const identity = await ECDSAKeyIdentity.generate(); + await agent.update(canisterId, callFields, undefined, identity); + + expect(agent.call).toHaveBeenCalledWith(canisterId, callFields, identity); }); it('includes callResponse in the result', async () => { diff --git a/packages/core/src/agent/polling/index.ts b/packages/core/src/agent/polling/index.ts index bf4b7e062..5ff190001 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -144,7 +144,7 @@ export async function pollForResponse( agent, pollingOptions: options, }); - state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest); + state = await agent.readState(canisterId, { paths: [path] }, identity, currentRequest); } else { // If preSignReadStateRequest is false, we use the default strategy and sign the request each time state = await agent.readState(canisterId, { paths: [path] }, identity); From e1debe1840bfde3dbcbb0ea0ece0400f0641ee6c Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 25 Mar 2026 14:45:50 +0000 Subject: [PATCH 08/10] Add missing doc comments --- packages/core/src/agent/agent/api.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agent/agent/api.ts b/packages/core/src/agent/agent/api.ts index cf844b2cd..8b87f706b 100644 --- a/packages/core/src/agent/agent/api.ts +++ b/packages/core/src/agent/agent/api.ts @@ -198,6 +198,8 @@ export interface Agent { * Create the request for the read state call. * `readState` uses this internally. * Useful to avoid signing the same request multiple times. + * @param options The options for this call. + * @param identity The Identity to use. If not specified, uses the instance identity. */ createReadStateRequest?( options: ReadStateOptions, @@ -210,7 +212,7 @@ export interface Agent { * but the certificate might contain less information than requested. * @param effectiveCanisterId A Canister ID related to this call. * @param options The options for this call. - * @param identity Identity for the call. If not specified, uses the instance identity. + * @param identity The Identity to use. If not specified, uses the instance identity. * @param request The request to send in case it has already been created. */ readState( @@ -227,6 +229,7 @@ export interface Agent { * @param canisterId The canister to call. * @param fields The call options (method name, arg, effective canister ID, optional nonce). * @param pollingOptions Optional polling configuration. + * @param identity The Identity to use. If not specified, uses the instance identity. * @returns The certified result including the certificate, reply bytes, and raw certificate bytes. */ update( @@ -251,7 +254,7 @@ export interface Agent { * @param canisterId The Principal of the Canister to send the query to. Sending a query to * the management canister is not supported (as it has no meaning from an agent). * @param options Options to use to create and send the query. - * @param identity Sender principal to use when sending the query. + * @param identity The Identity to use. If not specified, uses the instance identity. * @returns The response from the replica. The Promise will only reject when the communication * failed. If the query itself failed but no protocol errors happened, the response will * be of type QueryResponseRejected. From 7dce8503a9031dc183e50fcbb2e920b481f4ee4e Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Wed, 25 Mar 2026 15:15:32 +0000 Subject: [PATCH 09/10] Use the existing `CallOptions` rather than redefining options --- packages/core/src/agent/agent/api.ts | 5 +++++ packages/core/src/agent/agent/http/index.ts | 13 +------------ 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/packages/core/src/agent/agent/api.ts b/packages/core/src/agent/agent/api.ts index 8b87f706b..bd6500461 100644 --- a/packages/core/src/agent/agent/api.ts +++ b/packages/core/src/agent/agent/api.ts @@ -117,6 +117,11 @@ export interface CallOptions { */ effectiveCanisterId?: Principal | string; + /** + * Whether to use synchronous call mode. Defaults to true. + */ + callSync?: boolean; + /** * An optional nonce to use for the call, used to prevent replay attacks. */ diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index dbb3d466b..0532bccbf 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -490,23 +490,12 @@ export class HttpAgent implements Agent { * Makes a call to a canister method. * @param canisterId - The ID of the canister to call. Can be a Principal or a string. * @param options - Options for the call. - * @param options.methodName - The name of the method to call. - * @param options.arg - The argument to pass to the method, as a Uint8Array. - * @param options.effectiveCanisterId - (Optional) The effective canister ID, if different from the target canister ID. - * @param options.callSync - (Optional) Whether to use synchronous call mode. Defaults to true. - * @param options.nonce - (Optional) A unique nonce for the request. If provided, it will override any nonce set by transforms. * @param identity - (Optional) The identity to use for the call. If not provided, the agent's current identity will be used. * @returns A promise that resolves to the response of the call, including the request ID and response details. */ public async call( canisterId: Principal | string, - options: { - methodName: string; - arg: Uint8Array; - effectiveCanisterId?: Principal | string; - callSync?: boolean; - nonce?: Uint8Array | Nonce; - }, + options: CallOptions, identity?: Identity | Promise, ): Promise { const callSync = options.callSync ?? true; From bf39273e1db58db387ad359778d9b0a5c23e31ea Mon Sep 17 00:00:00 2001 From: Hamish Peebles Date: Thu, 9 Apr 2026 08:55:27 +0100 Subject: [PATCH 10/10] Add doc comment --- packages/core/src/agent/agent/http/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index f657c4b48..ef12ff05f 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -641,6 +641,7 @@ export class HttpAgent implements Agent { * @param canisterId The canister to call. * @param fields The call options (method name, arg, effective canister ID, optional nonce). * @param pollingOptions Optional polling configuration. + * @param identity - (Optional) The identity to use. If not provided, the agent's current identity will be used. * @returns The certified result including the certificate, reply bytes, and raw certificate bytes. */ public async update(