UNPKG

@dfinity/agent

Version:

JavaScript and TypeScript library to interact with the Internet Computer

577 lines (522 loc) 19.3 kB
import { type Agent, type HttpDetailsResponse, isV2ResponseBody, isV3ResponseBody, QueryResponseStatus, } from './agent/index.ts'; import { CertifiedRejectErrorCode, ExternalError, InputError, MissingCanisterIdErrorCode, MissingRootKeyErrorCode, RejectError, UncertifiedRejectErrorCode, UncertifiedRejectUpdateErrorCode, UnexpectedErrorCode, UnknownError, } from './errors.ts'; import { IDL } from '@dfinity/candid'; import { pollForResponse, type PollingOptions, DEFAULT_POLLING_OPTIONS } from './polling/index.ts'; import { Principal } from '@dfinity/principal'; import { Certificate, type CreateCertificateOptions, lookupResultToBuffer } from './certificate.ts'; import { HttpAgent } from './agent/http/index.ts'; import { utf8ToBytes } from '@noble/hashes/utils'; /** * Configuration to make calls to the Replica. */ export interface CallConfig { /** * An agent to use in this call, otherwise the actor or call will try to discover the * agent to use. */ agent?: Agent; /** * Options for controlling polling behavior. */ pollingOptions?: PollingOptions; /** * The canister ID of this Actor. */ canisterId?: string | Principal; /** * The effective canister ID. */ effectiveCanisterId?: Principal; /** * The nonce to use for this call. This is used to prevent replay attacks. */ nonce?: Uint8Array; } /** * Configuration that can be passed to customize the Actor behavior. */ export interface ActorConfig extends Pick<CallConfig, 'agent' | 'effectiveCanisterId'> { /** * The Canister ID of this Actor. This is required for an Actor. */ canisterId: string | Principal; /** * An override function for update calls' CallConfig. This will be called on every calls. */ callTransform?( methodName: string, args: unknown[], callConfig: CallConfig, ): Partial<CallConfig> | void; /** * An override function for query calls' CallConfig. This will be called on every query. */ queryTransform?( methodName: string, args: unknown[], callConfig: CallConfig, ): Partial<CallConfig> | void; /** * Polyfill for BLS Certificate verification in case wasm is not supported */ blsVerify?: CreateCertificateOptions['blsVerify']; /** * Polling options to use when making update calls. This will override the default DEFAULT_POLLING_OPTIONS. */ pollingOptions?: PollingOptions; } // TODO: move this to proper typing when Candid support TypeScript. /** * A subclass of an actor. Actor class itself is meant to be a based class. */ export type ActorSubclass<T = Record<string, ActorMethod>> = Actor & T; /** * An actor method type, defined for each methods of the actor service. */ export interface ActorMethod<Args extends unknown[] = unknown[], Ret = unknown> { (...args: Args): Promise<Ret>; withOptions(options: CallConfig): (...args: Args) => Promise<Ret>; } /** * An actor method type, defined for each methods of the actor service. */ export interface ActorMethodWithHttpDetails<Args extends unknown[] = unknown[], Ret = unknown> extends ActorMethod { (...args: Args): Promise<{ httpDetails: HttpDetailsResponse; result: Ret }>; } /** * An actor method type, defined for each methods of the actor service. */ export interface ActorMethodExtended<Args extends unknown[] = unknown[], Ret = unknown> extends ActorMethod { (...args: Args): Promise<{ certificate?: Certificate; httpDetails?: HttpDetailsResponse; result: Ret; }>; } export type FunctionWithArgsAndReturn<Args extends unknown[] = unknown[], Ret = unknown> = ( ...args: Args ) => Ret; // Update all entries of T with the extra information from ActorMethodWithInfo export type ActorMethodMappedWithHttpDetails<T> = { [K in keyof T]: T[K] extends FunctionWithArgsAndReturn<infer Args, infer Ret> ? ActorMethodWithHttpDetails<Args, Ret> : never; }; // Update all entries of T with the extra information from ActorMethodWithInfo export type ActorMethodMappedExtended<T> = { [K in keyof T]: T[K] extends FunctionWithArgsAndReturn<infer Args, infer Ret> ? ActorMethodExtended<Args, Ret> : never; }; /** * Internal metadata for actors. It's an enhanced version of ActorConfig with * some fields marked as required (as they are defaulted) and canisterId as * a Principal type. */ interface ActorMetadata { service: IDL.ServiceClass; agent?: Agent; config: ActorConfig; } const metadataSymbol = Symbol.for('ic-agent-metadata'); export interface CreateActorClassOpts { httpDetails?: boolean; certificate?: boolean; } /** * An actor base class. An actor is an object containing only functions that will * return a promise. These functions are derived from the IDL definition. */ export class Actor { /** * Get the Agent class this Actor would call, or undefined if the Actor would use * the default agent (global.ic.agent). * @param actor The actor to get the agent of. */ public static agentOf(actor: Actor): Agent | undefined { return actor[metadataSymbol].config.agent; } /** * Get the interface of an actor, in the form of an instance of a Service. * @param actor The actor to get the interface of. */ public static interfaceOf(actor: Actor): IDL.ServiceClass { return actor[metadataSymbol].service; } public static canisterIdOf(actor: Actor): Principal { return Principal.from(actor[metadataSymbol].config.canisterId); } public static createActorClass( interfaceFactory: IDL.InterfaceFactory, options?: CreateActorClassOpts, ): ActorConstructor { const service = interfaceFactory({ IDL }); class CanisterActor extends Actor { [x: string]: ActorMethod; constructor(config: ActorConfig) { if (!config.canisterId) { throw InputError.fromCode(new MissingCanisterIdErrorCode(config.canisterId)); } const canisterId = typeof config.canisterId === 'string' ? Principal.fromText(config.canisterId) : config.canisterId; super({ config: { ...DEFAULT_ACTOR_CONFIG, ...config, canisterId, }, service, }); for (const [methodName, func] of service._fields) { if (options?.httpDetails) { func.annotations.push(ACTOR_METHOD_WITH_HTTP_DETAILS); } if (options?.certificate) { func.annotations.push(ACTOR_METHOD_WITH_CERTIFICATE); } this[methodName] = _createActorMethod(this, methodName, func, config.blsVerify); } } } return CanisterActor; } /** * Creates an actor with the given interface factory and configuration. * * The [`@icp-sdk/bindgen`](https://js.icp.build/bindgen/) package can be used to generate the interface factory for your canister. * @param interfaceFactory - the interface factory for the actor, typically generated by the [`@icp-sdk/bindgen`](https://js.icp.build/bindgen/) package * @param configuration - the configuration for the actor * @returns an actor with the given interface factory and configuration * @see The {@link https://js.icp.build/core/latest/canister-environment/ | Canister Environment Guide} for more details on how to configure an actor using the canister environment. * @example * Using the interface factory generated by the [`@icp-sdk/bindgen`](https://js.icp.build/bindgen/) package: * ```ts * import { Actor, HttpAgent } from '@icp-sdk/core/agent'; * import { Principal } from '@icp-sdk/core/principal'; * import { idlFactory } from './api/declarations/hello-world.did'; * * // For a convenient way to get the canister ID, * // see the https://js.icp.build/core/latest/canister-environment/ guide. * const canisterId = Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai'); * * const agent = await HttpAgent.create({ * host: 'https://icp-api.io', * }); * * const actor = Actor.createActor(idlFactory, { * agent, * canisterId, * }); * * const response = await actor.greet('world'); * console.log(response); * ``` * @example * Using the `createActor` wrapper function generated by the [`@icp-sdk/bindgen`](https://js.icp.build/bindgen/) package: * ```ts * import { HttpAgent } from '@icp-sdk/core/agent'; * import { Principal } from '@icp-sdk/core/principal'; * import { createActor } from './api/hello-world'; * * // For a convenient way to get the canister ID, * // see the https://js.icp.build/core/latest/canister-environment/ guide. * const canisterId = Principal.fromText('rrkah-fqaaa-aaaaa-aaaaq-cai'); * * const agent = await HttpAgent.create({ * host: 'https://icp-api.io', * }); * * const actor = createActor(canisterId, { * agent, * }); * * const response = await actor.greet('world'); * console.log(response); * ``` */ public static createActor<T = Record<string, ActorMethod>>( interfaceFactory: IDL.InterfaceFactory, configuration: ActorConfig, ): ActorSubclass<T> { if (!configuration.canisterId) { throw InputError.fromCode(new MissingCanisterIdErrorCode(configuration.canisterId)); } return new (this.createActorClass(interfaceFactory))( configuration, ) as unknown as ActorSubclass<T>; } /** * Returns an actor with methods that return the http response details along with the result * @param interfaceFactory - the interface factory for the actor * @param configuration - the configuration for the actor * @deprecated - use createActor with actorClassOptions instead */ public static createActorWithHttpDetails<T = Record<string, ActorMethod>>( interfaceFactory: IDL.InterfaceFactory, configuration: ActorConfig, ): ActorSubclass<ActorMethodMappedWithHttpDetails<T>> { return new (this.createActorClass(interfaceFactory, { httpDetails: true }))( configuration, ) as unknown as ActorSubclass<ActorMethodMappedWithHttpDetails<T>>; } /** * Returns an actor with methods that return the http response details along with the result * @param interfaceFactory - the interface factory for the actor * @param configuration - the configuration for the actor * @param actorClassOptions - options for the actor class extended details to return with the result */ public static createActorWithExtendedDetails<T = Record<string, ActorMethod>>( interfaceFactory: IDL.InterfaceFactory, configuration: ActorConfig, actorClassOptions: CreateActorClassOpts = { httpDetails: true, certificate: true, }, ): ActorSubclass<ActorMethodMappedExtended<T>> { return new (this.createActorClass(interfaceFactory, actorClassOptions))( configuration, ) as unknown as ActorSubclass<ActorMethodMappedExtended<T>>; } private [metadataSymbol]: ActorMetadata; protected constructor(metadata: ActorMetadata) { this[metadataSymbol] = Object.freeze(metadata); } } // IDL functions can have multiple return values, so decoding always // produces an array. Ensure that functions with single or zero return // values behave as expected. function decodeReturnValue(types: IDL.Type[], msg: Uint8Array) { const returnValues = IDL.decode(types, msg); switch (returnValues.length) { case 0: return undefined; case 1: return returnValues[0]; default: return returnValues; } } const DEFAULT_ACTOR_CONFIG: Partial<ActorConfig> = { pollingOptions: DEFAULT_POLLING_OPTIONS, }; export type ActorConstructor = new (config: ActorConfig) => ActorSubclass; export const ACTOR_METHOD_WITH_HTTP_DETAILS = 'http-details'; export const ACTOR_METHOD_WITH_CERTIFICATE = 'certificate'; function _createActorMethod( actor: Actor, methodName: string, func: IDL.FuncClass, blsVerify?: CreateCertificateOptions['blsVerify'], ): ActorMethod { let caller: (options: CallConfig, ...args: unknown[]) => Promise<unknown>; if (func.annotations.includes('query') || func.annotations.includes('composite_query')) { caller = async (options, ...args) => { // First, if there's a config transformation, call it. options = { ...options, ...actor[metadataSymbol].config.queryTransform?.(methodName, args, { ...actor[metadataSymbol].config, ...options, }), }; const agent = options.agent || actor[metadataSymbol].config.agent || new HttpAgent(); const cid = Principal.from(options.canisterId || actor[metadataSymbol].config.canisterId); const arg = IDL.encode(func.argTypes, args); const result = await agent.query(cid, { methodName, arg, effectiveCanisterId: options.effectiveCanisterId, }); const httpDetails = { ...result.httpDetails, requestDetails: result.requestDetails, } as HttpDetailsResponse; switch (result.status) { case QueryResponseStatus.Rejected: { const uncertifiedRejectErrorCode = new UncertifiedRejectErrorCode( result.requestId, result.reject_code, result.reject_message, result.error_code, result.signatures, ); uncertifiedRejectErrorCode.callContext = { canisterId: cid, methodName, httpDetails, }; throw RejectError.fromCode(uncertifiedRejectErrorCode); } case QueryResponseStatus.Replied: return func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS) ? { httpDetails, result: decodeReturnValue(func.retTypes, result.reply.arg), } : decodeReturnValue(func.retTypes, result.reply.arg); } }; } else { caller = async (options, ...args) => { // First, if there's a config transformation, call it. options = { ...options, ...actor[metadataSymbol].config.callTransform?.(methodName, args, { ...actor[metadataSymbol].config, ...options, }), }; const agent = options.agent || actor[metadataSymbol].config.agent || HttpAgent.createSync(); const { canisterId, effectiveCanisterId, pollingOptions } = { ...DEFAULT_ACTOR_CONFIG, ...actor[metadataSymbol].config, ...options, }; const cid = Principal.from(canisterId); const ecid = effectiveCanisterId !== undefined ? Principal.from(effectiveCanisterId) : cid; const arg = IDL.encode(func.argTypes, args); const { requestId, response, requestDetails } = await agent.call(cid, { methodName, arg, effectiveCanisterId: ecid, nonce: options.nonce, }); let reply: Uint8Array | undefined; let certificate: Certificate | undefined; if (isV3ResponseBody(response.body)) { if (agent.rootKey == null) { throw ExternalError.fromCode(new MissingRootKeyErrorCode()); } const cert = response.body.certificate; certificate = await Certificate.create({ certificate: cert, rootKey: agent.rootKey, canisterId: ecid, blsVerify, agent, }); const path = [utf8ToBytes('request_status'), requestId]; const status = new TextDecoder().decode( lookupResultToBuffer(certificate.lookup_path([...path, 'status'])), ); switch (status) { case 'replied': reply = lookupResultToBuffer(certificate.lookup_path([...path, 'reply'])); break; case 'rejected': { // Find rejection details in the certificate const rejectCode = new Uint8Array( lookupResultToBuffer(certificate.lookup_path([...path, 'reject_code']))!, )[0]; const rejectMessage = new TextDecoder().decode( lookupResultToBuffer(certificate.lookup_path([...path, 'reject_message']))!, ); const error_code_buf = lookupResultToBuffer( certificate.lookup_path([...path, 'error_code']), ); const error_code = error_code_buf ? new TextDecoder().decode(error_code_buf) : undefined; const certifiedRejectErrorCode = new CertifiedRejectErrorCode( requestId, rejectCode, rejectMessage, error_code, ); certifiedRejectErrorCode.callContext = { canisterId: cid, methodName, httpDetails: response, }; throw RejectError.fromCode(certifiedRejectErrorCode); } } } else if (isV2ResponseBody(response.body)) { const { reject_code, reject_message, error_code } = response.body; const errorCode = new UncertifiedRejectUpdateErrorCode( requestId, reject_code, reject_message, error_code, ); errorCode.callContext = { canisterId: cid, methodName, httpDetails: response, }; throw RejectError.fromCode(errorCode); } // Fall back to polling if we receive an Accepted response code if (response.status === 202) { const pollOptions: PollingOptions = { ...pollingOptions, blsVerify, }; // Contains the certificate and the reply from the boundary node const response = await pollForResponse(agent, ecid, requestId, pollOptions); certificate = response.certificate; reply = response.reply; } const shouldIncludeHttpDetails = func.annotations.includes(ACTOR_METHOD_WITH_HTTP_DETAILS); const shouldIncludeCertificate = func.annotations.includes(ACTOR_METHOD_WITH_CERTIFICATE); const httpDetails = { ...response, requestDetails } as HttpDetailsResponse; if (reply !== undefined) { if (shouldIncludeHttpDetails && shouldIncludeCertificate) { return { httpDetails, certificate, result: decodeReturnValue(func.retTypes, reply), }; } else if (shouldIncludeCertificate) { return { certificate, result: decodeReturnValue(func.retTypes, reply), }; } else if (shouldIncludeHttpDetails) { return { httpDetails, result: decodeReturnValue(func.retTypes, reply), }; } return decodeReturnValue(func.retTypes, reply); } else { const errorCode = new UnexpectedErrorCode( `Call was returned undefined. We cannot determine if the call was successful or not. Return types: [${func.retTypes.map(t => t.display()).join(',')}].`, ); errorCode.callContext = { canisterId: cid, methodName, httpDetails, }; throw UnknownError.fromCode(errorCode); } }; } const handler = (...args: unknown[]) => caller({}, ...args); handler.withOptions = (options: CallConfig) => (...args: unknown[]) => caller(options, ...args); return handler as ActorMethod; }