diff --git a/CHANGELOG.md b/CHANGELOG.md index 38699798f..1ef4681a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Renames `v3ResponseBody` to `v4ResponseBody` - Renames `isV3ResponseBody` to `isV4ResponseBody` - Renames `HttpV3ApiNotSupportedErrorCode` to `HttpV4ApiNotSupportedErrorCode` +- feat(agent)!: use `/api/v3` for query and read_state requests - feat(agent)!: supports both subnet id and canister id for certificate verification - The `canisterId` option has been replaced with the `principal` option in the `Certificate.create` options object - feat(assets)!: replaces `@dfinity/{agent,candid,principal}` deps with `@icp-sdk/core` diff --git a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap index f23347570..839c5e458 100644 --- a/e2e/node/basic/__snapshots__/syncTime.test.ts.snap +++ b/e2e/node/basic/__snapshots__/syncTime.test.ts.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`syncTime > on async creation > should sync time on when enabled > V4 read state body one 1`] = ` +exports[`syncTime > on async creation > should sync time when enabled > V3 read state body one 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -23,7 +23,7 @@ exports[`syncTime > on async creation > should sync time on when enabled > V4 re } `; -exports[`syncTime > on async creation > should sync time on when enabled > V4 read state body three 1`] = ` +exports[`syncTime > on async creation > should sync time when enabled > V3 read state body three 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -46,7 +46,7 @@ exports[`syncTime > on async creation > should sync time on when enabled > V4 re } `; -exports[`syncTime > on async creation > should sync time on when enabled > V4 read state body two 1`] = ` +exports[`syncTime > on async creation > should sync time when enabled > V3 read state body two 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -116,7 +116,7 @@ exports[`syncTime > on error > should not sync time by default > V4 call body 1` } `; -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V2 read state body one 1`] = ` +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body one 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -139,54 +139,7 @@ exports[`syncTime > on error > should sync time when the local time does not mat } `; -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V4 call body 1`] = ` -{ - "content": { - "arg": { - "data": [ - 68, - 73, - 68, - 76, - 0, - 1, - 113, - 5, - 119, - 111, - 114, - 108, - 100, - ], - "type": "Buffer", - }, - "canister_id": { - "data": [ - 255, - 255, - 255, - 255, - 255, - 144, - 0, - 1, - 1, - 1, - ], - "type": "Buffer", - }, - "ingress_expiry": 1746103140000000000n, - "method_name": "greet", - "nonce": Any, - "request_type": "call", - "sender": Any, - }, - "sender_pubkey": Any, - "sender_sig": Any, -} -`; - -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V4 read state body three 1`] = ` +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body three 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -209,7 +162,7 @@ exports[`syncTime > on error > should sync time when the local time does not mat } `; -exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V4 read state body two 1`] = ` +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V3 read state body two 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -232,7 +185,7 @@ exports[`syncTime > on error > should sync time when the local time does not mat } `; -exports[`syncTime > on first call > should not sync time by default > V4 call body 1`] = ` +exports[`syncTime > on error > should sync time when the local time does not match the subnet time > V4 call body 1`] = ` { "content": { "arg": { @@ -279,7 +232,7 @@ exports[`syncTime > on first call > should not sync time by default > V4 call bo } `; -exports[`syncTime > on first call > should not sync time when explicitly disabled > V4 call body 1`] = ` +exports[`syncTime > on first call > should not sync time by default > V4 call body 1`] = ` { "content": { "arg": { @@ -326,7 +279,7 @@ exports[`syncTime > on first call > should not sync time when explicitly disable } `; -exports[`syncTime > on first call > should sync time when enabled > V4 call body 1`] = ` +exports[`syncTime > on first call > should not sync time when explicitly disabled > V4 call body 1`] = ` { "content": { "arg": { @@ -373,7 +326,7 @@ exports[`syncTime > on first call > should sync time when enabled > V4 call body } `; -exports[`syncTime > on first call > should sync time when enabled > V4 read state body one 1`] = ` +exports[`syncTime > on first call > should sync time when enabled > V3 read state body one 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -396,7 +349,7 @@ exports[`syncTime > on first call > should sync time when enabled > V4 read stat } `; -exports[`syncTime > on first call > should sync time when enabled > V4 read state body three 1`] = ` +exports[`syncTime > on first call > should sync time when enabled > V3 read state body three 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -419,7 +372,7 @@ exports[`syncTime > on first call > should sync time when enabled > V4 read stat } `; -exports[`syncTime > on first call > should sync time when enabled > V4 read state body two 1`] = ` +exports[`syncTime > on first call > should sync time when enabled > V3 read state body two 1`] = ` { "content": { "ingress_expiry": 1746103140000000000n, @@ -441,3 +394,50 @@ exports[`syncTime > on first call > should sync time when enabled > V4 read stat }, } `; + +exports[`syncTime > on first call > should sync time when enabled > V4 call body 1`] = ` +{ + "content": { + "arg": { + "data": [ + 68, + 73, + 68, + 76, + 0, + 1, + 113, + 5, + 119, + 111, + 114, + 108, + 100, + ], + "type": "Buffer", + }, + "canister_id": { + "data": [ + 255, + 255, + 255, + 255, + 255, + 144, + 0, + 1, + 1, + 1, + ], + "type": "Buffer", + }, + "ingress_expiry": 1746103140000000000n, + "method_name": "greet", + "nonce": Any, + "request_type": "call", + "sender": Any, + }, + "sender_pubkey": Any, + "sender_sig": Any, +} +`; diff --git a/e2e/node/basic/canisterStatus.test.ts b/e2e/node/basic/canisterStatus.test.ts index 5ff3b332e..fd3f5d0f7 100644 --- a/e2e/node/basic/canisterStatus.test.ts +++ b/e2e/node/basic/canisterStatus.test.ts @@ -12,7 +12,7 @@ import { getCanisterId } from '../utils/canisterid.ts'; import { MockReplica, mockSyncTimeResponse, - prepareV2ReadStateSubnetResponse, + prepareV3ReadStateSubnetResponse, } from '../utils/mock-replica.ts'; import { randomIdentity, randomKeyPair } from '../utils/identity.ts'; @@ -92,14 +92,14 @@ describe('canister status', () => { identity, }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); // first try, fails - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); // syncs time @@ -124,7 +124,7 @@ describe('canister status', () => { expect(err.cause.code).toBeInstanceOf(CertificateTimeErrorCode); expect(err.message).toContain('Certificate is signed more than 5 minutes in the past'); } - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); }); it('should sync time and succeed if the certificate is not fresh', async () => { @@ -137,14 +137,14 @@ describe('canister status', () => { identity, }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); // first try, fails - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); // sync time, we return the replica date to make the agent sync time properly @@ -162,7 +162,7 @@ describe('canister status', () => { paths: ['subnet'], }), ).resolves.not.toThrow(); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); }); it('should not sync time and succeed if the certificate is not fresh and disableTimeVerification is true', async () => { @@ -175,13 +175,13 @@ describe('canister status', () => { identity, }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: replicaDate, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(subnetResponseBody); }); @@ -193,7 +193,7 @@ describe('canister status', () => { disableCertificateTimeVerification: true, }), ).resolves.not.toThrow(); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); }); }); diff --git a/e2e/node/basic/mainnet.test.ts b/e2e/node/basic/mainnet.test.ts index 4e69dd801..4d3cf1e1a 100644 --- a/e2e/node/basic/mainnet.test.ts +++ b/e2e/node/basic/mainnet.test.ts @@ -6,8 +6,8 @@ import { CanisterStatus, polling, requestIdOf, - TrustError, - MissingSignatureErrorCode, + ProtocolError, + DerKeyLengthMismatchErrorCode, } from '@icp-sdk/core/agent'; import { IDL } from '@icp-sdk/core/candid'; import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; @@ -206,9 +206,9 @@ test('it should allow you to set an incorrect root key', async () => { try { await actor.whoami(); } catch (error) { - expect(error).toBeInstanceOf(TrustError); - const errorCode = (error as TrustError).cause.code; - expect(errorCode).toBeInstanceOf(MissingSignatureErrorCode); + expect(error).toBeInstanceOf(ProtocolError); + const errorCode = (error as ProtocolError).cause.code; + expect(errorCode).toBeInstanceOf(DerKeyLengthMismatchErrorCode); expect(errorCode.requestContext).toBeDefined(); } }); diff --git a/e2e/node/basic/queryExpiry.test.ts b/e2e/node/basic/queryExpiry.test.ts index 0255dcf2c..f050193fe 100644 --- a/e2e/node/basic/queryExpiry.test.ts +++ b/e2e/node/basic/queryExpiry.test.ts @@ -1,16 +1,18 @@ import { beforeEach, describe, it, vi, expect } from 'vitest'; import { + mockReadStateNodeKeysResponse, MockReplica, + mockSyncSubnetTimeResponse, mockSyncTimeResponse, - prepareV2QueryResponse, - prepareV2ReadStateSubnetResponse, + prepareV3QueryResponse, + prepareV3ReadStateResponse, + prepareV3ReadStateSubnetResponse, } from '../utils/mock-replica.ts'; import { IDL } from '@icp-sdk/core/candid'; import { Principal } from '@icp-sdk/core/principal'; import { randomIdentity, randomKeyPair } from '../utils/identity.ts'; import { CertificateOutdatedErrorCode, - CertificateTimeErrorCode, HttpAgent, requestIdOf, TrustError, @@ -32,6 +34,7 @@ describe('queryExpiry', () => { const greetReply = IDL.encode([IDL.Text], [greetRes]); const subnetKeyPair = randomKeyPair(); + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); const nodeIdentity = randomIdentity(); const identity = randomIdentity(); @@ -52,7 +55,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody, requestId } = await prepareV2QueryResponse({ + const { responseBody, requestId } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -61,26 +64,40 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + res.status(200).send(readStateResponseBody); + }); + + // Get node key from subnet + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + keyPair: subnetKeyPair, + date: now, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); - const req = mockReplica.getV2QueryReq(canisterId.toString(), 0); + const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); }); @@ -100,7 +117,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -109,20 +126,20 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + + // Get node key from subnet + await mockReadStateNodeKeysResponse({ + mockReplica, nodeIdentity, - canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], - keyPair: subnetKeyPair, - date: futureDate, // make sure the certificate is fresh for this call - }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + canisterId, + subnetKeyPair, + date: futureDate, // make sure the certificate is fresh for these calls }); - expect.assertions(4); + expect.assertions(6); try { await actor[greetMethodName](greetReq); @@ -130,7 +147,10 @@ describe('queryExpiry', () => { expectCertificateOutdatedError(e); } - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + // Early promise failure stops these requests, even though the agent makes them + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(0); }); it('should retry and fail if the timestamp is outside the max ingress expiry (with retry)', async () => { @@ -146,7 +166,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -155,44 +175,56 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + res.status(200).send(responseBody); + }); + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + res.status(200).send(responseBody); + }); + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + res.status(200).send(responseBody); + }); + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); // advance to go over the max ingress expiry (5 minutes) advanceTimeByMilliseconds(timeDiffMsecs); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + // Get subnet id from canister + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ nodeIdentity, canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: now, }); - // fetch subnet keys, fails for certificate freshness checks - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + res.status(200).send(readStateResponseBody); }); - // sync time, keeping a date in the future to make sure the agent still has outdated time - await mockSyncTimeResponse({ - mockReplica, + + // Get node key from subnet, fails for certificate freshness checks + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], keyPair: subnetKeyPair, date: futureDate, - canisterId, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); }); - expect.assertions(5); + expect.assertions(6); try { await actor[greetMethodName](greetReq); } catch (e) { - expect(e).toBeInstanceOf(TrustError); - const err = e as TrustError; - expect(err.cause.code).toBeInstanceOf(CertificateTimeErrorCode); - expect(err.message).toContain('Certificate is signed more than 5 minutes in the past.'); + expectCertificateOutdatedError(e); } - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); }); it('should not retry if the timestamp is outside the max ingress expiry (verifyQuerySignatures=false)', async () => { @@ -206,7 +238,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -215,22 +247,17 @@ describe('queryExpiry', () => { nodeIdentity, date: now, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); // advance to go over the max ingress expiry (5 minutes) advanceTimeByMilliseconds(6 * MINUTE_TO_MSECS); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(500).send('Should not be called'); - }); - const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); it.each([ @@ -257,7 +284,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody, requestId } = await prepareV2QueryResponse({ + const { responseBody, requestId } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -267,27 +294,27 @@ describe('queryExpiry', () => { timeDiffMsecs, date: replicaDate, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + // Get subnet id from canister + await mockReadStateNodeKeysResponse({ + mockReplica, nodeIdentity, - canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], - keyPair: subnetKeyPair, + canisterId, + subnetKeyPair, date: replicaDate, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); - }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(1); - const req = mockReplica.getV2QueryReq(canisterId.toString(), 0); + const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); }, ); @@ -306,7 +333,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody } = await prepareV2QueryResponse({ + const { responseBody } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -316,7 +343,7 @@ describe('queryExpiry', () => { timeDiffMsecs: 0, // sync time is disabled date: replicaDate, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); @@ -328,7 +355,7 @@ describe('queryExpiry', () => { expectCertificateOutdatedError(e); } - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); }); it('should succeed if clock is drifted by more than 5 minutes in the future without syncing it', async () => { @@ -345,7 +372,7 @@ describe('queryExpiry', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody, requestId } = await prepareV2QueryResponse({ + const { responseBody, requestId } = await prepareV3QueryResponse({ canisterId, methodName: greetMethodName, arg: greetArgs, @@ -355,33 +382,34 @@ describe('queryExpiry', () => { timeDiffMsecs: 0, // sync time is disabled date: replicaDate, }); - mockReplica.setV2QuerySpyImplOnce(canisterId.toString(), (_req, res) => { + mockReplica.setV3QuerySpyImplOnce(canisterId.toString(), (_req, res) => { res.status(200).send(responseBody); }); - const { responseBody: subnetResponseBody } = await prepareV2ReadStateSubnetResponse({ + // Get node key from subnet + await mockReadStateNodeKeysResponse({ + mockReplica, nodeIdentity, - canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], - keyPair: subnetKeyPair, + canisterId, + subnetKeyPair, date: replicaDate, }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(subnetResponseBody); - }); - await mockSyncTimeResponse({ + + // Certificate time is in the future, so the agent will sync time with the subnet + await mockSyncSubnetTimeResponse({ mockReplica, keyPair: subnetKeyPair, date: replicaDate, - canisterId, }); const actorResponse = await actor[greetMethodName](greetReq); expect(actorResponse).toEqual(greetRes); - expect(mockReplica.getV2QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(4); + expect(mockReplica.getV3QuerySpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(1); + expect(mockReplica.getV3ReadSubnetStateSpy(subnetId.toString())).toHaveBeenCalledTimes(4); - const req = mockReplica.getV2QueryReq(canisterId.toString(), 0); + const req = mockReplica.getV3QueryReq(canisterId.toString(), 0); expect(requestIdOf(req.content)).toEqual(requestId); }); }); diff --git a/e2e/node/basic/syncTime.test.ts b/e2e/node/basic/syncTime.test.ts index 6eaaf3153..2c8b817a8 100644 --- a/e2e/node/basic/syncTime.test.ts +++ b/e2e/node/basic/syncTime.test.ts @@ -16,12 +16,7 @@ import { Principal } from '@icp-sdk/core/principal'; import { IDL } from '@icp-sdk/core/candid'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createActor } from '../canisters/counter.ts'; -import { - MockReplica, - mockSyncTimeResponse, - prepareV2ReadStateTimeResponse, - prepareV4Response, -} from '../utils/mock-replica.ts'; +import { MockReplica, mockSyncTimeResponse, prepareV4Response } from '../utils/mock-replica.ts'; import { randomIdentity, randomKeyPair } from '../utils/identity.ts'; import { concatBytes } from '@noble/hashes/utils'; @@ -154,27 +149,27 @@ describe('syncTime', () => { const reqTwo = mockReplica.getV4CallReq(canisterId.toString(), 1); expect(reqTwo).toEqual(req); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 0), + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 0), { sender: anonIdentity.getPrincipal(), }, - 'V2 read state body one', + 'V3 read state body one', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 1), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 1), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body two', + 'V3 read state body two', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 2), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 2), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body three', + 'V3 read state body three', ); expect(agent.hasSyncedTime()).toBe(true); }); @@ -215,24 +210,17 @@ describe('syncTime', () => { } expect(mockReplica.getV4CallSpy(canisterId.toString())).toHaveBeenCalledTimes(2); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); expect(agent.hasSyncedTime()).toBe(true); }); }); describe('on async creation', () => { - it('should sync time on when enabled', async () => { - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + it('should sync time when enabled', async () => { + await mockSyncTimeResponse({ + mockReplica, keyPair, - }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { - res.status(200).send(readStateResponse); - }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { - res.status(200).send(readStateResponse); - }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { - res.status(200).send(readStateResponse); + canisterId: ICP_LEDGER, }); const agent = await HttpAgent.create({ @@ -241,56 +229,53 @@ describe('syncTime', () => { shouldSyncTime: true, }); - expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(3); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(ICP_LEDGER, 0), + expect(mockReplica.getV3ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(3); + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(ICP_LEDGER, 0), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body one', + 'V3 read state body one', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(ICP_LEDGER, 1), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(ICP_LEDGER, 1), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body two', + 'V3 read state body two', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(ICP_LEDGER, 2), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(ICP_LEDGER, 2), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body three', + 'V3 read state body three', ); expect(agent.hasSyncedTime()).toBe(true); }); it('should not sync time by default', async () => { - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + await mockSyncTimeResponse({ + mockReplica, keyPair, - }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { - res.status(200).send(readStateResponse); + canisterId: ICP_LEDGER, }); const agent = await HttpAgent.create({ host: mockReplica.address, rootKey: keyPair.publicKeyDer, - shouldSyncTime: false, identity: anonIdentity, }); - expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); it('should not sync time when explicitly disabled', async () => { - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + await mockSyncTimeResponse({ + mockReplica, keyPair, - }); - mockReplica.setV2ReadStateSpyImplOnce(ICP_LEDGER, (_req, res) => { - res.status(200).send(readStateResponse); + canisterId: ICP_LEDGER, }); const agent = await HttpAgent.create({ @@ -300,7 +285,7 @@ describe('syncTime', () => { identity: anonIdentity, }); - expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); }); @@ -315,17 +300,10 @@ describe('syncTime', () => { }); const actor = await createActor(canisterId, { agent }); - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + await mockSyncTimeResponse({ + mockReplica, keyPair, - }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(readStateResponse); - }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(readStateResponse); - }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(readStateResponse); + canisterId, }); const { responseBody, requestId } = await prepareV4Response({ @@ -359,27 +337,27 @@ describe('syncTime', () => { 'V4 call body', ); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 0), + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(3); + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 0), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body one', + 'V3 read state body one', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 1), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 1), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body two', + 'V3 read state body two', ); - expectV2ReadStateRequest( - mockReplica.getV2ReadStateReq(canisterId.toString(), 2), + expectV3ReadStateRequest( + mockReplica.getV3ReadStateReq(canisterId.toString(), 2), { sender: anonIdentity.getPrincipal(), }, - 'V4 read state body three', + 'V3 read state body three', ); expect(agent.hasSyncedTime()).toBe(true); }); @@ -393,11 +371,10 @@ describe('syncTime', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + await mockSyncTimeResponse({ + mockReplica, keyPair, - }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(readStateResponse); + canisterId, }); const { responseBody, requestId } = await prepareV4Response({ @@ -430,7 +407,7 @@ describe('syncTime', () => { 'V4 call body', ); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); @@ -444,11 +421,10 @@ describe('syncTime', () => { const actor = await createActor(canisterId, { agent }); const sender = identity.getPrincipal(); - const { responseBody: readStateResponse } = await prepareV2ReadStateTimeResponse({ + await mockSyncTimeResponse({ + mockReplica, keyPair, - }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { - res.status(200).send(readStateResponse); + canisterId, }); const { responseBody, requestId } = await prepareV4Response({ @@ -482,7 +458,7 @@ describe('syncTime', () => { 'V4 call body', ); - expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); + expect(mockReplica.getV3ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0); expect(agent.hasSyncedTime()).toBe(false); }); }); @@ -518,13 +494,13 @@ function expectV4CallRequest( ); } -interface ExpectedV2ReadStateRequest { +interface ExpectedV3ReadStateRequest { sender: Principal; } -function expectV2ReadStateRequest( +function expectV3ReadStateRequest( actual: UnSigned, - expected: ExpectedV2ReadStateRequest, + expected: ExpectedV3ReadStateRequest, snapshotName?: string, ) { expect(actual.content.sender).toEqual(expected.sender.toUint8Array()); diff --git a/e2e/node/utils/mock-replica.ts b/e2e/node/utils/mock-replica.ts index dd037bf8c..447f1c473 100644 --- a/e2e/node/utils/mock-replica.ts +++ b/e2e/node/utils/mock-replica.ts @@ -26,19 +26,22 @@ import { import { Principal } from '@icp-sdk/core/principal'; import { Ed25519KeyIdentity } from '@icp-sdk/core/identity'; import { Mock, vi } from 'vitest'; -import { createReplyTree, createSubnetTree, createTimeTree } from './tree.ts'; -import { randomKeyPair, signBls, KeyPair } from './identity.ts'; +import { createReplyTree, createSubnetTree } from './tree.ts'; +import { randomKeyPair, signBls, KeyPair, randomIdentity } from './identity.ts'; import { concatBytes, toBytes } from '@noble/hashes/utils'; const NANOSECONDS_TO_MSECS = 1_000_000; export enum MockReplicaSpyType { CallV4 = 'CallV4', - ReadStateV2 = 'ReadStateV2', - QueryV2 = 'QueryV2', + ReadStateV3 = 'ReadStateV3', + ReadSubnetStateV3 = 'ReadSubnetStateV3', + QueryV3 = 'QueryV3', } -export type MockReplicaRequest = Request<{ canisterId: string }, Uint8Array, Uint8Array>; +type MockReplicaRequestParams = { canisterId: string } | { subnetId: string }; + +export type MockReplicaRequest = Request; export type MockReplicaResponse = Response; export type MockReplicaSpyImpl = (req: MockReplicaRequest, res: MockReplicaResponse) => void; @@ -46,16 +49,17 @@ export type MockReplicaSpy = Mock; export interface MockReplicaSpies { [MockReplicaSpyType.CallV4]?: MockReplicaSpy; - [MockReplicaSpyType.ReadStateV2]?: MockReplicaSpy; - [MockReplicaSpyType.QueryV2]?: MockReplicaSpy; + [MockReplicaSpyType.ReadStateV3]?: MockReplicaSpy; + [MockReplicaSpyType.ReadSubnetStateV3]?: MockReplicaSpy; + [MockReplicaSpyType.QueryV3]?: MockReplicaSpy; } -function fallbackSpyImpl(spyType: MockReplicaSpyType, canisterId: string): MockReplicaSpyImpl { +function fallbackSpyImpl(spyType: MockReplicaSpyType, principal: string): MockReplicaSpyImpl { return (req, res) => { res .status(500) .send( - `No implementation defined for ${spyType} spy on canisterId: ${canisterId}. Requested path: ${req.path}`, + `No implementation defined for ${spyType} spy on principal: ${principal}. Requested path: ${req.path}`, ); }; } @@ -73,12 +77,16 @@ export class MockReplica { this.#createEndpointSpy(MockReplicaSpyType.CallV4), ); app.post( - '/api/v2/canister/:canisterId/read_state', - this.#createEndpointSpy(MockReplicaSpyType.ReadStateV2), + '/api/v3/canister/:canisterId/read_state', + this.#createEndpointSpy(MockReplicaSpyType.ReadStateV3), + ); + app.post( + '/api/v3/subnet/:subnetId/read_state', + this.#createEndpointSpy(MockReplicaSpyType.ReadSubnetStateV3), ); app.post( - '/api/v2/canister/:canisterId/query', - this.#createEndpointSpy(MockReplicaSpyType.QueryV2), + '/api/v3/canister/:canisterId/query', + this.#createEndpointSpy(MockReplicaSpyType.QueryV3), ); } @@ -105,24 +113,32 @@ export class MockReplica { this.#setSpyImplOnce(canisterId, MockReplicaSpyType.CallV4, impl); } - public setV2ReadStateSpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { - this.#setSpyImplOnce(canisterId, MockReplicaSpyType.ReadStateV2, impl); + public setV3ReadStateSpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { + this.#setSpyImplOnce(canisterId, MockReplicaSpyType.ReadStateV3, impl); + } + + public setV3ReadSubnetStateSpyImplOnce(subnetId: string, impl: MockReplicaSpyImpl): void { + this.#setSpyImplOnce(subnetId, MockReplicaSpyType.ReadSubnetStateV3, impl); } - public setV2QuerySpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { - this.#setSpyImplOnce(canisterId, MockReplicaSpyType.QueryV2, impl); + public setV3QuerySpyImplOnce(canisterId: string, impl: MockReplicaSpyImpl): void { + this.#setSpyImplOnce(canisterId, MockReplicaSpyType.QueryV3, impl); } public getV4CallSpy(canisterId: string): MockReplicaSpy { return this.#getSpy(canisterId, MockReplicaSpyType.CallV4); } - public getV2ReadStateSpy(canisterId: string): MockReplicaSpy { - return this.#getSpy(canisterId, MockReplicaSpyType.ReadStateV2); + public getV3ReadStateSpy(canisterId: string): MockReplicaSpy { + return this.#getSpy(canisterId, MockReplicaSpyType.ReadStateV3); + } + + public getV3ReadSubnetStateSpy(subnetId: string): MockReplicaSpy { + return this.#getSpy(subnetId, MockReplicaSpyType.ReadSubnetStateV3); } - public getV2QuerySpy(canisterId: string): MockReplicaSpy { - return this.#getSpy(canisterId, MockReplicaSpyType.QueryV2); + public getV3QuerySpy(canisterId: string): MockReplicaSpy { + return this.#getSpy(canisterId, MockReplicaSpyType.QueryV3); } public getV4CallReq(canisterId: string, callNumber: number): Signed { @@ -131,88 +147,100 @@ export class MockReplica { return Cbor.decode>(req.body); } - public getV2ReadStateReq(canisterId: string, callNumber: number): UnSigned { - const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.ReadStateV2); + public getV3ReadStateReq(canisterId: string, callNumber: number): UnSigned { + const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.ReadStateV3); return Cbor.decode>(req.body); } - public getV2QueryReq(canisterId: string, callNumber: number): UnSigned { - const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.QueryV2); + public getV3ReadSubnetStateReq(subnetId: string, callNumber: number): UnSigned { + const [req] = this.#getCallParams(subnetId, callNumber, MockReplicaSpyType.ReadSubnetStateV3); + + return Cbor.decode>(req.body); + } + + public getV3QueryReq(canisterId: string, callNumber: number): UnSigned { + const [req] = this.#getCallParams(canisterId, callNumber, MockReplicaSpyType.QueryV3); return Cbor.decode>(req.body); } #createEndpointSpy(spyType: MockReplicaSpyType): MockReplicaSpyImpl { return (req, res) => { - const { canisterId } = req.params; + let principal: string; + if ('canisterId' in req.params) { + principal = req.params.canisterId; + } else if ('subnetId' in req.params) { + principal = req.params.subnetId; + } else { + res.status(500).send('No canisterId or subnetId found in request.'); + return; + } - const canisterSpies = this.#listeners.get(canisterId); - if (!canisterSpies) { - res.status(500).send(`No listeners defined for canisterId: ${canisterId}.`); + const principalSpies = this.#listeners.get(principal); + if (!principalSpies) { + res.status(500).send(`No listeners defined for principal: ${principal}.`); return; } - const spy = canisterSpies[spyType]; + const spy = principalSpies[spyType]; if (!spy) { - res.status(500).send(`No ${spyType} spy defined for canisterId: ${canisterId}.`); + res.status(500).send(`No ${spyType} spy defined for principal: ${principal}.`); return; } // add fallback implementation to return 500 if the spy runs out of implementations - spy.mockImplementation(fallbackSpyImpl(spyType, canisterId)); + spy.mockImplementation(fallbackSpyImpl(spyType, principal)); spy(req, res); }; } - #setSpyImplOnce(canisterId: string, spyType: MockReplicaSpyType, impl: MockReplicaSpyImpl): void { - const map: MockReplicaSpies = this.#listeners.get(canisterId.toString()) ?? {}; + #setSpyImplOnce(principal: string, spyType: MockReplicaSpyType, impl: MockReplicaSpyImpl): void { + const map: MockReplicaSpies = this.#listeners.get(principal.toString()) ?? {}; const spy = map[spyType] ?? vi.fn(); spy.mockImplementationOnce(impl); map[spyType] = spy; - this.#listeners.set(canisterId.toString(), map); + this.#listeners.set(principal.toString(), map); } - #getSpy(canisterId: string, spyType: MockReplicaSpyType): MockReplicaSpy { - const canisterSpies = this.#listeners.get(canisterId); - if (!canisterSpies) { - throw new Error(`No listeners defined for canisterId: ${canisterId}.`); + #getSpy(principal: string, spyType: MockReplicaSpyType): MockReplicaSpy { + const principalSpies = this.#listeners.get(principal); + if (!principalSpies) { + throw new Error(`No listeners defined for principal: ${principal}.`); } - const spy = canisterSpies[spyType]; + const spy = principalSpies[spyType]; if (!spy) { - throw new Error(`No ${spyType} spy defined for canisterId: ${canisterId}.`); + throw new Error(`No ${spyType} spy defined for principal: ${principal}.`); } return spy; } #getCallParams( - canisterId: string, + principal: string, callNumber: number, spyType: MockReplicaSpyType, ): [MockReplicaRequest, MockReplicaResponse] { - const spy = this.#getSpy(canisterId, spyType); + const spy = this.#getSpy(principal, spyType); if (!spy.mock.calls.length) { - throw new Error(`No calls found for canisterId: ${canisterId}.`); + throw new Error(`No calls found for principal: ${principal}.`); } const callParams = spy.mock.calls[callNumber]; if (!callParams) { throw new Error( - `No call params found for canisterId: ${canisterId}, callNumber: ${callNumber}. Actual number of calls is ${spy.mock.calls.length}.`, + `No call params found for principal: ${principal}, callNumber: ${callNumber}. Actual number of calls is ${spy.mock.calls.length}.`, ); } if (!callParams[0]) { - throw new Error(`No request found for canisterId: ${canisterId}, callNumber: ${callNumber}.`); + throw new Error(`No request found for principal: ${principal}, callNumber: ${callNumber}.`); } if (!callParams[1]) { - throw new Error( - `No response found for canisterId: ${canisterId}, callNumber: ${callNumber}.`, - ); + throw new Error(`No response found for principal: ${principal}, callNumber: ${callNumber}.`); } return callParams; @@ -305,35 +333,52 @@ export async function prepareV4Response({ }; } -export interface V2ReadStateTimeOptions { - keyPair?: KeyPair; - date?: Date; +export interface V3ReadStateResponse { + responseBody: Uint8Array; } -export interface V2ReadStateResponse { - responseBody: Uint8Array; +interface V3ReadStateOptions { + nodeIdentity: Ed25519KeyIdentity; + canisterRanges: Array<[Uint8Array, Uint8Array]>; + keyPair?: KeyPair; + date?: Date; } /** - * Prepares a version 2 read state time response. - * @param {V2ReadStateTimeOptions} options - The options for preparing the response. - * @param {Date} options.date - The date for the response. + * Prepares a version 3 read state subnet response. + * @param {V3ReadStateOptions} options - The options for preparing the response. + * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. + * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {KeyPair} options.keyPair - The key pair for signing. - * @returns {Promise} A promise that resolves to the prepared response. + * @param {Date} options.date - The date for the response. + * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV2ReadStateTimeResponse({ - date, +export async function prepareV3ReadStateResponse({ + nodeIdentity, + canisterRanges, keyPair, -}: V2ReadStateTimeOptions): Promise { + date, +}: V3ReadStateOptions): Promise { keyPair = keyPair ?? randomKeyPair(); date = date ?? new Date(); - const tree = createTimeTree(date); + const subnetId = Principal.selfAuthenticating(keyPair.publicKeyDer).toUint8Array(); + + const tree = createSubnetTree({ + subnetId, + subnetPublicKey: keyPair.publicKeyDer, + nodeIdentity, + canisterRanges, + date, + }); const signature = await signTree(tree, keyPair); + // We pass the same key pair for signature, even though in reality the delegation would be signed by the root subnet + const delegation = await createDelegationCertificate(subnetId, keyPair, canisterRanges, date); const cert: Cert = { tree, signature, + delegation, }; const responseBody: ReadStateResponse = { certificate: Cbor.encode(cert), @@ -344,35 +389,28 @@ export async function prepareV2ReadStateTimeResponse({ }; } -interface V2ReadStateSubnetOptions { - nodeIdentity: Ed25519KeyIdentity; - canisterRanges: Array<[Uint8Array, Uint8Array]>; - keyPair?: KeyPair; - date?: Date; -} - /** - * Prepares a version 2 read state subnet response. - * @param {V2ReadStateSubnetOptions} options - The options for preparing the response. + * Prepares a version 3 read state subnet response. + * @param {V3ReadStateOptions} options - The options for preparing the response. * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {KeyPair} options.keyPair - The key pair for signing. * @param {Date} options.date - The date for the response. - * @returns {Promise} A promise that resolves to the prepared response. + * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV2ReadStateSubnetResponse({ +export async function prepareV3ReadStateSubnetResponse({ nodeIdentity, canisterRanges, keyPair, date, -}: V2ReadStateSubnetOptions): Promise { +}: V3ReadStateOptions): Promise { keyPair = keyPair ?? randomKeyPair(); date = date ?? new Date(); - const subnetId = Principal.selfAuthenticating(keyPair.publicKeyDer).toUint8Array(); const tree = createSubnetTree({ subnetId, + subnetPublicKey: keyPair.publicKeyDer, nodeIdentity, canisterRanges, date, @@ -392,7 +430,7 @@ export async function prepareV2ReadStateSubnetResponse({ }; } -interface V2QueryResponseOptions { +interface V3QueryResponseOptions { canisterId: Principal | string; methodName: string; arg: Uint8Array; @@ -404,14 +442,14 @@ interface V2QueryResponseOptions { date?: Date; } -interface V2QueryResponse { +interface V3QueryResponse { responseBody: Uint8Array; requestId: RequestId; } /** * Prepares a version 2 query response. - * @param {V2QueryResponseOptions} options - The options for preparing the response. + * @param {V3QueryResponseOptions} options - The options for preparing the response. * @param {string} options.canisterId - The ID of the canister. * @param {string} options.methodName - The name of the method being called. * @param {Uint8Array} options.arg - The arguments for the method call. @@ -421,9 +459,9 @@ interface V2QueryResponse { * @param {number} options.timeDiffMsecs - The time difference in milliseconds. * @param {Uint8Array} options.reply - The reply payload. * @param {Date} options.date - The date for the response. - * @returns {Promise} A promise that resolves to the prepared response. + * @returns {Promise} A promise that resolves to the prepared response. */ -export async function prepareV2QueryResponse({ +export async function prepareV3QueryResponse({ canisterId, methodName, arg, @@ -433,7 +471,7 @@ export async function prepareV2QueryResponse({ timeDiffMsecs, reply, date, -}: V2QueryResponseOptions): Promise { +}: V3QueryResponseOptions): Promise { canisterId = Principal.from(canisterId); sender = Principal.from(sender); ingressExpiryInMinutes = ingressExpiryInMinutes ?? 5; @@ -529,18 +567,138 @@ export async function mockSyncTimeResponse({ date, canisterId, }: MockSyncTimeResponseOptions) { - canisterId = Principal.from(canisterId).toText(); - const { responseBody: timeResponseBody } = await prepareV2ReadStateTimeResponse({ + canisterId = Principal.from(canisterId); + const { responseBody: timeResponseBody } = await prepareV3ReadStateResponse({ keyPair, date, + canisterRanges: [[canisterId.toUint8Array(), canisterId.toUint8Array()]], + nodeIdentity: randomIdentity(), }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => { + const canisterIdString = canisterId.toString(); + mockReplica.setV3ReadStateSpyImplOnce(canisterIdString, (_req, res) => { res.status(200).send(timeResponseBody); }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterIdString, (_req, res) => { res.status(200).send(timeResponseBody); }); - mockReplica.setV2ReadStateSpyImplOnce(canisterId, (_req, res) => { + mockReplica.setV3ReadStateSpyImplOnce(canisterIdString, (_req, res) => { res.status(200).send(timeResponseBody); }); } + +type MockSyncSubnetTimeResponseOptions = { + mockReplica: MockReplica; + keyPair: KeyPair; + date?: Date; +}; + +/** + * A shortcut to prepare the mock replica to respond to the sync subnet time request. + * It mocks the read subnet state endpoint 3 times. + * @param {MockSyncTimeResponseOptions} options - The options for preparing the response. + * @param {MockReplica} options.mockReplica - The mock replica to prepare. + * @param {KeyPair} options.keyPair - The key pair for signing. + * @param {Date} options.date - The date to use for the returned certificate `time` field. Optional. + */ +export async function mockSyncSubnetTimeResponse({ + mockReplica, + keyPair, + date, +}: MockSyncSubnetTimeResponseOptions) { + const subnetId = Principal.selfAuthenticating(keyPair.publicKeyDer); + const { responseBody: subnetResponseBody } = await prepareV3ReadStateSubnetResponse({ + keyPair, + date, + canisterRanges: [], // not needed for subnet time + nodeIdentity: randomIdentity(), + }); + + const subnetIdString = subnetId.toString(); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetIdString, (_req, res) => { + res.status(200).send(subnetResponseBody); + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetIdString, (_req, res) => { + res.status(200).send(subnetResponseBody); + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetIdString, (_req, res) => { + res.status(200).send(subnetResponseBody); + }); +} + +async function createDelegationCertificate( + subnetId: Uint8Array, + keyPair: KeyPair, + canisterRanges: Array<[Uint8Array, Uint8Array]>, + date?: Date, +): Promise> { + date = date ?? new Date(); + const tree = createSubnetTree({ + subnetId, + subnetPublicKey: keyPair.publicKeyDer, + nodeIdentity: randomIdentity(), + canisterRanges, + date, + }); + const signature = await signTree(tree, keyPair); + + const cert: Cert = { + tree, + signature, + }; + + return { + subnet_id: subnetId, + certificate: Cbor.encode(cert), + }; +} + +type MockReadStateNodeKeysResponseOptions = { + mockReplica: MockReplica; + nodeIdentity: Ed25519KeyIdentity; + canisterId: Principal | string; + subnetKeyPair: KeyPair; + date?: Date; +}; + +/** + * A shortcut to prepare the mock replica to respond to the read state node keys request. + * Prepares one read state and one read subnet state response. + * @param {MockReadStateNodeKeysResponseOptions} options - The options for preparing the response. + * @param {MockReplica} options.mockReplica - The mock replica to prepare. + * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. + * @param {string} options.canisterId - The ID of the canister. + * @param {KeyPair} options.subnetKeyPair - The key pair for signing the subnet. + * @param {Date} options.date - The date to use for the returned certificate `time` field. Optional. + */ +export async function mockReadStateNodeKeysResponse({ + mockReplica, + nodeIdentity, + canisterId, + subnetKeyPair, + date, +}: MockReadStateNodeKeysResponseOptions) { + canisterId = Principal.from(canisterId); + const canisterIdBytes = canisterId.toUint8Array(); + + const { responseBody: readStateResponseBody } = await prepareV3ReadStateResponse({ + nodeIdentity, + canisterRanges: [[canisterIdBytes, canisterIdBytes]], + keyPair: subnetKeyPair, + date, + }); + mockReplica.setV3ReadStateSpyImplOnce(canisterId.toString(), (_req, res) => { + res.status(200).send(readStateResponseBody); + }); + + // Get node key from subnet + const subnetId = Principal.selfAuthenticating(subnetKeyPair.publicKeyDer); + const { responseBody: readSubnetStateResponseBody } = await prepareV3ReadStateSubnetResponse({ + nodeIdentity, + canisterRanges: [[canisterIdBytes, canisterIdBytes]], + keyPair: subnetKeyPair, + date, + }); + mockReplica.setV3ReadSubnetStateSpyImplOnce(subnetId.toString(), (_req, res) => { + res.status(200).send(readSubnetStateResponseBody); + }); +} diff --git a/e2e/node/utils/tree.ts b/e2e/node/utils/tree.ts index 8a1ae1a55..f2772af19 100644 --- a/e2e/node/utils/tree.ts +++ b/e2e/node/utils/tree.ts @@ -116,6 +116,7 @@ export function createTimeTree(date: Date): HashTree { interface SubnetTreeOptions { subnetId: Uint8Array; + subnetPublicKey: Uint8Array; nodeIdentity: Ed25519KeyIdentity; canisterRanges: Array<[Uint8Array, Uint8Array]>; date: Date; @@ -125,6 +126,7 @@ interface SubnetTreeOptions { * Creates a subnet hash tree. * @param {SubnetTreeOptions} options - The options for the subnet tree. * @param {Uint8Array} options.subnetId - The ID of the subnet. + * @param {Uint8Array} options.subnetPublicKey - The DER-encoded public key of the subnet. * @param {Ed25519KeyIdentity} options.nodeIdentity - The identity of the node. * @param {Array<[Uint8Array, Uint8Array]>} options.canisterRanges - The canister ranges for the subnet. * @param {Date} options.date - The timestamp for the tree. @@ -132,6 +134,7 @@ interface SubnetTreeOptions { */ export function createSubnetTree({ subnetId, + subnetPublicKey, nodeIdentity, canisterRanges, date, @@ -141,12 +144,15 @@ export function createSubnetTree({ labeled('subnet', labeled(subnetId, fork( - labeled('canister_ranges', leaf(Cbor.encode(canisterRanges))), - labeled('node', - labeled(nodeIdentity.getPrincipal().toUint8Array(), - labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())), + fork( + labeled('canister_ranges', leaf(Cbor.encode(canisterRanges))), + labeled('node', + labeled(nodeIdentity.getPrincipal().toUint8Array(), + labeled('public_key', leaf(nodeIdentity.getPublicKey().toDer())), + ), ), ), + labeled('public_key', leaf(subnetPublicKey)), ), ), ), diff --git a/packages/core/src/agent/actor.test.ts b/packages/core/src/agent/actor.test.ts index 429ae348b..203169ddd 100644 --- a/packages/core/src/agent/actor.test.ts +++ b/packages/core/src/agent/actor.test.ts @@ -171,7 +171,7 @@ describe('makeActor', () => { }); expect(calls[1][0].toString()).toEqual( - `http://127.0.0.1/api/v2/canister/${canisterId.toText()}/read_state`, + `http://127.0.0.1/api/v3/canister/${canisterId.toText()}/read_state`, ); expect(calls[1][1]).toEqual({ method: 'POST', diff --git a/packages/core/src/agent/agent/http/index.ts b/packages/core/src/agent/agent/http/index.ts index afa37123e..5bf7620b7 100644 --- a/packages/core/src/agent/agent/http/index.ts +++ b/packages/core/src/agent/agent/http/index.ts @@ -55,8 +55,9 @@ import { type ReadStateRequest, type HttpHeaderField, } from './types.ts'; -import { type SubnetStatus, request as canisterStatusRequest } from '../../canisterStatus/index.ts'; +import { request as canisterStatusRequest } from '../../canisterStatus/index.ts'; import { request as subnetStatusRequest } from '../../subnetStatus/index.ts'; +import { type SubnetNodeKeys } from '../../utils/readState.ts'; import { Certificate, type HashTree, lookup_path, LookupPathStatus } from '../../certificate.ts'; import { ed25519 } from '@noble/curves/ed25519'; import { ExpirableMap } from '../../utils/expirableMap.ts'; @@ -316,7 +317,7 @@ export class HttpAgent implements Agent { #queryPipeline: HttpAgentRequestTransformFn[] = []; #updatePipeline: HttpAgentRequestTransformFn[] = []; - #subnetKeys: ExpirableMap = new ExpirableMap({ + #subnetKeys: ExpirableMap = new ExpirableMap({ expirationTime: 5 * MINUTE_TO_MSECS, }); #verifyQuerySignatures = true; @@ -661,7 +662,7 @@ export class HttpAgent implements Agent { const delay = tries === 0 ? 0 : backoff.next(); - const url = new URL(`/api/v2/canister/${ecid.toString()}/query`, this.host); + const url = new URL(`/api/v3/canister/${ecid.toString()}/query`, this.host); this.log.print(`fetching "${url.pathname}" with tries:`, { tries, @@ -934,17 +935,17 @@ export class HttpAgent implements Agent { }; }; - const getSubnetStatus = async (): Promise => { - const cachedSubnetStatus = this.#subnetKeys.get(ecid.toString()); - if (cachedSubnetStatus) { - return cachedSubnetStatus; + const getSubnetNodeKeys = async (): Promise => { + const cachedSubnetNodeKeys = this.#subnetKeys.get(ecid.toString()); + if (cachedSubnetNodeKeys) { + return cachedSubnetNodeKeys; } await this.fetchSubnetKeys(ecid.toString()); - const subnetStatus = this.#subnetKeys.get(ecid.toString()); - if (!subnetStatus) { + const subnetNodeKeys = this.#subnetKeys.get(ecid.toString()); + if (!subnetNodeKeys) { throw TrustError.fromCode(new MissingSignatureErrorCode()); } - return subnetStatus; + return subnetNodeKeys; }; try { @@ -954,16 +955,19 @@ export class HttpAgent implements Agent { } // Make query and fetch subnet keys in parallel - const [queryWithDetails, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]); + const [queryWithDetails, subnetNodeKeys] = await Promise.all([ + makeQuery(), + getSubnetNodeKeys(), + ]); try { - return this.#verifyQueryResponse(queryWithDetails, subnetStatus); + return this.#verifyQueryResponse(queryWithDetails, subnetNodeKeys); } 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); + const updatedSubnetNodeKeys = await getSubnetNodeKeys(); + return this.#verifyQueryResponse(queryWithDetails, updatedSubnetNodeKeys); } } catch (error) { let queryError: AgentError; @@ -987,12 +991,12 @@ export class HttpAgent implements Agent { /** * 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 + * @param subnetNodeKeys - The subnet node keys * @returns ApiQueryResponse */ #verifyQueryResponse = ( queryResponse: ApiQueryResponse, - subnetStatus: SubnetStatus, + subnetNodeKeys: SubnetNodeKeys, ): ApiQueryResponse => { if (this.#verifyQuerySignatures === false) { // This should not be called if the user has disabled verification @@ -1031,7 +1035,7 @@ export class HttpAgent implements Agent { const separatorWithHash = concatBytes(IC_RESPONSE_DOMAIN_SEPARATOR, hash); // FIX: check for match without verifying N times - const pubKey = subnetStatus.nodeKeys.get(nodeId); + const pubKey = subnetNodeKeys.get(nodeId); if (!pubKey) { throw ProtocolError.fromCode(new MalformedPublicKeyErrorCode()); } @@ -1119,7 +1123,7 @@ export class HttpAgent implements Agent { transformedRequest = await this.createReadStateRequest(fields, identity); } - const url = new URL(`/api/v2/canister/${canister.toString()}/read_state`, this.host); + const url = new URL(`/api/v3/canister/${canister.toString()}/read_state`, this.host); return await this.#readStateInner(url, transformedRequest, requestId); } @@ -1323,9 +1327,7 @@ export class HttpAgent implements Agent { this.#setTimeDiffMsecs(callTime, replicaTimes); } catch (error) { const syncTimeError = - error instanceof AgentError - ? error - : UnknownError.fromCode(new UnexpectedErrorCode(error)); + error instanceof AgentError ? error : UnknownError.fromCode(new UnexpectedErrorCode(error)); this.log.error('Caught exception while attempting to sync time with subnet', syncTimeError); throw syncTimeError; @@ -1415,21 +1417,25 @@ export class HttpAgent implements Agent { this.#identity = Promise.resolve(identity); } - public async fetchSubnetKeys(canisterId: Principal | string) { + public async fetchSubnetKeys( + canisterId: Principal | string, + ): Promise { const effectiveCanisterId: Principal = Principal.from(canisterId); await this.#asyncGuard(effectiveCanisterId); - const response = await canisterStatusRequest({ - canisterId: effectiveCanisterId, - paths: ['subnet'], + + const subnetId = await this.getSubnetIdFromCanister(effectiveCanisterId); + + const response = await subnetStatusRequest({ + subnetId, + paths: ['nodeKeys'], agent: this, }); - const subnetResponse = response.get('subnet'); - if (subnetResponse && typeof subnetResponse === 'object' && 'nodeKeys' in subnetResponse) { - this.#subnetKeys.set(effectiveCanisterId.toText(), subnetResponse as SubnetStatus); - return subnetResponse as SubnetStatus; + const nodeKeys = response.get('nodeKeys') as SubnetNodeKeys | undefined; + if (nodeKeys) { + this.#subnetKeys.set(effectiveCanisterId.toText(), nodeKeys); + return nodeKeys; } - // If the subnet status is not returned, return undefined return undefined; } @@ -1451,6 +1457,7 @@ export class HttpAgent implements Agent { rootKey: this.rootKey!, principal: { canisterId: effectiveCanisterId }, agent: this, + disableTimeVerification: true, // avoid extra calls to syncTime }); if (!canisterCertificate.cert.delegation) { diff --git a/packages/core/src/agent/canisterStatus/index.ts b/packages/core/src/agent/canisterStatus/index.ts index 62a3ceba0..50792e6c3 100644 --- a/packages/core/src/agent/canisterStatus/index.ts +++ b/packages/core/src/agent/canisterStatus/index.ts @@ -2,9 +2,7 @@ import { Principal } from '#principal'; import { CertificateVerificationErrorCode, MissingRootKeyErrorCode, - CertificateNotAuthorizedErrorCode, ExternalError, - TrustError, AgentError, UnknownError, UnexpectedErrorCode, @@ -12,12 +10,7 @@ import { CertificateTimeErrorCode, } from '../errors.ts'; import { HttpAgent } from '../agent/http/index.ts'; -import { - type Cert, - Certificate, - check_canister_ranges, - lookupResultToBuffer, -} from '../certificate.ts'; +import { type Cert, Certificate, lookupResultToBuffer } from '../certificate.ts'; import * as cbor from '../cbor.ts'; import { decodeTime } from '../utils/leb.ts'; import { utf8ToBytes, bytesToHex } from '@noble/hashes/utils'; @@ -198,6 +191,14 @@ export const request = async (options: CanisterStatusOptions): Promise(certificate); - const { delegation, tree } = cert; + const { delegation } = cert; let subnetId: Principal; if (delegation && delegation.subnet_id) { subnetId = Principal.fromUint8Array(new Uint8Array(delegation.subnet_id)); @@ -219,11 +220,6 @@ export const fetchNodeKeys = ( subnetId = IC_ROOT_SUBNET_ID; } - const canisterInRange = check_canister_ranges({ canisterId, subnetId, tree }); - if (!canisterInRange) { - throw TrustError.fromCode(new CertificateNotAuthorizedErrorCode(canisterId, subnetId)); - } - const nodeKeys = lookupNodeKeysFromCertificate(cert, subnetId); return {