Skip to content

Commit

Permalink
feat: support for management canister logging (#863)
Browse files Browse the repository at this point in the history
* feat: updates for canister logging feature from management canister

* feat: support and tests for fetch_canister_logs

* changelog

* test for bitcoin query

* bitcoin query tests
  • Loading branch information
krpeacock committed Mar 22, 2024
1 parent a926850 commit c4c9ef9
Show file tree
Hide file tree
Showing 15 changed files with 1,057 additions and 420 deletions.
31 changes: 31 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,41 @@

## [Unreleased]

### Added

* feat: adds support for verified queries against management canister
* includes support for `fetch_canister_logs` in the actor provided by `getManagementCanister`
* also includes support for bitcoin queries

Logging

```ts
// Agent should not use an anonymous identity for this call, and should ideally be a canister controller
const management = await getManagementCanister({ agent });
const logs = await management.fetch_canister_logs({ canister_id: canisterId });
```

Bitcoin

```ts
// For now, the verifyQuerySignatures option must be set to false
const agent = await makeAgent({ host: 'https://icp-api.io', verifyQuerySignatures: false });
const management = getManagementCanister({
agent
});

const result = await management.bitcoin_get_balance_query({
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
network: { mainnet: null },
min_confirmations: [6],
});
```

### Changed

* fix: pads date numbers in changelog automation. E.G. 2024-3-1 -> 2024-03-01
* feat: allow passing `DBCreateOptions` to `IdbStorage` constructor
* updated management canister interface

## [1.1.1] - 2024-03-19

Expand Down
31 changes: 31 additions & 0 deletions e2e/node/basic/logging.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { bufFromBufLike, getManagementCanister } from '@dfinity/agent';
import { describe, it, expect } from 'vitest';
import logsActor from '../canisters/logs';
import { makeAgent } from '../utils/agent';

describe('canister logs', () => {
it('should make requests to the management canister', async () => {
const { canisterId } = await logsActor();

const management = await getManagementCanister({ agent: await makeAgent() });
const logs = await management.fetch_canister_logs({ canister_id: canisterId });

expect(logs.canister_log_records.length).toBe(1);
const content = bufFromBufLike(logs.canister_log_records[0].content);

expect(new TextDecoder().decode(content).trim()).toBe('Hello, first call!');
});
it('should show additional logs', async () => {
const { canisterId, actor } = await logsActor();

await actor.hello('second call');

const management = await getManagementCanister({ agent: await makeAgent() });
const logs = await management.fetch_canister_logs({ canister_id: canisterId });

expect(logs.canister_log_records.length).toBe(2);
const content = bufFromBufLike(logs.canister_log_records[1].content);

expect(new TextDecoder().decode(content).trim()).toBe('Hello, second call!');
});
}, 10_000);
27 changes: 26 additions & 1 deletion e2e/node/basic/mainnet.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Actor, AnonymousIdentity, HttpAgent, Identity, CanisterStatus } from '@dfinity/agent';
import {
Actor,
AnonymousIdentity,
HttpAgent,
Identity,
CanisterStatus,
getManagementCanister,
} from '@dfinity/agent';
import { IDL } from '@dfinity/candid';
import { Ed25519KeyIdentity } from '@dfinity/identity';
import { Principal } from '@dfinity/principal';
Expand Down Expand Up @@ -161,3 +168,21 @@ describe('controllers', () => {
`);
});
});

describe('bitcoin query', async () => {
it('should return the balance of a bitcoin address', async () => {
// TODO - verify node signature for bitcoin once supported
const agent = await makeAgent({ host: 'https://icp-api.io', verifyQuerySignatures: false });
const management = getManagementCanister({
agent,
});

const result = await management.bitcoin_get_balance_query({
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
network: { mainnet: null },
min_confirmations: [6],
});
console.log(`balance for address: ${result}`);
expect(result).toBeGreaterThan(0n);
});
});
1 change: 1 addition & 0 deletions e2e/node/basic/mitm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ mitmTest(
const counter = await createActor('tnnnb-2yaaa-aaaab-qaiiq-cai', {
agent: await makeAgent({
host: 'http://127.0.0.1:8888',
verifyQuerySignatures: false,
}),
});
await expect(counter.greet('counter')).rejects.toThrow(/Invalid certificate/);
Expand Down
52 changes: 52 additions & 0 deletions e2e/node/canisters/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Principal } from '@dfinity/principal';
import agent from '../utils/agent';
import { readFileSync } from 'fs';
import path from 'path';
import { Actor, ActorMethod, ActorSubclass } from '@dfinity/agent';
import { IDL } from '@dfinity/candid';

export interface _SERVICE {
hello: ActorMethod<[string], undefined>;
}
export declare const init: (args: { IDL: typeof IDL }) => IDL.Type[];

export const idlFactory = ({ IDL }) => {
return IDL.Service({ hello: IDL.Func([IDL.Text], [], []) });
};

let cache: {
canisterId: Principal;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: any;
} | null = null;

/**
* Create a counter Actor + canisterId
*/
export default async function (): Promise<{
canisterId: Principal;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: any;
}> {
if (!cache) {
const module = readFileSync(path.join(__dirname, 'logs.wasm'));

const canisterId = await Actor.createCanister({ agent: await agent });
await Actor.install({ module }, { canisterId, agent: await agent });

const actor = Actor.createActor(idlFactory, {
canisterId,
agent: await agent,
}) as ActorSubclass<_SERVICE>;

await actor.hello('first call');

cache = {
canisterId,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor,
};
}

return cache;
}
Binary file added e2e/node/canisters/logs.wasm
Binary file not shown.
2 changes: 0 additions & 2 deletions e2e/node/utils/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ if (Number.isNaN(port)) {
export const makeAgent = async (options?: HttpAgentOptions) => {
const agent = new HttpAgent({
host: `http://127.0.0.1:${process.env.REPLICA_PORT ?? 4943}`,
// TODO - remove this when the dfx replica supports it
verifyQuerySignatures: false,
...options,
});
try {
Expand Down
11 changes: 10 additions & 1 deletion packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ export class Actor {
compute_allocation: settings.compute_allocation ? [settings.compute_allocation] : [],
freezing_threshold: settings.freezing_threshold ? [settings.freezing_threshold] : [],
memory_allocation: settings.memory_allocation ? [settings.memory_allocation] : [],
reserved_cycles_limit: [],
log_visibility: [],
},
];
}
Expand Down Expand Up @@ -429,7 +431,11 @@ function _createActorMethod(
const cid = Principal.from(options.canisterId || actor[metadataSymbol].config.canisterId);
const arg = IDL.encode(func.argTypes, args);

const result = await agent.query(cid, { methodName, arg });
const result = await agent.query(cid, {
methodName,
arg,
effectiveCanisterId: options.effectiveCanisterId,
});

switch (result.status) {
case QueryResponseStatus.Rejected:
Expand Down Expand Up @@ -517,6 +523,9 @@ export function getManagementCanister(config: CallConfig): ActorSubclass<Managem
_methodName: string,
args: Record<string, unknown> & { canister_id: string }[],
) {
if (config.effectiveCanisterId) {
return { effectiveCanisterId: Principal.from(config.effectiveCanisterId) };
}
const first = args[0];
let effectiveCanisterId = Principal.fromHex('');
if (first && typeof first === 'object' && first.canister_id) {
Expand Down
5 changes: 5 additions & 0 deletions packages/agent/src/agent/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@ export interface QueryFields {
* A binary encoded argument. This is already encoded and will be sent as is.
*/
arg: ArrayBuffer;

/**
* Overrides canister id for path to fetch. This is used for management canister calls.
*/
effectiveCanisterId?: Principal;
}

/**
Expand Down
69 changes: 49 additions & 20 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,14 @@ export enum RequestStatusResponseStatus {
const DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS = 5 * 60 * 1000;

// Root public key for the IC, encoded as hex
const IC_ROOT_KEY =
export const IC_ROOT_KEY =
'308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814' +
'c0e6ec71fab583b08bd81373c255c3c371b2e84863c98a4f1e08b74235d14fb5d9c0cd546d968' +
'5f913a0c0b2cc5341583bf4b4392e467db96d65b9bb4cb717112f8472e0d5a4d14505ffd7484' +
'b01291091c5f87b98883463f98091a0baaae';

export const MANAGEMENT_CANISTER_ID = 'aaaaa-aa';

// IC0 domain info
const IC0_DOMAIN = 'ic0.app';
const IC0_SUB_DOMAIN = '.ic0.app';
Expand Down Expand Up @@ -396,6 +398,8 @@ export class HttpAgent implements Agent {

const body = cbor.encode(transformedRequest.body);

this.log(`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 request = this._requestAndRetry(() =>
Expand Down Expand Up @@ -427,36 +431,50 @@ export class HttpAgent implements Agent {

async #requestAndRetryQuery(
args: {
canister: string;
ecid: Principal;
transformedRequest: HttpAgentRequest;
body: ArrayBuffer;
requestId: RequestId;
},
tries = 0,
): Promise<ApiQueryResponse> {
const { canister, transformedRequest, body, requestId } = args;
const { ecid, transformedRequest, body, requestId } = args;
let response: ApiQueryResponse;
// Make the request and retry if it throws an error
try {
const fetchResponse = await this._fetch(
'' + new URL(`/api/v2/canister/${canister}/query`, this._host),
'' + new URL(`/api/v2/canister/${ecid.toString()}/query`, this._host),
{
...this._fetchOptions,
...transformedRequest.request,
body,
},
);
const queryResponse: QueryResponse = cbor.decode(await fetchResponse.arrayBuffer());
response = {
...queryResponse,
httpDetails: {
ok: fetchResponse.ok,
status: fetchResponse.status,
statusText: fetchResponse.statusText,
headers: httpHeadersTransform(fetchResponse.headers),
},
requestId,
};
if (fetchResponse.status === 200) {
const queryResponse: QueryResponse = cbor.decode(await fetchResponse.arrayBuffer());
response = {
...queryResponse,
httpDetails: {
ok: fetchResponse.ok,
status: fetchResponse.status,
statusText: fetchResponse.statusText,
headers: httpHeadersTransform(fetchResponse.headers),
},
requestId,
};
} else {
throw new AgentHTTPResponseError(
`Server returned an error:\n` +
` Code: ${fetchResponse.status} (${fetchResponse.statusText})\n` +
` Body: ${await fetchResponse.text()}\n`,
{
ok: fetchResponse.ok,
status: fetchResponse.status,
statusText: fetchResponse.statusText,
headers: httpHeadersTransform(fetchResponse.headers),
},
);
}
} catch (error) {
if (tries < this._retryTimes) {
this.log.warn(
Expand Down Expand Up @@ -553,7 +571,12 @@ export class HttpAgent implements Agent {
fields: QueryFields,
identity?: Identity | Promise<Identity>,
): Promise<ApiQueryResponse> {
this.log(`making query to canister ${canisterId} with fields:`, fields);
const ecid = fields.effectiveCanisterId
? Principal.from(fields.effectiveCanisterId)
: Principal.from(canisterId);

this.log(`ecid ${ecid.toString()}`);
this.log(`canisterId ${canisterId.toString()}`);
const makeQuery = async () => {
const id = await (identity !== undefined ? await identity : await this._identity);
if (!id) {
Expand Down Expand Up @@ -597,6 +620,7 @@ export class HttpAgent implements Agent {

const args = {
canister: canister.toText(),
ecid,
transformedRequest,
body,
requestId,
Expand All @@ -609,12 +633,12 @@ export class HttpAgent implements Agent {
if (!this.#verifyQuerySignatures) {
return undefined;
}
const subnetStatus = this.#subnetKeys.get(canisterId.toString());
const subnetStatus = this.#subnetKeys.get(ecid.toString());
if (subnetStatus) {
return subnetStatus;
}
await this.fetchSubnetKeys(canisterId.toString());
return this.#subnetKeys.get(canisterId.toString());
await this.fetchSubnetKeys(ecid.toString());
return this.#subnetKeys.get(ecid.toString());
};
// Attempt to make the query i=retryTimes times
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Expand All @@ -633,7 +657,7 @@ export class HttpAgent implements Agent {
// 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(canisterId.toString());
await this.fetchSubnetKeys(canisterId.toString());
await this.fetchSubnetKeys(ecid.toString());

const updatedSubnetStatus = this.#subnetKeys.get(canisterId.toString());
if (!updatedSubnetStatus) {
Expand Down Expand Up @@ -767,6 +791,10 @@ export class HttpAgent implements Agent {
const transformedRequest = request ?? (await this.createReadStateRequest(fields, identity));
const body = cbor.encode(transformedRequest.body);

this.log(
`fetching "/api/v2/canister/${canister}/read_state" with request:`,
transformedRequest,
);
// TODO - https://dfinity.atlassian.net/browse/SDK-1092
const response = await this._requestAndRetry(() =>
this._fetch('' + new URL(`/api/v2/canister/${canister}/read_state`, this._host), {
Expand Down Expand Up @@ -858,6 +886,7 @@ export class HttpAgent implements Agent {
}
: {};

this.log(`fetching "/api/v2/status"`);
const response = await this._requestAndRetry(() =>
this._fetch('' + new URL(`/api/v2/status`, this._host), { headers, ...this._fetchOptions }),
);
Expand Down
Loading

0 comments on commit c4c9ef9

Please sign in to comment.