Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## [Unreleased]

### Feat

- **agent**: allow passing identity into all canister requests exposed by `HttpAgent`

## v5.2.0 (2026-03-24)

## v5.2.0-beta.0 (2026-03-23)
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/agent/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made this optional to be consistent with QueryFields


/**
* An optional nonce to use for the call, used to prevent replay attacks.
Expand Down Expand Up @@ -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<unknown>;
createReadStateRequest?(
options: ReadStateOptions,
identity?: Identity | Promise<Identity>,
): Promise<unknown>;

/**
* Send a read state query to the replica. This includes a list of paths to return,
Expand All @@ -213,7 +216,7 @@ export interface Agent {
readState(
effectiveCanisterId: Principal | string,
options: ReadStateOptions,
identity?: Identity,
identity?: Identity | Promise<Identity>,
request?: unknown,
): Promise<ReadStateResponse>;

Expand All @@ -230,6 +233,7 @@ export interface Agent {
canisterId: Principal | string,
fields: CallOptions,
pollingOptions?: PollingOptions,
identity?: Identity | Promise<Identity>,
): Promise<UpdateResult>;

/**
Expand Down
19 changes: 15 additions & 4 deletions packages/core/src/agent/agent/http/http-update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
19 changes: 8 additions & 11 deletions packages/core/src/agent/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -673,9 +673,10 @@ export class HttpAgent implements Agent {
canisterId: Principal | string,
fields: CallOptions,
pollingOptions: PollingOptions = {},
identity?: Identity | Promise<Identity>,
): Promise<UpdateResult> {
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)) {
Expand All @@ -701,6 +702,7 @@ export class HttpAgent implements Agent {
effectiveCanisterId,
requestId,
pollingOptions,
identity,
);
return { ...pollResult, requestDetails, callResponse: response };
}
Expand Down Expand Up @@ -1021,9 +1023,7 @@ export class HttpAgent implements Agent {
identity?: Identity | Promise<Identity>,
): Promise<ApiQueryResponse> {
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()}`);
Expand Down Expand Up @@ -1240,7 +1240,7 @@ export class HttpAgent implements Agent {
public async readState(
canisterId: Principal | string,
fields: ReadStateOptions,
_identity?: Identity | Promise<Identity>,
identity?: Identity | Promise<Identity>,
// eslint-disable-next-line
request?: any,
): Promise<ReadStateResponse> {
Expand Down Expand Up @@ -1269,10 +1269,6 @@ export class HttpAgent implements Agent {
requestId = getRequestId(fields);

// Always create a fresh request with the current identity
const identity = await this.#identity;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need to calculate the identity here since that already happens within createReadStateRequest

if (!identity) {
throw ExternalError.fromCode(new IdentityInvalidErrorCode());
}
transformedRequest = await this.createReadStateRequest(fields, identity);
}

Expand All @@ -1290,14 +1286,15 @@ export class HttpAgent implements Agent {
public async readSubnetState(
subnetId: Principal | string,
options: ReadStateOptions,
identity?: Identity | Promise<Identity>,
): Promise<ReadStateResponse> {
await this.#rootKeyGuard();
const subnet = Principal.from(subnetId);

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);
Expand Down
9 changes: 6 additions & 3 deletions packages/core/src/agent/polling/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/index.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';

Expand Down Expand Up @@ -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.
Expand All @@ -128,6 +130,7 @@ export async function pollForResponse(
canisterId: Principal,
requestId: RequestId,
options: PollingOptions = {},
identity?: Identity | Promise<Identity>,
): Promise<PollForResponseResult> {
const path = [utf8ToBytes('request_status'), requestId];

Expand All @@ -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) {
Expand Down Expand Up @@ -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: {
Expand Down
Loading