diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 02ec115d..b50095a8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,11 @@ ### Added +- feat: sync_call support in HttpAgent and Actor + - Skips polling if the sync call succeeds and provides a certificate + - Falls back to v2 api if the v3 endpoint 404's + - Adds certificate to SubmitResponse endpoint + - adds callSync option to `HttpAgent.call`, which defaults to `true` - feat: management canister interface updates for schnorr signatures - feat: ensure that identity-secp256k1 seed phrase must produce a 64 byte seed - docs: documentation and metadata for use-auth-client diff --git a/e2e/node/basic/counter.test.ts b/e2e/node/basic/counter.test.ts index 5793d008..f3d333a8 100644 --- a/e2e/node/basic/counter.test.ts +++ b/e2e/node/basic/counter.test.ts @@ -1,4 +1,5 @@ -import counterCanister, { createActor } from '../canisters/counter'; +import { Actor, HttpAgent } from '@dfinity/agent'; +import counterCanister, { idl } from '../canisters/counter'; import { it, expect, describe, vi } from 'vitest'; describe('counter', () => { @@ -37,26 +38,49 @@ describe('counter', () => { describe('retrytimes', () => { it('should retry after a failure', async () => { let count = 0; + const { canisterId } = await counterCanister(); const fetchMock = vi.fn(function (...args) { - if (count <= 1) { - count += 1; + count += 1; + // let the first 3 requests pass, then throw an error on the call + if (count === 3) { return new Response('Test error - ignore', { status: 500, statusText: 'Internal Server Error', }); } + // eslint-disable-next-line prefer-spread - return fetch.apply( - null, - args as [input: string | Request, init?: RequestInit | CMRequestInit | undefined], - ); + return fetch.apply(null, args as [input: string | Request, init?: RequestInit | undefined]); }); - const counter = await createActor({ fetch: fetchMock as typeof fetch, retryTimes: 3 }); - try { - expect(await counter.greet('counter')).toEqual('Hello, counter!'); - } catch (error) { - console.error(error); + const counter = await Actor.createActor(idl, { + canisterId, + agent: await HttpAgent.create({ + fetch: fetchMock as typeof fetch, + retryTimes: 3, + host: 'http://localhost:4943', + shouldFetchRootKey: true, + }), + }); + + const result = await counter.greet('counter'); + expect(result).toEqual('Hello, counter!'); + + // The number of calls should be 4 or more, depending on whether the test environment is using v3 or v2 + if (findV2inCalls(fetchMock.mock.calls as [string, Request][]) === -1) { + // TODO - pin to 4 once dfx v0.23.0 is released + expect(fetchMock.mock.calls.length).toBe(4); + } else { + expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(4); } }, 40000); }); + +const findV2inCalls = (calls: [string, Request][]) => { + for (let i = 0; i < calls.length; i++) { + if (calls[i][0].includes('v2')) { + return i; + } + } + return -1; +}; diff --git a/e2e/node/basic/watermark.test.ts b/e2e/node/basic/watermark.test.ts index 22496fa0..adad9a3a 100644 --- a/e2e/node/basic/watermark.test.ts +++ b/e2e/node/basic/watermark.test.ts @@ -114,16 +114,44 @@ test('replay attack', async () => { expect(startValue3).toBe(1n); const queryResponseIndex = indexOfQueryResponse(fetchProxy.history); + console.log(queryResponseIndex); fetchProxy.replayFromHistory(queryResponseIndex); - // the replayed request should throw an error - expect(fetchProxy.calls).toBe(7); + // The number of calls should be 4 or more, depending on whether the test environment is using v3 or v2 + const usingV2 = + findV2inCalls( + fetchProxy.history.map(response => { + return [response.url]; + }), + ) !== -1; + if (usingV2) { + // TODO - pin to 5 once dfx v0.23.0 is released + // the replayed request should throw an error + expect(fetchProxy.calls).toBe(5); + } else { + expect(fetchProxy.calls).toBeGreaterThanOrEqual(5); + } await expect(actor.read()).rejects.toThrowError( 'Timestamp failed to pass the watermark after retrying the configured 3 times. We cannot guarantee the integrity of the response since it could be a replay attack.', ); - // The agent should should have made 4 additional requests (3 retries + 1 original request) - expect(fetchProxy.calls).toBe(11); + // TODO - pin to 9 once dfx v0.23.0 is released + if (usingV2) { + // the replayed request should throw an error + // The agent should should have made 4 additional requests (3 retries + 1 original request) + expect(fetchProxy.calls).toBe(9); + } else { + expect(fetchProxy.calls).toBeGreaterThanOrEqual(9); + } }, 10_000); + +const findV2inCalls = (calls: [string][]) => { + for (let i = 0; i < calls.length; i++) { + if (calls[i][0].includes('v2')) { + return i; + } + } + return -1; +}; diff --git a/e2e/node/canisters/counter.ts b/e2e/node/canisters/counter.ts index 45456bf3..c1439b22 100644 --- a/e2e/node/canisters/counter.ts +++ b/e2e/node/canisters/counter.ts @@ -11,7 +11,7 @@ let cache: { actor: any; } | null = null; -const idl = ({ IDL }) => { +export const idl = ({ IDL }) => { return IDL.Service({ inc: IDL.Func([], [], []), inc_read: IDL.Func([], [IDL.Nat], []), diff --git a/e2e/node/integration/actor.test.ts b/e2e/node/integration/actor.test.ts index 49b48cf4..855dce28 100644 --- a/e2e/node/integration/actor.test.ts +++ b/e2e/node/integration/actor.test.ts @@ -14,9 +14,9 @@ test("Legacy Agent interface should be accepted by Actor's createActor", async ( ); // Verify that update calls work - await actor.write(8n); //? + await actor.write(8n); // Verify that query calls work - const count = await actor.read(); //? + const count = await actor.read(); expect(count).toBe(8n); }, 15_000); // TODO: tests for rejected, unknown time out diff --git a/packages/agent/src/actor.test.ts b/packages/agent/src/actor.test.ts index e848586f..d15483d6 100644 --- a/packages/agent/src/actor.test.ts +++ b/packages/agent/src/actor.test.ts @@ -6,7 +6,7 @@ import { CallRequest, SubmitRequestType, UnSigned } from './agent/http/types'; import * as cbor from './cbor'; import { requestIdOf } from './request_id'; import * as pollingImport from './polling'; -import { ActorConfig } from './actor'; +import { Actor, ActorConfig } from './actor'; const importActor = async (mockUpdatePolling?: () => void) => { jest.dontMock('./polling'); @@ -329,7 +329,7 @@ describe('makeActor', () => { `); expect(replyUpdateWithHttpDetails.result).toEqual(canisterDecodedReturnValue); - replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); //? + replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); expect(replyUpdateWithHttpDetails.httpDetails).toMatchSnapshot(); }); diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 88fe7f9d..f347f6fa 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -9,12 +9,12 @@ import { SubmitResponse, } from './agent'; import { AgentError } from './errors'; -import { IDL } from '@dfinity/candid'; +import { bufFromBufLike, IDL } from '@dfinity/candid'; import { pollForResponse, PollStrategyFactory, strategy } from './polling'; import { Principal } from '@dfinity/principal'; import { RequestId } from './request_id'; import { toHex } from './utils/buffer'; -import { Certificate, CreateCertificateOptions } from './certificate'; +import { Certificate, CreateCertificateOptions, lookupResultToBuffer } from './certificate'; import managementCanisterIdl from './canisters/management_idl'; import _SERVICE, { canister_install_mode, canister_settings } from './canisters/management_service'; @@ -525,35 +525,49 @@ function _createActorMethod( const ecid = effectiveCanisterId !== undefined ? Principal.from(effectiveCanisterId) : cid; const arg = IDL.encode(func.argTypes, args); + if (agent.rootKey == null) + throw new AgentError('Agent root key not initialized before making call'); + const { requestId, response, requestDetails } = await agent.call(cid, { methodName, arg, effectiveCanisterId: ecid, }); - - requestId; - response; - requestDetails; - - if (!response.ok || response.body /* IC-1462 */) { - throw new UpdateCallRejectedError(cid, methodName, requestId, response); + let reply: ArrayBuffer | undefined; + let certificate: Certificate | undefined; + if (response.body && response.body.certificate) { + const cert = response.body.certificate; + certificate = await Certificate.create({ + certificate: bufFromBufLike(cert), + rootKey: agent.rootKey, + canisterId: Principal.from(canisterId), + blsVerify, + }); + const path = [new TextEncoder().encode('request_status'), requestId]; + const status = new TextDecoder().decode( + lookupResultToBuffer(certificate.lookup([...path, 'status'])), + ); + + switch (status) { + case 'replied': + reply = lookupResultToBuffer(certificate.lookup([...path, 'reply'])); + break; + case 'rejected': + throw new UpdateCallRejectedError(cid, methodName, requestId, response); + } + } + // Fall back to polling if we recieve an Accepted response code + if (response.status === 202) { + const pollStrategy = pollingStrategyFactory(); + // Contains the certificate and the reply from the boundary node + const response = await pollForResponse(agent, ecid, requestId, pollStrategy, blsVerify); + certificate = response.certificate; + reply = response.reply; } - - const pollStrategy = pollingStrategyFactory(); - // Contains the certificate and the reply from the boundary node - const { certificate, reply } = await pollForResponse( - agent, - ecid, - requestId, - pollStrategy, - blsVerify, - ); - 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 { diff --git a/packages/agent/src/agent/api.ts b/packages/agent/src/agent/api.ts index 58e204d2..a4425ae4 100644 --- a/packages/agent/src/agent/api.ts +++ b/packages/agent/src/agent/api.ts @@ -131,6 +131,8 @@ export interface SubmitResponse { error_code?: string; reject_code: number; reject_message: string; + // Available in a v3 call response + certificate?: ArrayBuffer; } | null; headers: HttpHeaderField[]; }; diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index dbe2f730..386b6bda 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -13,7 +13,7 @@ import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; import { JSDOM } from 'jsdom'; -import { AnonymousIdentity, SignIdentity, toHex } from '../..'; +import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { AgentError } from '../../errors'; import { AgentHTTPResponseError } from './errors'; @@ -106,7 +106,7 @@ test('call', async () => { expect(requestId).toEqual(expectedRequestId); const call1 = calls[0][0]; const call2 = calls[0][1]; - expect(call1).toBe(`http://127.0.0.1/api/v2/canister/${canisterId.toText()}/call`); + expect(call1).toBe(`http://127.0.0.1/api/v3/canister/${canisterId.toText()}/call`); expect(call2.method).toEqual('POST'); expect(call2.body).toEqual(cbor.encode(expectedRequest)); expect(call2.headers['Content-Type']).toEqual('application/cbor'); @@ -320,7 +320,7 @@ test('use anonymous principal if unspecified', async () => { expect(calls.length).toBe(1); expect(requestId).toEqual(expectedRequestId); - expect(calls[0][0]).toBe(`http://127.0.0.1/api/v2/canister/${canisterId.toText()}/call`); + expect(calls[0][0]).toBe(`http://127.0.0.1/api/v3/canister/${canisterId.toText()}/call`); const call2 = calls[0][1]; expect(call2.method).toEqual('POST'); expect(call2.body).toEqual(cbor.encode(expectedRequest)); @@ -812,3 +812,4 @@ test('it should log errors to console if the option is set', async () => { const agent = new HttpAgent({ host: HTTP_AGENT_HOST, fetch: jest.fn(), logToConsole: true }); await agent.syncTime(); }); + diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index 387962e0..067cbe9e 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -410,9 +410,11 @@ export class HttpAgent implements Agent { methodName: string; arg: ArrayBuffer; effectiveCanisterId?: Principal | string; + callSync?: boolean; }, identity?: Identity | Promise, ): Promise { + const callSync = options.callSync ?? true; const id = await (identity !== undefined ? await identity : await this.#identity); if (!id) { throw new IdentityInvalidError( @@ -470,44 +472,84 @@ export class HttpAgent implements Agent { transformedRequest = await id.transformRequest(transformedRequest); const body = cbor.encode(transformedRequest.body); - - this.log.print( - `fetching "/api/v2/canister/${ecid.toText()}/call" with request:`, - transformedRequest, - ); - - // Run both in parallel. The fetch is quite expensive, so we have plenty of time to - // calculate the requestId locally. const backoff = this.#backoffStrategy(); - const request = this.#requestAndRetry({ - request: () => - this.#fetch('' + new URL(`/api/v2/canister/${ecid.toText()}/call`, this.host), { + 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, - }), - backoff, - tries: 0, - }); + }); + }; + + 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 [response, requestId] = await Promise.all([request, requestIdOf(submit)]); - const responseBuffer = await response.arrayBuffer(); - const responseBody = ( - response.status === 200 && responseBuffer.byteLength > 0 ? cbor.decode(responseBuffer) : null - ) as SubmitResponse['response']['body']; + const request = this.#requestAndRetry({ + request: callSync ? requestSync : requestAsync, + backoff, + tries: 0, + }); - return { - requestId, - response: { - ok: response.ok, - status: response.status, - statusText: response.statusText, - body: responseBody, - headers: httpHeadersTransform(response.headers), - }, - requestDetails: submit, - }; + const [response, requestId] = await Promise.all([request, requestIdOf(submit)]); + + const responseBuffer = await response.arrayBuffer(); + const responseBody = ( + response.status === 200 && responseBuffer.byteLength > 0 + ? cbor.decode(responseBuffer) + : null + ) as SubmitResponse['response']['body']; + + // Update the watermark with the latest time from consensus + if (responseBody?.certificate) { + const time = await this.parseTimeFromResponse({ certificate: responseBody.certificate }); + this.#waterMark = time; + } + + return { + requestId, + response: { + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: responseBody, + headers: httpHeadersTransform(response.headers), + }, + requestDetails: submit, + }; + } catch (error) { + // If the error is due to the v3 api not being supported, fall back to v2 + if ((error as AgentError).message.includes('v3 api not supported.')) { + this.log.warn('v3 api not supported. Fall back to v2'); + return this.call( + canisterId, + { + ...options, + // disable v3 api + callSync: false, + }, + identity, + ); + } + + this.log.error('Error while making call:', error as Error); + throw error; + } } async #requestAndRetryQuery(args: { @@ -680,9 +722,18 @@ export class HttpAgent implements Agent { ` Code: ${response.status} (${response.statusText})\n` + ` Body: ${responseText}\n`; + if (response.status === 404 && response.url.includes('api/v3')) { + throw new AgentHTTPResponseError('v3 api not supported. Fall back to v2', { + ok: response.ok, + status: response.status, + statusText: response.statusText, + headers: httpHeadersTransform(response.headers), + }); + } if (tries < this.#retryTimes) { return await this.#requestAndRetry({ request, backoff, tries: tries + 1 }); } + throw new AgentHTTPResponseError(errorMessage, { ok: response.ok, status: response.status, @@ -933,38 +984,45 @@ export class HttpAgent implements Agent { ); // TODO - https://dfinity.atlassian.net/browse/SDK-1092 const backoff = this.#backoffStrategy(); + try { + const response = await this.#requestAndRetry({ + request: () => + this.#fetch( + '' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), + { + ...this.#fetchOptions, + ...transformedRequest.request, + body, + }, + ), + backoff, + tries: 0, + }); - const response = await this.#requestAndRetry({ - request: () => - this.#fetch('' + new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host), { - ...this.#fetchOptions, - ...transformedRequest.request, - body, - }), - backoff, - tries: 0, - }); + if (!response.ok) { + throw new Error( + `Server returned an error:\n` + + ` Code: ${response.status} (${response.statusText})\n` + + ` Body: ${await response.text()}\n`, + ); + } + const decodedResponse: ReadStateResponse = cbor.decode(await response.arrayBuffer()); - if (!response.ok) { - throw new Error( - `Server returned an error:\n` + - ` Code: ${response.status} (${response.statusText})\n` + - ` Body: ${await response.text()}\n`, - ); - } - const decodedResponse: ReadStateResponse = cbor.decode(await response.arrayBuffer()); + this.log.print('Read state response:', decodedResponse); + const parsedTime = await this.parseTimeFromResponse(decodedResponse); + if (parsedTime > 0) { + this.log.print('Read state response time:', parsedTime); + this.#waterMark = parsedTime; + } - this.log.print('Read state response:', decodedResponse); - const parsedTime = await this.parseTimeFromResponse(decodedResponse); - if (parsedTime > 0) { - this.log.print('Read state response time:', parsedTime); - this.#waterMark = parsedTime; + return decodedResponse; + } catch (error) { + this.log.error('Caught exception while attempting to read state', error as AgentError); + throw error; } - - return decodedResponse; } - public async parseTimeFromResponse(response: ReadStateResponse): Promise { + public async parseTimeFromResponse(response: { certificate: ArrayBuffer }): Promise { let tree: HashTree; if (response.certificate) { const decoded: { tree: HashTree } | undefined = cbor.decode(response.certificate); @@ -1040,8 +1098,9 @@ export class HttpAgent implements Agent { public async fetchRootKey(): Promise { if (!this.#rootKeyFetched) { + const status = await this.status(); // Hex-encoded version of the replica root key - this.rootKey = ((await this.status()) as JsonObject & { root_key: ArrayBuffer }).root_key; + this.rootKey = (status as JsonObject & { root_key: ArrayBuffer }).root_key; this.#rootKeyFetched = true; } return this.rootKey;