Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: call and poll #941

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- feat: new `callAndPoll` function for an agent to call a canister and poll for the response

## [2.1.2] - 2024-09-30
- fix: revert https://github.com/dfinity/agent-js/pull/923 allow option to set agent replica time
- fix: handle v3 traps correctly, pulling the reject_code and message from the certificate in the error response like v2.
Expand Down
17 changes: 17 additions & 0 deletions e2e/node/basic/callAndPoll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HttpAgent, fromHex, callAndPoll } from '@dfinity/agent';
import { expect, describe, it, vi } from 'vitest';
describe('call and poll', () => {
it('should handle call and poll', async () => {
vi.useRealTimers();

const options = {
canisterId: 'tnnnb-2yaaa-aaaab-qaiiq-cai',
methodName: 'inc_read',
agent: await HttpAgent.create({ host: 'https://icp-api.io' }),
arg: fromHex('4449444c0000'),
};

const certificate = await callAndPoll(options);
expect(certificate instanceof ArrayBuffer).toBe(true);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you going to enhance the tests? e.g. asserting that the certificate return is the expected value or asserting that the function correctly throws if there was an error?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this should have been marked as a draft. I wanted feedback on the inputs and return values before proceeding. If you have real-world test cases this feature will be used for, those would be great to add as tests as well, beyond the basic cases!

});
});
9 changes: 8 additions & 1 deletion e2e/node/basic/trap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import util from 'util';
import exec from 'child_process';
const execAsync = util.promisify(exec.exec);

const { stdout } = await execAsync('dfx canister id trap');
// eslint-disable-next-line prefer-const
let stdout;
krpeacock marked this conversation as resolved.
Show resolved Hide resolved
try {
({ stdout } = await execAsync('dfx canister id trap'));
} catch {
await execAsync('dfx deploy trap');
({ stdout } = await execAsync('dfx canister id trap'));
}

export const idlFactory = ({ IDL }) => {
return IDL.Service({
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/certificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export interface CreateCertificateOptions {

export class Certificate {
public cert: Cert;
public readonly rawCert: ArrayBuffer;

/**
* Create a new instance of a certificate, automatically verifying it. Throws a
Expand Down Expand Up @@ -191,6 +192,7 @@ export class Certificate {
// Default to 5 minutes
private _maxAgeInMinutes: number = 5,
) {
this.rawCert = certificate;
this.cert = cbor.decode(new Uint8Array(certificate));
}

Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './der';
export * from './fetch_candid';
export * from './public_key';
export * from './request_id';
export * from './utils/callAndPoll';
export * from './utils/bls';
export * from './utils/buffer';
export * from './utils/random';
Expand Down
54 changes: 54 additions & 0 deletions packages/agent/src/utils/callAndPoll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Principal } from '@dfinity/principal';
import { Agent, Certificate, bufFromBufLike, polling, v3ResponseBody } from '..';
import { AgentError } from '../errors';

/**
* Call a canister using the v3 api and either return the response or fall back to polling
* @param options - The options to use when calling the canister
* @param options.canisterId - The canister id to call
* @param options.methodName - The method name to call
* @param options.agent - The agent to use to make the call
* @param options.arg - The argument to pass to the canister
* @returns The certificate response from the canister (which includes the reply)
*/
export async function callAndPoll(options: {
canisterId: Principal | string;
methodName: string;
agent: Agent;
arg: ArrayBuffer;
}): Promise<ArrayBuffer> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to get back the contentMap and the rawCert. Ideally, this function would have the exact same interface as call, maybe with the addition of being able to supply the pollStrategy as suggested by @dfx-json.

Also, please extend the CallOptions interface to allow specifying the nonce too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@frederikrothenberger can you define what contentMap precisely is? Should it be an ArrayBuffer? Does it need to be decoded into a JavaScript object? Are you willing to provide an IDL interface for the types so we can actually decode the candid?

Relatedly, I don't know what actual feature this is for, or how it will be used in practice. We'd probably have had a lot less back-and-forth about this if I was just designing a feature for your use-case instead of all these half-requirements and us each having slightly different definitions of what things are

const { canisterId, methodName, agent, arg } = options;
const cid = Principal.from(options.canisterId);

const { defaultStrategy } = polling.strategy;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be useful for polling strategy to be an option?


if (agent.rootKey == null) throw new Error('Agent root key not initialized before making call');

const { requestId, response } = await agent.call(cid, {
methodName,
arg,
effectiveCanisterId: cid,
});

let certificate: Certificate;
if (response.body && (response.body as v3ResponseBody).certificate) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you assert that the type is v3 before casting? This feels a bit optimistic.

krpeacock marked this conversation as resolved.
Show resolved Hide resolved
const cert = (response.body as v3ResponseBody).certificate;
// Create certificate to validate the responses
certificate = await Certificate.create({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the certificate status be asserted at this point?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't (and still won't be until tomorrow) sure what the intended purpose for this feature is. Certificate.create will validate the certificate, but not check the status. There's no problem with throwing if it's rejected, but I wasn't sure if that was desireable behavior for this low-level feature

certificate: bufFromBufLike(cert),
rootKey: agent.rootKey,
canisterId: Principal.from(canisterId),
});
} else {
throw new AgentError('unexpected call error: no certificate in response');
}
// Fall back to polling if we recieve an Accepted response code
if (response.status === 202) {
const pollStrategy = defaultStrategy();
// Contains the certificate and the reply from the boundary node
const response = await polling.pollForResponse(agent, cid, requestId, pollStrategy);
certificate = response.certificate;
}

return certificate.rawCert;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it throws if none of the above was a valid response?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The throw for an invalid response will originate from the Certificate.create step, either in the sync or polling flow. I could capture it and throw a custom error here for code legibility purposes

}
Loading