diff --git a/packages/agent/src/actor.ts b/packages/agent/src/actor.ts index 72ed8c8e..bf4f906c 100644 --- a/packages/agent/src/actor.ts +++ b/packages/agent/src/actor.ts @@ -7,6 +7,7 @@ import { QueryResponseStatus, ReplicaRejectCode, SubmitResponse, + v2ResponseBody, v3ResponseBody, } from './agent'; import { AgentError } from './errors'; @@ -581,7 +582,20 @@ function _createActorMethod( ); } } + } else if (response.body && 'reject_message' in response.body) { + // handle v2 response errors by throwing an UpdateCallRejectedError object + const { reject_code, reject_message, error_code } = response.body as v2ResponseBody; + throw new UpdateCallRejectedError( + cid, + methodName, + requestId, + response, + reject_code, + reject_message, + error_code, + ); } + // Fall back to polling if we receive an Accepted response code if (response.status === 202) { const pollStrategy = pollingStrategyFactory(); diff --git a/packages/agent/src/agent/http/__snapshots__/http.test.ts.snap b/packages/agent/src/agent/http/__snapshots__/http.test.ts.snap index 9ad5d26e..5daa43d2 100644 --- a/packages/agent/src/agent/http/__snapshots__/http.test.ts.snap +++ b/packages/agent/src/agent/http/__snapshots__/http.test.ts.snap @@ -1,5 +1,80 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`it should handle calls against the ic-management canister that succeed 1`] = ` +{ + "cycles": 3092219247033n, + "idle_cycles_burned_per_day": 1808810n, + "memory_size": 2301012n, + "module_hash": [ + Uint8Array [ + 254, + 155, + 232, + 199, + 49, + 146, + 52, + 52, + 57, + 201, + 131, + 209, + 77, + 162, + 243, + 122, + 89, + 50, + 105, + 40, + 93, + 49, + 15, + 210, + 193, + 29, + 73, + 112, + 229, + 241, + 110, + 182, + ], + ], + "query_stats": { + "num_calls_total": 0n, + "num_instructions_total": 0n, + "request_payload_bytes_total": 0n, + "response_payload_bytes_total": 0n, + }, + "reserved_cycles": 0n, + "settings": { + "compute_allocation": 0n, + "controllers": [ + { + "__principal__": "2vxsx-fae", + }, + { + "__principal__": "bnz7o-iuaaa-aaaaa-qaaaa-cai", + }, + { + "__principal__": "jhnlf-yu2dz-v7beb-c77gl-76tj7-shaqo-5qfvi-htvel-gzamb-bvzx6-yqe", + }, + ], + "freezing_threshold": 2592000n, + "log_visibility": { + "controllers": null, + }, + "memory_allocation": 0n, + "reserved_cycles_limit": 5000000000000n, + "wasm_memory_limit": 0n, + }, + "status": { + "running": null, + }, +} +`; + exports[`retry failures should succeed after multiple failures within the configured limit 1`] = ` { "requestDetails": undefined, diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 386b6bda..17b618ef 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -13,7 +13,14 @@ import { Principal } from '@dfinity/principal'; import { requestIdOf } from '../../request_id'; import { JSDOM } from 'jsdom'; -import { Actor, AnonymousIdentity, SignIdentity, toHex } from '../..'; +import { + Actor, + AnonymousIdentity, + fromHex, + getManagementCanister, + SignIdentity, + toHex, +} from '../..'; import { Ed25519KeyIdentity } from '@dfinity/identity'; import { AgentError } from '../../errors'; import { AgentHTTPResponseError } from './errors'; @@ -813,3 +820,139 @@ test('it should log errors to console if the option is set', async () => { await agent.syncTime(); }); +test('it should handle calls against the ic-management canister that are rejected', async () => { + const identity = new AnonymousIdentity(); + identity.getPrincipal().toString(); + + // Response generated by calling a locally deployed replica of the management canister, cloned using fetchCloner + const mockResponse = { + headers: [ + ['access-control-allow-origin', '*'], + ['content-length', '178'], + ['content-type', 'application/cbor'], + ['date', 'Mon, 21 Oct 2024 23:35:59 GMT'], + ], + ok: true, + status: 200, + statusText: 'OK', + body: 'd9d9f7a46673746174757378186e6f6e5f7265706c6963617465645f72656a656374696f6e6a6572726f725f636f6465664943303531326b72656a6563745f636f6465056e72656a6563745f6d657373616765785d4f6e6c7920636f6e74726f6c6c657273206f662063616e697374657220626b797a322d666d6161612d61616161612d71616161712d6361692063616e2063616c6c2069633030206d6574686f642063616e69737465725f737461747573', + now: 1729553760128, + }; + + // Mock the fetch implementation, resolving a pre-calculated response + const mockFetch: jest.Mock = jest.fn(() => { + return Promise.resolve({ + ...mockResponse, + body: fromHex(mockResponse.body), + arrayBuffer: async () => fromHex(mockResponse.body), + }); + }); + + // Mock time so certificates can be accurately decoded + jest.useFakeTimers(); + jest.setSystemTime(mockResponse.now); + + const agent = await HttpAgent.createSync({ + identity, + fetch: mockFetch, + host: 'http://localhost:4943', + }); + + // Use management canister call + const management = getManagementCanister({ agent }); + + // Call snapshot was made when the test canister was not authorized to be called by the anonymous identity. It should reject + expect( + management.canister_status({ + canister_id: Principal.from('bkyz2-fmaaa-aaaaa-qaaaq-cai'), + }), + ).rejects.toThrow( + 'Only controllers of canister bkyz2-fmaaa-aaaaa-qaaaq-cai can call ic00 method canister_status', + ); +}); +test('it should handle calls against the ic-management canister that succeed', async () => { + const identity = new AnonymousIdentity(); + + // Response generated by calling a locally deployed replica of the management canister, cloned using fetchCloner + const mockResponse = { + headers: [ + ['access-control-allow-origin', '*'], + ['content-length', '761'], + ['content-type', 'application/cbor'], + ['date', 'Tue, 22 Oct 2024 22:19:07 GMT'], + ], + ok: true, + status: 200, + statusText: 'OK', + body: 'd9d9f7a266737461747573677265706c6965646b63657274696669636174655902d7d9d9f7a26474726565830183018204582012dbb02955bd3e2987bbba491230b2bb4a593feb02b5bb2d08f5f861afa9cec28301820458202b60693266aeec370be9f54508af493f4dd740086476054c862fe5af17ab15c183024e726571756573745f73746174757383018301820458204bebdfa0327978bfb109f0e14b35e8d368bb62114628ae547386162e9ee3dad883025820cf1cd57f39dfbb40ca1c816c71407c8b1b2edfb5a632676c7917dc4aa8641c5283018302457265706c7982035901684449444c0a6c0b9cb1fa2568b2ceef2f01c0cff2717d9cbab69c0202ffdb81f7037d8daacd94087de3f9f5d90805e8fc8cec0908b0e4d2970a7d81cfaef40a0984aaa89e0f7d6b038da4879b047ff496e4910b7fffdba5db0e7f6d036c020004017d6d7b6c089cb1fa2568c0cff2717dd7e09b90020680ad988a047dedd9c8c90707f8e287cc0c7ddeebb5a90e7da882acc60f7d6d686b02d7e09b90027fa981ceb7067f6c04c1f8dc83037d83cac6e9057da1d0b8af0a7d8fd0cfd00f7d6e040100011100001945cd0f5904e6ce2e5ac91900fb0102809a9e01010100b9f384b5ff59d4b88c01b9f384b5ff59011100001945cd0f5904e6ce2e5ac91900fb01809a9e0103010104010a80000000001000000101011d9a1e6bf09022ffccbffa69fc8e083bb02d5079d48b3640c086b9bfb10280a0e5b9c291010000000000000000aab36e0120fe9be8c73192343439c983d14da2f37a593269285d310fd2c11d4970e5f16eb6008302467374617475738203477265706c69656482045820daeffcc5dadc3aca94e0dc470e429ad4e3bc08517b5776f6a71e7e6982883bef8301820458208e6c6a7c4ba444475de4f4cd2d6df9501873d3290693060faf92e6dc528ee08083024474696d65820349a88dd3f9dacbb98018697369676e6174757265583088040a8228ef3f428c61918c5fb356e74b2ab07aa19f960edbb1fdfcbbc115e35f2e6c3f33b5cf4752799619e67e2b22', + now: 1729635546372, + }; + + // Mock the fetch implementation, resolving a pre-calculated response + const mockFetch: jest.Mock = jest.fn(() => { + return Promise.resolve({ + ...mockResponse, + body: fromHex(mockResponse.body), + arrayBuffer: async () => fromHex(mockResponse.body), + }); + }); + + // Mock time so certificates can be accurately decoded + jest.useFakeTimers(); + jest.setSystemTime(mockResponse.now); + + // Pass in rootKey from replica (used because test was written using local replica) + const agent = await HttpAgent.createSync({ + identity, + fetch: mockFetch, + host: 'http://localhost:4943', + rootKey: fromHex( + '308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c050302010361008be882f1985cccb53fd551571a42818014835ed8f8a27767669b67dd4a836eb0d62b327e3368a80615b0e4f472c73f7917c036dc9317dcb64b319a1efa43dd7c656225c061de359db6fdf7033ac1bff24c944c145e46ebdce2093680b6209a13', + ), + }); + + // Use management canister call + const management = getManagementCanister({ agent }); + + // Important - override nonce when making request to ensure reproducible result + (Actor.agentOf(management) as HttpAgent).addTransform('update', async args => { + args.body.nonce = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce; + return args; + }); + + // Call snapshot was made after the test canister was authorized to be called by the anonymous identity. It should resolve the status + const status = await management.canister_status({ + canister_id: Principal.from('bkyz2-fmaaa-aaaaa-qaaaq-cai'), + }); + + expect(status).toMatchSnapshot(); +}); + +/** + * Test utility to clone a fetch response for mocking purposes with the agent + * @param request - RequestInfo + * @param init - RequestInit + * @returns Promise + */ +export async function fetchCloner( + request: RequestInfo | URL, + init?: RequestInit, +): Promise { + const response = await fetch(request, init); + const cloned = response.clone(); + const responseBuffer = await cloned.arrayBuffer(); + + const mock = { + headers: [...response.headers.entries()], + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: toHex(responseBuffer), + now: Date.now(), + }; + + console.log(request); + console.log(JSON.stringify(mock)); + + return response; +} diff --git a/packages/agent/src/certificate.ts b/packages/agent/src/certificate.ts index 8ddac8ca..813cd704 100644 --- a/packages/agent/src/certificate.ts +++ b/packages/agent/src/certificate.ts @@ -5,6 +5,7 @@ import { bufEquals, concat, fromHex, toHex } from './utils/buffer'; import { Principal } from '@dfinity/principal'; import * as bls from './utils/bls'; import { decodeTime } from './utils/leb'; +import { MANAGEMENT_CANISTER_ID } from './agent'; /** * A certificate may fail verification with respect to the provided public key @@ -271,17 +272,19 @@ export class Certificate { await cert.verify(); - const canisterInRange = check_canister_ranges({ - canisterId: this._canisterId, - subnetId: Principal.fromUint8Array(new Uint8Array(d.subnet_id)), - tree: cert.cert.tree, - }); - if (!canisterInRange) { - throw new CertificateVerificationError( - `Canister ${this._canisterId} not in range of delegations for subnet 0x${toHex( - d.subnet_id, - )}`, - ); + if (this._canisterId.toString() !== MANAGEMENT_CANISTER_ID) { + const canisterInRange = check_canister_ranges({ + canisterId: this._canisterId, + subnetId: Principal.fromUint8Array(new Uint8Array(d.subnet_id)), + tree: cert.cert.tree, + }); + if (!canisterInRange) { + throw new CertificateVerificationError( + `Canister ${this._canisterId} not in range of delegations for subnet 0x${toHex( + d.subnet_id, + )}`, + ); + } } const publicKeyLookup = lookupResultToBuffer( cert.lookup(['subnet', d.subnet_id, 'public_key']),