diff --git a/CHANGELOG.md b/CHANGELOG.md index 843613452..f280b131a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Feat +- **agent**: allow passing identity into all canister requests exposed by `HttpAgent` (#1286) - **identity**: add AttributesIdentity for sender_info support (#1338) ## v5.2.1 (2026-04-01) diff --git a/packages/core/src/agent/agent/api.ts b/packages/core/src/agent/agent/api.ts index 44dae98c0..bd6500461 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,12 @@ 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; + + /** + * Whether to use synchronous call mode. Defaults to true. + */ + callSync?: boolean; /** * An optional nonce to use for the call, used to prevent replay attacks. @@ -198,8 +203,13 @@ 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, 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, @@ -207,13 +217,13 @@ 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( effectiveCanisterId: Principal | string, options: ReadStateOptions, - identity?: Identity, + identity?: Identity | Promise, request?: unknown, ): Promise; @@ -224,12 +234,14 @@ 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( canisterId: Principal | string, fields: CallOptions, pollingOptions?: PollingOptions, + identity?: Identity | Promise, ): Promise; /** @@ -247,7 +259,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. 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 15acff484..44424c87c 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, UnexpectedV4StatusErrorCode } 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(); @@ -197,7 +198,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 () => { @@ -236,7 +237,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 () => { @@ -246,7 +247,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 () => { @@ -257,7 +258,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/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index 938af7968..ef12ff05f 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -475,23 +475,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; @@ -652,15 +641,17 @@ 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( 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)) { @@ -686,6 +677,7 @@ export class HttpAgent implements Agent { effectiveCanisterId, requestId, pollingOptions, + identity, ); return { ...pollResult, requestDetails, callResponse: response }; } @@ -1023,9 +1015,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()}`); @@ -1242,7 +1232,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 { @@ -1271,10 +1261,6 @@ export class HttpAgent implements Agent { requestId = getRequestId(fields); // Always create a fresh request with the current identity - const identity = await this.#identity; - if (!identity) { - throw ExternalError.fromCode(new IdentityInvalidErrorCode()); - } transformedRequest = await this.createReadStateRequest(fields, identity); } @@ -1292,6 +1278,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); @@ -1299,7 +1286,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 9863a328d..9978776d9 100644 --- a/packages/core/src/agent/polling/index.ts +++ b/packages/core/src/agent/polling/index.ts @@ -24,6 +24,7 @@ import type { PollStrategy, PollForResponseResult } from './types.ts'; import { ReadRequestType, type ReadStateRequest } from '../agent/http/types.ts'; import { RequestStatusResponseStatus } from '../agent/http/types.ts'; import { utf8ToBytes } from '@noble/hashes/utils'; +import type { Identity } from '../auth.ts'; export { defaultStrategy } from './strategy.ts'; export type { PollStrategy, PollForResponseResult } from './types.ts'; @@ -118,6 +119,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 polling requests. If not provided, the agent's current identity will be used. * @returns The certificate, reply bytes, and raw certificate bytes for the request. * @throws {ExternalError} If the agent's root key is not available. * @throws {RejectError} If the request was rejected by the canister. @@ -128,6 +130,7 @@ export async function pollForResponse( canisterId: Principal, requestId: RequestId, options: PollingOptions = {}, + identity?: Identity | Promise, ): Promise { const path = [utf8ToBytes('request_status'), requestId]; @@ -141,10 +144,10 @@ 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] }); + state = await agent.readState(canisterId, { paths: [path] }, identity); } if (agent.rootKey == null) { @@ -188,7 +191,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: {