UNPKG

@dfinity/agent

Version:

JavaScript and TypeScript library to interact with the Internet Computer

251 lines (227 loc) 8.31 kB
import { type RequestId } from '../request_id.ts'; import { type CreateCertificateOptions, Certificate, lookupResultToBuffer, } from '../certificate.ts'; import { type Agent, type ReadStateResponse } from '../agent/api.ts'; import { Principal } from '@dfinity/principal'; import { CertifiedRejectErrorCode, ExternalError, InputError, InvalidReadStateRequestErrorCode, MissingRootKeyErrorCode, RejectError, RequestStatusDoneNoReplyErrorCode, UnknownError, UNREACHABLE_ERROR, } from '../errors.ts'; export * as strategy from './strategy.ts'; import { defaultStrategy } from './strategy.ts'; import { ReadRequestType, type ReadStateRequest } from '../agent/http/types.ts'; import { RequestStatusResponseStatus } from '../agent/index.ts'; import { utf8ToBytes } from '@noble/hashes/utils'; export { defaultStrategy } from './strategy.ts'; export type PollStrategy = ( canisterId: Principal, requestId: RequestId, status: RequestStatusResponseStatus, ) => Promise<void>; interface SignedReadStateRequestWithExpiry extends ReadStateRequest { body: { content: Pick<ReadStateRequest, 'request_type' | 'ingress_expiry'>; }; } /** * Options for controlling polling behavior */ export interface PollingOptions { /** * A polling strategy that dictates how much and often we should poll the * read_state endpoint to get the result of an update call. * @default {@link defaultStrategy} */ strategy?: PollStrategy; /** * Whether to reuse the same signed request for polling or create a new unsigned request each time. * @default false */ preSignReadStateRequest?: boolean; /** * Optional replacement function that verifies the BLS signature of a certificate. */ blsVerify?: CreateCertificateOptions['blsVerify']; /** * The request to use for polling. If not provided, a new request will be created. * This is only used if `preSignReadStateRequest` is set to false. */ request?: ReadStateRequest; } export const DEFAULT_POLLING_OPTIONS: PollingOptions = { preSignReadStateRequest: false, }; /** * Check if an object has a property * @param value the object that might have the property * @param property the key of property we're looking for */ function hasProperty<O extends object, P extends string>( value: O, property: P, ): value is O & Record<P, unknown> { return Object.prototype.hasOwnProperty.call(value, property); } function isObjectWithProperty<O extends object, P extends string>( value: unknown, property: P, ): value is O & Record<P, unknown> { return value !== null && typeof value === 'object' && hasProperty(value, property); } function hasFunction<O extends object, P extends string>( value: O, property: P, ): value is O & Record<P, (...args: unknown[]) => unknown> { return hasProperty(value, property) && typeof value[property] === 'function'; } /** * Check if value is a signed read state request with expiry * @param value to check */ function isSignedReadStateRequestWithExpiry( value: unknown, ): value is SignedReadStateRequestWithExpiry { return ( isObjectWithProperty(value, 'body') && isObjectWithProperty(value.body, 'content') && (value.body.content as { request_type: ReadRequestType }).request_type === ReadRequestType.ReadState && isObjectWithProperty(value.body.content, 'ingress_expiry') && typeof value.body.content.ingress_expiry === 'object' && value.body.content.ingress_expiry !== null && hasFunction(value.body.content.ingress_expiry, 'toHash') ); } /** * Polls the IC to check the status of the given request then * returns the response bytes once the request has been processed. * @param agent The agent to use to poll read_state. * @param canisterId The effective canister ID. * @param requestId The Request ID to poll status for. * @param options polling options to control behavior */ export async function pollForResponse( agent: Agent, canisterId: Principal, requestId: RequestId, options: PollingOptions = {}, ): Promise<{ certificate: Certificate; reply: Uint8Array; }> { const path = [utf8ToBytes('request_status'), requestId]; let state: ReadStateResponse; let currentRequest: ReadStateRequest | undefined; const preSignReadStateRequest = options.preSignReadStateRequest ?? false; if (preSignReadStateRequest) { // If preSignReadStateRequest is true, we need to create a new request currentRequest = await constructRequest({ paths: [path], agent, pollingOptions: options, }); state = await agent.readState(canisterId, { paths: [path] }, undefined, currentRequest); } else { // If preSignReadStateRequest is false, we use the default strategy and sign the request each time state = await agent.readState(canisterId, { paths: [path] }); } if (agent.rootKey == null) { throw ExternalError.fromCode(new MissingRootKeyErrorCode()); } const cert = await Certificate.create({ certificate: state.certificate, rootKey: agent.rootKey, canisterId: canisterId, blsVerify: options.blsVerify, agent, }); const maybeBuf = lookupResultToBuffer(cert.lookup_path([...path, utf8ToBytes('status')])); let status; if (typeof maybeBuf === 'undefined') { // Missing requestId means we need to wait status = RequestStatusResponseStatus.Unknown; } else { status = new TextDecoder().decode(maybeBuf); } switch (status) { case RequestStatusResponseStatus.Replied: { return { reply: lookupResultToBuffer(cert.lookup_path([...path, 'reply']))!, certificate: cert, }; } case RequestStatusResponseStatus.Received: case RequestStatusResponseStatus.Unknown: case RequestStatusResponseStatus.Processing: { // Execute the polling strategy, then retry. const strategy = options.strategy ?? defaultStrategy(); await strategy(canisterId, requestId, status); return pollForResponse(agent, canisterId, requestId, { ...options, // Pass over either the strategy already provided or the new one created above strategy, request: currentRequest, }); } case RequestStatusResponseStatus.Rejected: { const rejectCode = new Uint8Array( lookupResultToBuffer(cert.lookup_path([...path, 'reject_code']))!, )[0]; const rejectMessage = new TextDecoder().decode( lookupResultToBuffer(cert.lookup_path([...path, 'reject_message']))!, ); const errorCodeBuf = lookupResultToBuffer(cert.lookup_path([...path, 'error_code'])); const errorCode = errorCodeBuf ? new TextDecoder().decode(errorCodeBuf) : undefined; throw RejectError.fromCode( new CertifiedRejectErrorCode(requestId, rejectCode, rejectMessage, errorCode), ); } case RequestStatusResponseStatus.Done: // This is _technically_ not an error, but we still didn't see the `Replied` status so // we don't know the result and cannot decode it. throw UnknownError.fromCode(new RequestStatusDoneNoReplyErrorCode(requestId)); } throw UNREACHABLE_ERROR; } // Determine if we should reuse the read state request or create a new one // based on the options provided. /** * Constructs a read state request for the given paths. * If the request is already signed and has an expiry, it will be returned as is. * Otherwise, a new request will be created. * @param options The options to use for creating the request. * @param options.paths The paths to read from. * @param options.agent The agent to use to create the request. * @param options.pollingOptions The options to use for creating the request. * @returns The read state request. */ export async function constructRequest(options: { paths: Uint8Array[][]; agent: Agent; pollingOptions: PollingOptions; }): Promise<ReadStateRequest> { const { paths, agent, pollingOptions } = options; if (pollingOptions.request && isSignedReadStateRequestWithExpiry(pollingOptions.request)) { return pollingOptions.request; } const request = await agent.createReadStateRequest?.( { paths, }, undefined, ); if (!isSignedReadStateRequestWithExpiry(request)) { throw InputError.fromCode(new InvalidReadStateRequestErrorCode(request)); } return request; }