@dfinity/agent
Version:
JavaScript and TypeScript library to interact with the Internet Computer
1,067 lines • 50.3 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.HttpAgent = exports.MANAGEMENT_CANISTER_ID = exports.IC_ROOT_KEY = exports.RequestStatusResponseStatus = exports.makeNonce = void 0;
exports.calculateIngressExpiry = calculateIngressExpiry;
const principal_1 = require("@dfinity/principal");
const errors_ts_1 = require("../../errors.js");
const auth_ts_1 = require("../../auth.js");
const cbor = __importStar(require("../../cbor.js"));
const request_id_ts_1 = require("../../request_id.js");
const api_ts_1 = require("../api.js");
const transforms_ts_1 = require("./transforms.js");
const types_ts_1 = require("./types.js");
const index_ts_1 = require("../../canisterStatus/index.js");
const certificate_ts_1 = require("../../certificate.js");
const ed25519_1 = require("@noble/curves/ed25519");
const expirableMap_ts_1 = require("../../utils/expirableMap.js");
const public_key_ts_1 = require("../../public_key.js");
const observable_ts_1 = require("../../observable.js");
const backoff_ts_1 = require("../../polling/backoff.js");
const leb_ts_1 = require("../../utils/leb.js");
const utils_1 = require("@noble/hashes/utils");
const buffer_ts_1 = require("../../utils/buffer.js");
const constants_ts_1 = require("../../constants.js");
__exportStar(require("./transforms.js"), exports);
var types_ts_2 = require("./types.js");
Object.defineProperty(exports, "makeNonce", { enumerable: true, get: function () { return types_ts_2.makeNonce; } });
var RequestStatusResponseStatus;
(function (RequestStatusResponseStatus) {
RequestStatusResponseStatus["Received"] = "received";
RequestStatusResponseStatus["Processing"] = "processing";
RequestStatusResponseStatus["Replied"] = "replied";
RequestStatusResponseStatus["Rejected"] = "rejected";
RequestStatusResponseStatus["Unknown"] = "unknown";
RequestStatusResponseStatus["Done"] = "done";
})(RequestStatusResponseStatus || (exports.RequestStatusResponseStatus = RequestStatusResponseStatus = {}));
const MINUTE_TO_MSECS = 60 * 1_000;
const MSECS_TO_NANOSECONDS = 1_000_000;
const DEFAULT_TIME_DIFF_MSECS = 0;
// Root public key for the IC, encoded as hex
exports.IC_ROOT_KEY = '308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814' +
'c0e6ec71fab583b08bd81373c255c3c371b2e84863c98a4f1e08b74235d14fb5d9c0cd546d968' +
'5f913a0c0b2cc5341583bf4b4392e467db96d65b9bb4cb717112f8472e0d5a4d14505ffd7484' +
'b01291091c5f87b98883463f98091a0baaae';
exports.MANAGEMENT_CANISTER_ID = 'aaaaa-aa';
// IC0 domain info
const IC0_DOMAIN = 'ic0.app';
const IC0_SUB_DOMAIN = '.ic0.app';
const ICP0_DOMAIN = 'icp0.io';
const ICP0_SUB_DOMAIN = '.icp0.io';
const ICP_API_DOMAIN = 'icp-api.io';
const ICP_API_SUB_DOMAIN = '.icp-api.io';
const HTTP_STATUS_OK = 200;
const HTTP_STATUS_ACCEPTED = 202;
const HTTP_STATUS_NOT_FOUND = 404;
function getDefaultFetch() {
let defaultFetch;
if (typeof window !== 'undefined') {
// Browser context
if (window.fetch) {
defaultFetch = window.fetch.bind(window);
}
else {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.HttpDefaultFetchErrorCode('Fetch implementation was not available. You appear to be in a browser context, but window.fetch was not present.'));
}
}
else if (typeof global !== 'undefined') {
// Node context
if (global.fetch) {
defaultFetch = global.fetch.bind(global);
}
else {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.HttpDefaultFetchErrorCode('Fetch implementation was not available. You appear to be in a Node.js context, but global.fetch was not available.'));
}
}
else if (typeof self !== 'undefined') {
if (self.fetch) {
defaultFetch = self.fetch.bind(self);
}
}
if (defaultFetch) {
return defaultFetch;
}
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.HttpDefaultFetchErrorCode('Fetch implementation was not available. Please provide fetch to the HttpAgent constructor, or ensure it is available in the window or global context.'));
}
function determineHost(configuredHost) {
let host;
if (configuredHost !== undefined) {
if (!configuredHost.match(/^[a-z]+:/) && typeof window !== 'undefined') {
host = new URL(window.location.protocol + '//' + configuredHost);
}
else {
host = new URL(configuredHost);
}
}
else {
// Mainnet, local, and remote environments will have the api route available
const knownHosts = ['ic0.app', 'icp0.io', '127.0.0.1', 'localhost'];
const remoteHosts = ['.github.dev', '.gitpod.io'];
const location = typeof window !== 'undefined' ? window.location : undefined;
const hostname = location?.hostname;
let knownHost;
if (hostname && typeof hostname === 'string') {
if (remoteHosts.some(host => hostname.endsWith(host))) {
knownHost = hostname;
}
else {
knownHost = knownHosts.find(host => hostname.endsWith(host));
}
}
if (location && knownHost) {
// If the user is on a boundary-node provided host, we can use the same host for the agent
host = new URL(`${location.protocol}//${knownHost}${location.port ? ':' + location.port : ''}`);
}
else {
host = new URL('https://icp-api.io');
}
}
return host.toString();
}
/**
* A HTTP agent allows users to interact with a client of the internet computer
using the available methods. It exposes an API that closely follows the
public view of the internet computer, and is not intended to be exposed
directly to the majority of users due to its low-level interface.
* There is a pipeline to apply transformations to the request before sending
it to the client. This is to decouple signature, nonce generation and
other computations so that this class can stay as simple as possible while
allowing extensions.
*/
class HttpAgent {
#rootKeyPromise;
#shouldFetchRootKey;
#timeDiffMsecs;
#hasSyncedTime;
#syncTimePromise;
#shouldSyncTime;
#identity;
#fetch;
#fetchOptions;
#callOptions;
#credentials;
#retryTimes; // Retry requests N times before erroring by default
#backoffStrategy;
#maxIngressExpiryInMinutes;
get #maxIngressExpiryInMs() {
return this.#maxIngressExpiryInMinutes * MINUTE_TO_MSECS;
}
#queryPipeline;
#updatePipeline;
#subnetKeys;
#verifyQuerySignatures;
/**
* @param options - Options for the HttpAgent
* @deprecated Use `HttpAgent.create` or `HttpAgent.createSync` instead
*/
constructor(options = {}) {
this.#rootKeyPromise = null;
this.#shouldFetchRootKey = false;
this.#timeDiffMsecs = DEFAULT_TIME_DIFF_MSECS;
this.#hasSyncedTime = false;
this.#syncTimePromise = null;
this.#shouldSyncTime = false;
// Public signature to help with type checking.
this._isAgent = true;
this.config = {};
this.log = new observable_ts_1.ObservableLog();
this.#queryPipeline = [];
this.#updatePipeline = [];
this.#subnetKeys = new expirableMap_ts_1.ExpirableMap({
expirationTime: 5 * MINUTE_TO_MSECS,
});
this.#verifyQuerySignatures = true;
/**
* See https://internetcomputer.org/docs/current/references/ic-interface-spec/#http-query for details on validation
* @param queryResponse - The response from the query
* @param subnetStatus - The subnet status, including all node keys
* @returns ApiQueryResponse
*/
this.#verifyQueryResponse = (queryResponse, subnetStatus) => {
if (this.#verifyQuerySignatures === false) {
// This should not be called if the user has disabled verification
return queryResponse;
}
const { status, signatures = [], requestId } = queryResponse;
for (const sig of signatures) {
const { timestamp, identity } = sig;
const nodeId = principal_1.Principal.fromUint8Array(identity).toText();
// Hash is constructed differently depending on the status
let hash;
if (status === api_ts_1.QueryResponseStatus.Replied) {
const { reply } = queryResponse;
hash = (0, request_id_ts_1.hashOfMap)({
status: status,
reply: reply,
timestamp: BigInt(timestamp),
request_id: requestId,
});
}
else if (status === api_ts_1.QueryResponseStatus.Rejected) {
const { reject_code, reject_message, error_code } = queryResponse;
hash = (0, request_id_ts_1.hashOfMap)({
status: status,
reject_code: reject_code,
reject_message: reject_message,
error_code: error_code,
timestamp: BigInt(timestamp),
request_id: requestId,
});
}
else {
throw errors_ts_1.UnknownError.fromCode(new errors_ts_1.UnexpectedErrorCode(`Unknown status: ${status}`));
}
const separatorWithHash = (0, utils_1.concatBytes)(constants_ts_1.IC_RESPONSE_DOMAIN_SEPARATOR, hash);
// FIX: check for match without verifying N times
const pubKey = subnetStatus.nodeKeys.get(nodeId);
if (!pubKey) {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.MalformedPublicKeyErrorCode());
}
const rawKey = public_key_ts_1.Ed25519PublicKey.fromDer(pubKey).rawKey;
const valid = ed25519_1.ed25519.verify(sig.signature, separatorWithHash, rawKey);
if (valid)
return queryResponse;
throw errors_ts_1.TrustError.fromCode(new errors_ts_1.QuerySignatureVerificationFailedErrorCode(nodeId));
}
return queryResponse;
};
this.config = options;
this.#fetch = options.fetch || getDefaultFetch() || fetch.bind(global);
this.#fetchOptions = options.fetchOptions;
this.#callOptions = options.callOptions;
this.#shouldFetchRootKey = options.shouldFetchRootKey ?? false;
this.#shouldSyncTime = options.shouldSyncTime ?? false;
// Use provided root key, otherwise fall back to IC_ROOT_KEY for mainnet or null if the key needs to be fetched
if (options.rootKey) {
this.rootKey = options.rootKey;
}
else if (this.#shouldFetchRootKey) {
this.rootKey = null;
}
else {
this.rootKey = (0, utils_1.hexToBytes)(exports.IC_ROOT_KEY);
}
const host = determineHost(options.host);
this.host = new URL(host);
if (options.verifyQuerySignatures !== undefined) {
this.#verifyQuerySignatures = options.verifyQuerySignatures;
}
// Default is 3
this.#retryTimes = options.retryTimes ?? 3;
// Delay strategy for retries. Default is exponential backoff
const defaultBackoffFactory = () => new backoff_ts_1.ExponentialBackoff({
maxIterations: this.#retryTimes,
});
this.#backoffStrategy = options.backoffStrategy || defaultBackoffFactory;
// Rewrite to avoid redirects
if (this.host.hostname.endsWith(IC0_SUB_DOMAIN)) {
this.host.hostname = IC0_DOMAIN;
}
else if (this.host.hostname.endsWith(ICP0_SUB_DOMAIN)) {
this.host.hostname = ICP0_DOMAIN;
}
else if (this.host.hostname.endsWith(ICP_API_SUB_DOMAIN)) {
this.host.hostname = ICP_API_DOMAIN;
}
if (options.credentials) {
const { name, password } = options.credentials;
this.#credentials = `${name}${password ? ':' + password : ''}`;
}
this.#identity = Promise.resolve(options.identity || new auth_ts_1.AnonymousIdentity());
if (options.ingressExpiryInMinutes && options.ingressExpiryInMinutes > 5) {
throw errors_ts_1.InputError.fromCode(new errors_ts_1.IngressExpiryInvalidErrorCode('The maximum ingress expiry time is 5 minutes.', options.ingressExpiryInMinutes));
}
if (options.ingressExpiryInMinutes && options.ingressExpiryInMinutes <= 0) {
throw errors_ts_1.InputError.fromCode(new errors_ts_1.IngressExpiryInvalidErrorCode('Ingress expiry time must be greater than 0.', options.ingressExpiryInMinutes));
}
this.#maxIngressExpiryInMinutes = options.ingressExpiryInMinutes || 5;
// Add a nonce transform to ensure calls are unique
this.addTransform('update', (0, transforms_ts_1.makeNonceTransform)(types_ts_1.makeNonce));
if (options.useQueryNonces) {
this.addTransform('query', (0, transforms_ts_1.makeNonceTransform)(types_ts_1.makeNonce));
}
if (options.logToConsole) {
this.log.subscribe(log => {
if (log.level === 'error') {
console.error(log.message);
}
else if (log.level === 'warn') {
console.warn(log.message);
}
else {
console.log(log.message);
}
});
}
}
static createSync(options = {}) {
return new this({ ...options });
}
static async create(options = {}) {
const agent = HttpAgent.createSync(options);
await agent.#asyncGuard();
return agent;
}
static async from(agent) {
try {
if ('config' in agent) {
return await HttpAgent.create(agent.config);
}
return await HttpAgent.create({
fetch: agent._fetch,
fetchOptions: agent._fetchOptions,
callOptions: agent._callOptions,
host: agent._host.toString(),
identity: agent._identity ?? undefined,
});
}
catch {
throw errors_ts_1.InputError.fromCode(new errors_ts_1.CreateHttpAgentErrorCode());
}
}
isLocal() {
const hostname = this.host.hostname;
return hostname === '127.0.0.1' || hostname.endsWith('127.0.0.1');
}
addTransform(type, fn, priority = fn.priority || 0) {
if (type === 'update') {
// Keep the pipeline sorted at all time, by priority.
const i = this.#updatePipeline.findIndex(x => (x.priority || 0) < priority);
this.#updatePipeline.splice(i >= 0 ? i : this.#updatePipeline.length, 0, Object.assign(fn, { priority }));
}
else if (type === 'query') {
// Keep the pipeline sorted at all time, by priority.
const i = this.#queryPipeline.findIndex(x => (x.priority || 0) < priority);
this.#queryPipeline.splice(i >= 0 ? i : this.#queryPipeline.length, 0, Object.assign(fn, { priority }));
}
}
async getPrincipal() {
if (!this.#identity) {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.IdentityInvalidErrorCode());
}
return (await this.#identity).getPrincipal();
}
/**
* 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.
*/
async call(canisterId, options, identity) {
const callSync = options.callSync ?? true;
const id = await (identity ?? this.#identity);
if (!id) {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.IdentityInvalidErrorCode());
}
const canister = principal_1.Principal.from(canisterId);
const ecid = options.effectiveCanisterId
? principal_1.Principal.from(options.effectiveCanisterId)
: canister;
await this.#asyncGuard(ecid);
const sender = id.getPrincipal();
const ingress_expiry = calculateIngressExpiry(this.#maxIngressExpiryInMinutes, this.#timeDiffMsecs);
const submit = {
request_type: types_ts_1.SubmitRequestType.Call,
canister_id: canister,
method_name: options.methodName,
arg: options.arg,
sender,
ingress_expiry,
};
let transformedRequest = (await this._transform({
request: {
body: null,
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
...(this.#credentials ? { Authorization: 'Basic ' + btoa(this.#credentials) } : {}),
},
},
endpoint: types_ts_1.Endpoint.Call,
body: submit,
}));
// Determine the nonce to use for the request
let nonce;
// Check if a nonce is provided in the options and convert it to the correct type
if (options?.nonce) {
nonce = toNonce(options.nonce);
}
// If no nonce is provided in the options, check the transformedRequest body
else if (transformedRequest.body.nonce) {
nonce = toNonce(transformedRequest.body.nonce);
}
// If no nonce is found, set it to undefined
else {
nonce = undefined;
}
// Assign the determined nonce to the submit object
submit.nonce = nonce;
/**
* Converts a Uint8Array to a Nonce type.
* @param buf - The buffer to convert.
* @returns The buffer as a Nonce.
*/
function toNonce(buf) {
return Object.assign(buf, { __nonce__: undefined });
}
// Apply transform for identity.
transformedRequest = (await id.transformRequest(transformedRequest));
const body = cbor.encode(transformedRequest.body);
const backoff = this.#backoffStrategy();
const requestId = (0, request_id_ts_1.requestIdOf)(submit);
try {
// Attempt v3 sync call
const requestSync = () => {
this.log.print(`fetching "/api/v3/canister/${ecid.toText()}/call" with request:`, transformedRequest);
return this.#fetch('' + new URL(`/api/v3/canister/${ecid.toText()}/call`, this.host), {
...this.#callOptions,
...transformedRequest.request,
body,
});
};
const requestAsync = () => {
this.log.print(`fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, transformedRequest);
return this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), {
...this.#callOptions,
...transformedRequest.request,
body,
});
};
const requestFn = callSync ? requestSync : requestAsync;
const { responseBodyBytes, ...response } = await this.#requestAndRetry({
requestFn,
backoff,
tries: 0,
});
const responseBody = (responseBodyBytes.byteLength > 0 ? cbor.decode(responseBodyBytes) : null);
return {
requestId,
response: {
...response,
body: responseBody,
},
requestDetails: submit,
};
}
catch (error) {
let callError;
if (error instanceof errors_ts_1.AgentError) {
// If the error is due to the v3 api not being supported, fall back to v2
if (error.hasCode(errors_ts_1.HttpV3ApiNotSupportedErrorCode)) {
this.log.warn('v3 api not supported. Fall back to v2');
return this.call(canisterId, {
...options,
// disable v3 api
callSync: false,
}, identity);
}
else if (error.hasCode(errors_ts_1.IngressExpiryInvalidErrorCode) && !this.#hasSyncedTime) {
// if there is an ingress expiry error and the time has not been synced yet,
// sync time with the network and try again
await this.syncTime(canister);
return this.call(canister, options, identity);
}
else {
// override the error code to include the request details
error.code.requestContext = {
requestId,
senderPubKey: transformedRequest.body.sender_pubkey,
senderSignature: transformedRequest.body.sender_sig,
ingressExpiry: transformedRequest.body.content.ingress_expiry,
};
callError = error;
}
}
else {
callError = errors_ts_1.UnknownError.fromCode(new errors_ts_1.UnexpectedErrorCode(error));
}
this.log.error(`Error while making call: ${callError.message}`, callError);
throw callError;
}
}
async #requestAndRetryQuery(args) {
const { ecid, transformedRequest, body, requestId, backoff, tries } = args;
const delay = tries === 0 ? 0 : backoff.next();
this.log.print(`fetching "/api/v2/canister/${ecid.toString()}/query" with tries:`, {
tries,
backoff,
delay,
});
// If delay is null, the backoff strategy is exhausted due to a maximum number of retries, duration, or other reason
if (delay === null) {
throw errors_ts_1.UnknownError.fromCode(new errors_ts_1.TimeoutWaitingForResponseErrorCode(`Backoff strategy exhausted after ${tries} attempts.`, requestId));
}
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
let response;
// Make the request and retry if it throws an error
try {
this.log.print(`fetching "/api/v2/canister/${ecid.toString()}/query" with request:`, transformedRequest);
const fetchResponse = await this.#fetch('' + new URL(`/api/v2/canister/${ecid.toString()}/query`, this.host), {
...this.#fetchOptions,
...transformedRequest.request,
body,
});
if (fetchResponse.status === HTTP_STATUS_OK) {
const queryResponse = cbor.decode((0, buffer_ts_1.uint8FromBufLike)(await fetchResponse.arrayBuffer()));
response = {
...queryResponse,
httpDetails: {
ok: fetchResponse.ok,
status: fetchResponse.status,
statusText: fetchResponse.statusText,
headers: (0, transforms_ts_1.httpHeadersTransform)(fetchResponse.headers),
},
requestId,
};
}
else {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.HttpErrorCode(fetchResponse.status, fetchResponse.statusText, (0, transforms_ts_1.httpHeadersTransform)(fetchResponse.headers), await fetchResponse.text()));
}
}
catch (error) {
if (tries < this.#retryTimes) {
this.log.warn(`Caught exception while attempting to make query:\n` +
` ${error}\n` +
` Retrying query.`);
return await this.#requestAndRetryQuery({ ...args, tries: tries + 1 });
}
if (error instanceof errors_ts_1.AgentError) {
// if it's an error that we have thrown, just throw it as is
throw error;
}
// if it's an error that we have not thrown, wrap it in a TransportError
throw errors_ts_1.TransportError.fromCode(new errors_ts_1.HttpFetchErrorCode(error));
}
// Skip timestamp verification if the user has set verifyQuerySignatures to false
if (!this.#verifyQuerySignatures) {
return response;
}
const signatureTimestampNs = response.signatures?.[0]?.timestamp;
if (!signatureTimestampNs) {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.MalformedSignatureErrorCode('Timestamp not found in query response. This suggests a malformed or malicious response.'));
}
const signatureTimestampMs = Number(BigInt(signatureTimestampNs) / BigInt(MSECS_TO_NANOSECONDS));
const currentTimestampInMs = Date.now() + this.#timeDiffMsecs;
// We don't need `Math.abs` here because we allow signatures in the future
if (currentTimestampInMs - signatureTimestampMs > this.#maxIngressExpiryInMs) {
if (tries < this.#retryTimes) {
this.log.warn('Timestamp is older than the max ingress expiry. Retrying query.', {
requestId,
signatureTimestampMs,
});
return await this.#requestAndRetryQuery({ ...args, tries: tries + 1 });
}
throw errors_ts_1.TrustError.fromCode(new errors_ts_1.CertificateOutdatedErrorCode(this.#maxIngressExpiryInMinutes, requestId, tries));
}
return response;
}
/**
* Makes a request and retries if it fails.
* @param args - The arguments for the request.
* @param args.requestFn - A function that returns a Promise resolving to a Response.
* @param args.backoff - The backoff strategy to use for retries.
* @param args.tries - The number of retry attempts made so far.
* @returns The response from the request, if the status is 200 or 202.
* See the https://internetcomputer.org/docs/references/ic-interface-spec#http-interface for details on the response statuses.
* @throws {ProtocolError} if the response status is not 200 or 202, and the retry limit has been reached.
* @throws {TransportError} if the request fails, and the retry limit has been reached.
*/
async #requestAndRetry(args) {
const { requestFn, backoff, tries } = args;
const delay = tries === 0 ? 0 : backoff.next();
// If delay is null, the backoff strategy is exhausted due to a maximum number of retries, duration, or other reason
if (delay === null) {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.TimeoutWaitingForResponseErrorCode(`Retry strategy exhausted after ${tries} attempts.`));
}
if (delay > 0) {
await new Promise(resolve => setTimeout(resolve, delay));
}
let response;
let responseBodyBytes = new Uint8Array();
try {
response = await requestFn();
// According to the spec, only 200 responses have a non-empty body
if (response.status === HTTP_STATUS_OK) {
// Consume the response body, to ensure that the response is not closed unexpectedly
responseBodyBytes = (0, buffer_ts_1.uint8FromBufLike)(await response.clone().arrayBuffer());
}
}
catch (error) {
if (tries < this.#retryTimes) {
this.log.warn(`Caught exception while attempting to make request:\n` +
` ${error}\n` +
` Retrying request.`);
// Delay the request by the configured backoff strategy
return await this.#requestAndRetry({ requestFn, backoff, tries: tries + 1 });
}
throw errors_ts_1.TransportError.fromCode(new errors_ts_1.HttpFetchErrorCode(error));
}
const headers = (0, transforms_ts_1.httpHeadersTransform)(response.headers);
if (response.status === HTTP_STATUS_OK || response.status === HTTP_STATUS_ACCEPTED) {
return {
ok: response.ok, // should always be true
status: response.status,
statusText: response.statusText,
responseBodyBytes,
headers,
};
}
const responseText = await response.text();
if (response.status === HTTP_STATUS_NOT_FOUND && response.url.includes('api/v3')) {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.HttpV3ApiNotSupportedErrorCode());
}
// The error message comes from https://github.com/dfinity/ic/blob/23d5990bfc5277c32e54f0087b5a38fa412171e1/rs/validator/src/ingress_validation.rs#L233
if (responseText.startsWith('Invalid request expiry: ')) {
throw errors_ts_1.InputError.fromCode(new errors_ts_1.IngressExpiryInvalidErrorCode(responseText, this.#maxIngressExpiryInMinutes));
}
if (tries < this.#retryTimes) {
return await this.#requestAndRetry({ requestFn, backoff, tries: tries + 1 });
}
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.HttpErrorCode(response.status, response.statusText, headers, responseText));
}
async query(canisterId, fields, identity) {
const backoff = this.#backoffStrategy();
const ecid = fields.effectiveCanisterId
? principal_1.Principal.from(fields.effectiveCanisterId)
: principal_1.Principal.from(canisterId);
await this.#asyncGuard(ecid);
this.log.print(`ecid ${ecid.toString()}`);
this.log.print(`canisterId ${canisterId.toString()}`);
let transformedRequest;
const id = await (identity ?? this.#identity);
if (!id) {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.IdentityInvalidErrorCode());
}
const canister = principal_1.Principal.from(canisterId);
const sender = id.getPrincipal();
const ingressExpiry = calculateIngressExpiry(this.#maxIngressExpiryInMinutes, this.#timeDiffMsecs);
const request = {
request_type: types_ts_1.ReadRequestType.Query,
canister_id: canister,
method_name: fields.methodName,
arg: fields.arg,
sender,
ingress_expiry: ingressExpiry,
};
const requestId = (0, request_id_ts_1.requestIdOf)(request);
transformedRequest = await this._transform({
request: {
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
...(this.#credentials ? { Authorization: 'Basic ' + btoa(this.#credentials) } : {}),
},
},
endpoint: types_ts_1.Endpoint.Query,
body: request,
});
// Apply transform for identity.
transformedRequest = (await id.transformRequest(transformedRequest));
const body = cbor.encode(transformedRequest.body);
const args = {
canister: canister.toText(),
ecid,
transformedRequest,
body,
requestId,
backoff,
tries: 0,
};
const makeQuery = async () => {
// Attempt to make the query i=retryTimes times
const query = await this.#requestAndRetryQuery(args);
return {
requestDetails: request,
...query,
};
};
const getSubnetStatus = async () => {
const cachedSubnetStatus = this.#subnetKeys.get(ecid.toString());
if (cachedSubnetStatus) {
return cachedSubnetStatus;
}
await this.fetchSubnetKeys(ecid.toString());
const subnetStatus = this.#subnetKeys.get(ecid.toString());
if (!subnetStatus) {
throw errors_ts_1.TrustError.fromCode(new errors_ts_1.MissingSignatureErrorCode());
}
return subnetStatus;
};
try {
if (!this.#verifyQuerySignatures) {
// Skip verification if the user has disabled it
return await makeQuery();
}
// Make query and fetch subnet keys in parallel
const [queryWithDetails, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]);
try {
return this.#verifyQueryResponse(queryWithDetails, subnetStatus);
}
catch {
// In case the node signatures have changed, refresh the subnet keys and try again
this.log.warn('Query response verification failed. Retrying with fresh subnet keys.');
this.#subnetKeys.delete(ecid.toString());
const updatedSubnetStatus = await getSubnetStatus();
return this.#verifyQueryResponse(queryWithDetails, updatedSubnetStatus);
}
}
catch (error) {
let queryError;
if (error instanceof errors_ts_1.AgentError) {
// override the error code to include the request details
error.code.requestContext = {
requestId,
senderPubKey: transformedRequest.body.sender_pubkey,
senderSignature: transformedRequest.body.sender_sig,
ingressExpiry: transformedRequest.body.content.ingress_expiry,
};
queryError = error;
}
else {
queryError = errors_ts_1.UnknownError.fromCode(new errors_ts_1.UnexpectedErrorCode(error));
}
this.log.error(`Error while making query: ${queryError.message}`, queryError);
throw queryError;
}
}
/**
* See https://internetcomputer.org/docs/current/references/ic-interface-spec/#http-query for details on validation
* @param queryResponse - The response from the query
* @param subnetStatus - The subnet status, including all node keys
* @returns ApiQueryResponse
*/
#verifyQueryResponse;
async createReadStateRequest(fields, identity) {
await this.#asyncGuard();
const id = await (identity ?? this.#identity);
if (!id) {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.IdentityInvalidErrorCode());
}
const sender = id.getPrincipal();
const transformedRequest = await this._transform({
request: {
method: 'POST',
headers: {
'Content-Type': 'application/cbor',
...(this.#credentials ? { Authorization: 'Basic ' + btoa(this.#credentials) } : {}),
},
},
endpoint: types_ts_1.Endpoint.ReadState,
body: {
request_type: types_ts_1.ReadRequestType.ReadState,
paths: fields.paths,
sender,
ingress_expiry: calculateIngressExpiry(this.#maxIngressExpiryInMinutes, this.#timeDiffMsecs),
},
});
// Apply transform for identity.
return id.transformRequest(transformedRequest);
}
async readState(canisterId, fields, _identity,
// eslint-disable-next-line
request) {
await this.#rootKeyGuard();
const canister = principal_1.Principal.from(canisterId);
function getRequestId(options) {
for (const path of options.paths) {
const [pathName, value] = path;
const request_status = new TextEncoder().encode('request_status');
if ((0, buffer_ts_1.uint8Equals)(pathName, request_status)) {
return value;
}
}
}
let transformedRequest;
let requestId;
// If a pre-signed request is provided, use it
if (request) {
// This is a pre-signed request
transformedRequest = request;
requestId = (0, request_id_ts_1.requestIdOf)(transformedRequest);
}
else {
// This is fields, we need to create a request
requestId = getRequestId(fields);
// Always create a fresh request with the current identity
const identity = await this.#identity;
if (!identity) {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.IdentityInvalidErrorCode());
}
transformedRequest = await this.createReadStateRequest(fields, identity);
}
this.log.print(`fetching "/api/v2/canister/${canister}/read_state" with request:`, transformedRequest);
const backoff = this.#backoffStrategy();
try {
const { responseBodyBytes } = await this.#requestAndRetry({
requestFn: () => this.#fetch('' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), {
...this.#fetchOptions,
...transformedRequest.request,
body: cbor.encode(transformedRequest.body),
}),
backoff,
tries: 0,
});
const decodedResponse = cbor.decode(responseBodyBytes);
this.log.print('Read state response:', decodedResponse);
return decodedResponse;
}
catch (error) {
let readStateError;
if (error instanceof errors_ts_1.AgentError) {
// override the error code to include the request details
error.code.requestContext = {
requestId,
senderPubKey: transformedRequest.body.sender_pubkey,
senderSignature: transformedRequest.body.sender_sig,
ingressExpiry: transformedRequest.body.content.ingress_expiry,
};
readStateError = error;
}
else {
readStateError = errors_ts_1.UnknownError.fromCode(new errors_ts_1.UnexpectedErrorCode(error));
}
this.log.error(`Error while making read state: ${readStateError.message}`, readStateError);
throw readStateError;
}
}
parseTimeFromResponse(response) {
let tree;
if (response.certificate) {
const decoded = cbor.decode(response.certificate);
if (decoded && 'tree' in decoded) {
tree = decoded.tree;
}
else {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.HashTreeDecodeErrorCode('Could not decode time from response'));
}
const timeLookup = (0, certificate_ts_1.lookup_path)(['time'], tree);
if (timeLookup.status !== certificate_ts_1.LookupPathStatus.Found) {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.LookupErrorCode('Time was not found in the response or was not in its expected format.', timeLookup.status));
}
if (!(timeLookup.value instanceof Uint8Array) && !ArrayBuffer.isView(timeLookup)) {
throw errors_ts_1.ProtocolError.fromCode(new errors_ts_1.MalformedLookupFoundValueErrorCode('Time was not in its expected format.'));
}
const date = (0, leb_ts_1.decodeTime)(timeLookup.value);
this.log.print('Time from response:', date);
this.log.print('Time from response in milliseconds:', date.getTime());
return date.getTime();
}
else {
this.log.warn('No certificate found in response');
}
return 0;
}
/**
* Allows agent to sync its time with the network. Can be called during intialization or mid-lifecycle if the device's clock has drifted away from the network time. This is necessary to set the Expiry for a request
* @param {Principal} canisterIdOverride - Pass a canister ID if you need to sync the time with a particular subnet. Uses the ICP ledger canister by default.
*/
async syncTime(canisterIdOverride) {
this.#syncTimePromise =
this.#syncTimePromise ??
(async () => {
await this.#rootKeyGuard();
const callTime = Date.now();
try {
if (!canisterIdOverride) {
this.log.print('Syncing time with the IC. No canisterId provided, so falling back to ryjl3-tyaaa-aaaaa-aaaba-cai');
}
// Fall back with canisterId of the ICP Ledger
const canisterId = canisterIdOverride ?? principal_1.Principal.from('ryjl3-tyaaa-aaaaa-aaaba-cai');
const anonymousAgent = HttpAgent.createSync({
identity: new auth_ts_1.AnonymousIdentity(),
host: this.host.toString(),
fetch: this.#fetch,
retryTimes: 0,
rootKey: this.rootKey ?? undefined,
shouldSyncTime: false,
});
const replicaTimes = await Promise.all(Array(3)
.fill(null)
.map(async () => {
const status = await (0, index_ts_1.request)({
canisterId,
agent: anonymousAgent,
paths: ['time'],
disableCertificateTimeVerification: true, // avoid recursive calls to syncTime
});
const date = status.get('time');
if (date instanceof Date) {
return date.getTime();
}
}, []));
const maxReplicaTime = replicaTimes.reduce((max, current) => {
return typeof current === 'number' && current > max ? current : max;
}, 0);
if (maxReplicaTime > 0) {
this.#timeDiffMsecs = maxReplicaTime - callTime;
this.#hasSyncedTime = true;
this.log.notify({
message: `Syncing time: offset of ${this.#timeDiffMsecs}`,
level: 'info',
});
}
}
catch (error) {
const syncTimeError = error instanceof errors_ts_1.AgentError
? error
: errors_ts_1.UnknownError.fromCode(new errors_ts_1.UnexpectedErrorCode(error));
this.log.error('Caught exception while attempting to sync time', syncTimeError);
throw syncTimeError;
}
})();
await this.#syncTimePromise.finally(() => {
this.#syncTimePromise = null;
});
}
async status() {
const headers = this.#credentials
? {
Authorization: 'Basic ' + btoa(this.#credentials),
}
: {};
this.log.print(`fetching "/api/v2/status"`);
const backoff = this.#backoffStrategy();
const { responseBodyBytes } = await this.#requestAndRetry({
backoff,
requestFn: () => this.#fetch('' + new URL(`/api/v2/status`, this.host), { headers, ...this.#fetchOptions }),
tries: 0,
});
return cbor.decode(responseBodyBytes);
}
async fetchRootKey() {
// Wait for already pending promise to avoid duplicate calls
this.#rootKeyPromise =
this.#rootKeyPromise ??
(async () => {
const value = await this.status();
// Hex-encoded version of the replica root key
this.rootKey = value.root_key;
return this.rootKey;
})();
// clear rootkey promise and return result
return await this.#rootKeyPromise.finally(() => {
this.#rootKeyPromise = null;
});
}
async #asyncGuard(canisterIdOverride) {
await Promise.all([this.#rootKeyGuard(), this.#syncTimeGuard(canisterIdOverride)]);
}
async #rootKeyGuard() {
if (this.rootKey) {
return;
}
else if (this.rootKey === null &&
this.host.toString() !== 'https://icp-api.io' &&
this.#shouldFetchRootKey) {
await this.fetchRootKey();
}
else {
throw errors_ts_1.ExternalError.fromCode(new errors_ts_1.MissingRootKeyErrorCode(this.#shouldFetchRootKey));
}
}
async #syncTimeGuard(canisterIdOverride) {
if (this.#shouldSyncTime && !this.hasSyncedTime()) {
await this.syncTime(canisterIdOverride);
}
}
invalidateIdentity() {
this.#identity = null;
}
replaceIdentity(identity) {
this.#identity = Promise.resolve(identity);
}
async fetchSubnetKeys(canisterId) {
const effectiveCanisterId = principal_1.Principal.from(canisterId);
await this.#asyncGuard(effectiveCanisterId);
const response = await (0, index_ts_1.request)({
canisterId: effectiveCanisterId,
paths: ['subnet'],
agent: this,
});
const subnetResponse = response.get('subnet');
if (subnetResponse && typeof subnetResponse === 'object' && 'nodeKeys' in subnetResponse) {
this.#subnetKeys.set(effectiveCanisterId.toText(), subnetResponse);
return subnetResponse;
}
// If the subnet status is not returned, return undefined
return undefined;
}
_transform(request) {
let p = Promise.resolve(request);
if (request.endpoint === types_ts_1.Endpoint.Call) {
for (const fn of this.#updatePipeline) {
p = p.then(r => fn(r).then(r2 => r2 || r));
}
}
else {
for (const fn of this.#queryPipeline) {
p = p.then(r => fn(r).then(r2 => r2 || r));
}
}
return p;
}
/**
* Returns the time difference in milliseconds between the IC network clock and the client's clock,
* after the clock has been synced.
*
* If the time has not been synced, returns `0`.
*/
getTimeDiffMsecs() {
return this.#timeDiffMsecs;
}
/**
* Returns `true` if the time has been synced at least once with the IC network, `false` otherwise.
*/
hasSyncedTime() {
return this.#hasSyncedTime;
}
}
exports.HttpAgent = HttpAgent;
/**
* Calculates the ingress expiry time based on the maximum allowed expiry in minutes and the time difference in milliseconds.
* The expiry is rounded down according to the {@link Expiry.fromDeltaInMilliseconds} method.
* @param maxIngressExpiryInMinutes - The maximum ingress expiry time in minutes.
* @param timeDiffMsecs - The time difference in milliseconds to adjust the expiry