Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 18 additions & 6 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,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;
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


/**
* Whether to use synchronous call mode. Defaults to true.
*/
callSync?: boolean;

/**
* An optional nonce to use for the call, used to prevent replay attacks.
Expand Down Expand Up @@ -198,22 +203,27 @@ 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<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,
* and will return a Certificate. This will only reject on communication errors,
* 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<Identity>,
request?: unknown,
): Promise<ReadStateResponse>;

Expand All @@ -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<Identity>,
): Promise<UpdateResult>;

/**
Expand All @@ -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.
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, 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();
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
33 changes: 10 additions & 23 deletions packages/core/src/agent/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Identity>,
): Promise<SubmitResponse> {
const callSync = options.callSync ?? true;
Expand Down Expand Up @@ -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<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 @@ -686,6 +677,7 @@ export class HttpAgent implements Agent {
effectiveCanisterId,
requestId,
pollingOptions,
identity,
);
return { ...pollResult, requestDetails, callResponse: response };
}
Expand Down Expand Up @@ -1023,9 +1015,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 @@ -1242,7 +1232,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 @@ -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;
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 @@ -1292,14 +1278,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/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';

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