diff --git a/docs/generated/changelog.html b/docs/generated/changelog.html
index 491c78ecd..35e9de864 100644
--- a/docs/generated/changelog.html
+++ b/docs/generated/changelog.html
@@ -12,6 +12,7 @@
Agent-JS Changelog
Version x.x.x
+ - feat: adds subnet metrics decoding to canisterStatus for `/subnet` path
-
chore: replaces use of localhost with 127.0.0.1 for better node 18 support. Also swaps
Jest for vitest, runs mitm against mainnet, and updates some packages
diff --git a/packages/agent/src/agent/http/call.test.ts b/packages/agent/src/agent/http/call.test.ts
new file mode 100644
index 000000000..9eb1d9f59
--- /dev/null
+++ b/packages/agent/src/agent/http/call.test.ts
@@ -0,0 +1,49 @@
+import { Actor } from '../../actor';
+import { AnonymousIdentity } from '../../auth';
+import { Call } from './call';
+import { IDL } from '@dfinity/candid';
+import { HttpAgent } from '.';
+import { Principal } from '@dfinity/principal';
+
+const setup = () =>
+ new Call({
+ identity: new AnonymousIdentity(),
+ canisterId: 'ivcos-eqaaa-aaaab-qablq-cai',
+ callArgs: {
+ arg: new ArrayBuffer(0),
+ methodName: 'whoami',
+ },
+ maxTries: 3,
+ fetchConfig: {
+ body: new ArrayBuffer(0),
+ method: 'POST',
+ headers: {},
+ fetch,
+ host: 'https://icp-api.io',
+ },
+ });
+
+jest.setTimeout(30000);
+test('makes a call', async () => {
+ jest.useRealTimers();
+ const call = setup();
+ expect(call).toBeInstanceOf(Call);
+ const { response, requestId } = await call.request();
+ expect(response).toBeTruthy();
+});
+
+test('actor', async () => {
+ const actor = Actor.createActor(
+ () => {
+ return IDL.Service({
+ whoami: IDL.Func([], [IDL.Principal], ['query']),
+ });
+ },
+ {
+ agent: new HttpAgent({ host: 'https://icp-api.io' }),
+ canisterId: 'ivcos-eqaaa-aaaab-qablq-cai',
+ },
+ ) as Actor & { whoami: () => Promise };
+ const whoami = await actor.whoami();
+ expect(whoami.toText()).toBe('2vxsx-fae');
+});
diff --git a/packages/agent/src/agent/http/call.ts b/packages/agent/src/agent/http/call.ts
new file mode 100644
index 000000000..c55fc0458
--- /dev/null
+++ b/packages/agent/src/agent/http/call.ts
@@ -0,0 +1,247 @@
+import { Principal } from '@dfinity/principal';
+import { Identity } from '../../auth';
+import { Expiry, httpHeadersTransform } from './transforms';
+import {
+ CallRequest,
+ Endpoint,
+ HttpAgentRequest,
+ HttpAgentRequestTransformFn,
+ HttpHeaderField,
+ SubmitRequestType,
+} from './types';
+
+import * as cbor from '../../cbor';
+import { SubmitResponse } from '../api';
+import { AgentError } from '../../errors';
+import { RequestId, requestIdOf } from '../../request_id';
+
+const DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS = 5 * 60 * 1000;
+
+export type CallOptions = {
+ canisterId: Principal | string;
+ callArgs: {
+ methodName: string;
+ arg: ArrayBuffer;
+ effectiveCanisterId?: Principal | string;
+ };
+ maxTries: number;
+ identity: Identity | Promise;
+ fetchConfig: FetchConfig;
+ callOptions?: Record;
+ credentials?: string;
+};
+
+export type CallResponse = {
+ response: {
+ ok: boolean;
+ status: number;
+ statusText: string;
+ body: {
+ error_code?: string | undefined;
+ reject_code: number;
+ reject_message: string;
+ } | null;
+ headers: HttpHeaderField[];
+ };
+ requestId: RequestId;
+};
+
+export type FetchConfig = {
+ body: ArrayBuffer;
+ method: string;
+ headers: Record;
+ fetch: typeof fetch;
+ host: string;
+};
+
+class AgentCallError extends AgentError {
+ constructor(message: string) {
+ super(message);
+ }
+}
+
+/**
+ * Call is a wrapper around a call to a canister.
+ * It manages the state of the call and provides
+ * methods for retrying the call.
+ */
+export class Call {
+ #options: CallOptions;
+ #tries = 0;
+ #maxTries = 3;
+ #timeDiffMsecs = 0;
+ #pipeline: HttpAgentRequestTransformFn[] = [];
+ #lastError?: AgentCallError;
+
+ constructor(options: CallOptions) {
+ this.#options = options;
+ }
+
+ get options() {
+ return this.#options;
+ }
+
+ get tries() {
+ return this.#tries;
+ }
+
+ get maxTries() {
+ return this.#maxTries;
+ }
+
+ public async request(): Promise {
+ this.#tries; //?
+ while (this.#tries < this.#maxTries) {
+ try {
+ return await this.#try();
+ } catch (e) {
+ console.log(e);
+ }
+ }
+ throw new AgentCallError('Max tries reached');
+ }
+
+ async #exponentialBackoff(cb: () => Promise): Promise {
+ const delay = 2 ** this.#tries * 100;
+ delay;
+ // await new Promise(resolve => setTimeout(resolve, delay));
+ return await cb();
+ }
+
+ async #try(): Promise {
+ if (this.#tries >= this.#maxTries) {
+ if (this.#lastError) {
+ throw this.#lastError;
+ }
+ throw new AgentCallError('Max tries reached');
+ }
+ this.#tries++;
+
+ const { canisterId, identity, callArgs, credentials, fetchConfig, callOptions } = this.#options;
+
+ const id = await identity;
+
+ const canister = Principal.from(canisterId);
+ const ecid = callArgs.effectiveCanisterId
+ ? Principal.from(callArgs.effectiveCanisterId)
+ : canister;
+
+ const sender: Principal = id.getPrincipal() || Principal.anonymous();
+
+ const ingress_expiry = new Expiry(
+ DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS,
+ BigInt(this.#timeDiffMsecs),
+ );
+
+ const submit: CallRequest = {
+ request_type: SubmitRequestType.Call,
+ canister_id: canister,
+ method_name: callArgs.methodName,
+ arg: callArgs.arg,
+ sender,
+ ingress_expiry,
+ };
+
+ let transformedRequest = await this.#transform({
+ request: {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/cbor',
+ ...(credentials ? { Authorization: 'Basic ' + btoa(credentials) } : {}),
+ },
+ },
+ endpoint: Endpoint.Call,
+ body: submit,
+ });
+
+ transformedRequest = (await id.transformRequest(transformedRequest)) as HttpAgentRequest;
+
+ const body = cbor.encode(transformedRequest.body);
+
+ const { host } = fetchConfig;
+
+ return fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, host), {
+ ...callOptions,
+ ...transformedRequest.request,
+ body,
+ } as RequestInit)
+ .then(async response => {
+ if (response.status === 401) {
+ console.log(response.status, response.statusText);
+ throw new Error('Unauthorized');
+ }
+
+ if (response.status === 404) {
+ console.log(response.status, response.statusText);
+ throw new Error('Canister not found');
+ }
+
+ if (!response.ok) {
+ this.#lastError = new AgentCallError(
+ `Server returned an error:\n` +
+ ` Code: ${response.status} (${response.statusText})\n` +
+ ` Body: ${await response.clone().text()}\n`,
+ );
+
+ const responseText = await response.clone().text();
+
+ // Handle time drift errors
+ if (responseText.includes('Specified ingress_expiry')) {
+ const errorParts = responseText.split(': ');
+ errorParts;
+ const minExpiry = new Date(errorParts[2].split(',')[0].trim()); //?
+ const maxExpiry = new Date(errorParts[3].split(',')[0].trim()); //?
+ const providedExpiry = new Date(errorParts[4].trim()); //?
+
+ const result = {
+ minimum: minExpiry,
+ maximum: maxExpiry,
+ provided: providedExpiry,
+ };
+
+ console.log(
+ 'HttpAgent has detected a disagreement about time with the replica. Retrying with adjusted expiry.',
+ result,
+ );
+ // Adjust the time difference to account for the time it took to make the request.
+ this.#timeDiffMsecs = maxExpiry.getTime() - Date.now() - 1000;
+ console.log('Adjusted time difference to', this.#timeDiffMsecs, 'milliseconds.');
+ }
+ }
+
+ const responseBuffer = await response.arrayBuffer();
+ const responseBody = (
+ response.status === 200 && responseBuffer.byteLength > 0
+ ? cbor.decode(responseBuffer)
+ : null
+ ) as SubmitResponse['response']['body'];
+
+ const requestId = requestIdOf(submit);
+
+ return {
+ response: {
+ ok: response.ok,
+ status: response.status,
+ statusText: response.statusText,
+ body: responseBody,
+ headers: httpHeadersTransform(response.headers),
+ },
+ requestId,
+ };
+ })
+ .catch(e => {
+ console.log(e);
+ throw e;
+ });
+ }
+
+ #transform = async (request: HttpAgentRequest): Promise => {
+ let p = Promise.resolve(request);
+
+ for (const fn of this.#pipeline) {
+ p = p.then(r => fn(r).then(r2 => r2 || r));
+ }
+
+ return p;
+ };
+}
diff --git a/packages/agent/src/certificate.test.ts b/packages/agent/src/certificate.test.ts
index 44bd5e0a0..e2f68bcb6 100644
--- a/packages/agent/src/certificate.test.ts
+++ b/packages/agent/src/certificate.test.ts
@@ -274,6 +274,15 @@ describe('node keys', () => {
const nodeKeys = cert.cache_node_keys();
expect(nodeKeys).toMatchInlineSnapshot(`
Object {
+ "metrics": Object {
+ "canister_state_bytes": 10007399447n,
+ "consumed_cycles_total": Object {
+ "current": 15136490391288n,
+ "deleted": 0n,
+ },
+ "num_canisters": 451n,
+ "update_transactions_total": 222360n,
+ },
"nodeKeys": Array [
"302a300506032b65700321005b0bdf0329932ab0a78fa7192ad76cf37d67eb2024739774d3b67da7799ebc9c",
"302a300506032b65700321009776d25542873dafb8099303d1fca8e4aa344e73cf5a3d7df5b40f9c8ed5a085",
diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts
index 3682c9b9b..a02076963 100644
--- a/packages/agent/src/certificate.ts
+++ b/packages/agent/src/certificate.ts
@@ -49,6 +49,15 @@ export type SubnetStatus = {
// Principal as a string
subnetId: string;
nodeKeys: string[];
+ metrics?: {
+ num_canisters: bigint;
+ canister_state_bytes: bigint;
+ consumed_cycles_total: {
+ current: bigint;
+ deleted: bigint;
+ };
+ update_transactions_total: bigint;
+ };
};
/**
@@ -164,6 +173,8 @@ export interface CreateCertificateOptions {
maxAgeInMinutes?: number;
}
+type MetricsResult = number | bigint | Map | undefined;
+
export class Certificate {
private readonly cert: Cert;
#nodeKeys: string[] = [];
@@ -216,6 +227,12 @@ export class Certificate {
return this.lookup([label]);
}
+ #toBigInt(n: MetricsResult): bigint {
+ if (typeof n === 'undefined') return BigInt(0);
+ if (typeof n === 'bigint') return n;
+ return BigInt(Number(n));
+ }
+
public cache_node_keys(root_key?: Uint8Array): SubnetStatus {
const tree = this.cert.tree;
let delegation = this.cert.delegation;
@@ -248,10 +265,47 @@ export class Certificate {
}
});
- return {
+ const metricsTree = lookup_path(
+ ['subnet', delegation?.subnet_id as ArrayBuffer, 'metrics'],
+ tree,
+ );
+ let metrics: SubnetStatus['metrics'] | undefined = undefined;
+ if (metricsTree) {
+ const decoded = cbor.decode(metricsTree as ArrayBuffer) as Map<
+ number,
+ Map
+ >;
+
+ // Cbor may decode values as either number or bigint. For consistency, we convert all numbers to bigint
+ const num_canisters = this.#toBigInt(decoded.get(0));
+ const canister_state_bytes = this.#toBigInt(decoded.get(1));
+ const current_consumed_cycles = this.#toBigInt(
+ (decoded.get(2) as Map).get(0),
+ );
+ const deleted_consumed_cycles = this.#toBigInt(
+ (decoded.get(2) as Map).get(1),
+ );
+ const update_transactions_total = this.#toBigInt(decoded.get(3));
+
+ metrics = {
+ num_canisters: num_canisters,
+ canister_state_bytes: canister_state_bytes,
+ consumed_cycles_total: {
+ current: current_consumed_cycles,
+ deleted: deleted_consumed_cycles,
+ },
+ update_transactions_total: update_transactions_total,
+ };
+ }
+
+ const result: SubnetStatus = {
subnetId: Principal.fromUint8Array(new Uint8Array(delegation.subnet_id)).toText(),
nodeKeys: this.#nodeKeys,
};
+ if (metrics) {
+ result.metrics = metrics;
+ }
+ return result;
}
private async verify(): Promise {