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: generate nonce for HttpAgent calls #554

Merged
merged 5 commits into from Apr 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 0 additions & 2 deletions demos/ledgerhq/src/main.js
Expand Up @@ -82,8 +82,6 @@ document.getElementById('sendBtn').addEventListener('click', async () => {

// Need to run a replica locally which has ledger canister running on it
const agent = new HttpAgent({ host, identity });
// Ledger Hardware Wallet requires that the request must contain a nonce
agent.addTransform(makeNonceTransform());

const resp = await agent.call(canisterId, {
methodName: document.getElementById('methodName').value,
Expand Down
13 changes: 13 additions & 0 deletions docs/generated/changelog.html
Expand Up @@ -28,6 +28,19 @@ <h2>Version 0.10.5</h2>
versions to 0 for major version updates
</li>
<li>Removes jest-expect-message, which was making test error messages less useful</li>
<li>
<p>
HttpAgent now generates a nonce to ensure that calls are unique by default. If you want
to opt out or provide your own nonce logic, you can now pass an option of
<code>disableNonce: true</code>during the agent initialization.
</p>
<p>
If you are currently using
<code>agent.addTransform(makeNonceTransform())</code>
, please note that you should remove that logic, or add the <code>disableNonce</code>
option to your agent when upgrading.
</p>
</li>
</ul>
<h2>Version 0.10.3</h2>
<ul>
Expand Down
24 changes: 22 additions & 2 deletions e2e/node/basic/counter.test.ts
@@ -1,9 +1,9 @@
/**
* @jest-environment node
*/
import counterCanister from '../canisters/counter';
import counterCanister, { noncelessCanister } from '../canisters/counter';

jest.setTimeout(30000);
jest.setTimeout(40000);
describe('counter', () => {
it('should greet', async () => {
const { actor: counter } = await counterCanister();
Expand All @@ -13,6 +13,26 @@ describe('counter', () => {
console.error(error);
}
});
it('should submit distinct requests with nonce by default', async () => {
const { actor: counter } = await counterCanister();
const values = await Promise.all(new Array(4).fill(undefined).map(() => counter.inc_read()));
const set1 = new Set(values);
const values2 = await Promise.all(new Array(4).fill(undefined).map(() => counter.inc_read()));
const set2 = new Set(values2);

// Sets of unique results should be the same length
expect(set1.size).toBe(values.length);
expect(set2.size).toEqual(values2.length);
});
it('should submit duplicate requests if nonce is disabled', async () => {
const { actor: counter } = await noncelessCanister();
const values = await Promise.all(new Array(4).fill(undefined).map(() => counter.inc_read()));
const set1 = new Set(values);
const values2 = await Promise.all(new Array(4).fill(undefined).map(() => counter.inc_read()));
const set2 = new Set(values2);

expect(set1.size < values.length || set2.size < values2.length).toBe(true);
});
it('should increment', async () => {
const { actor: counter } = await counterCanister();
try {
Expand Down
43 changes: 41 additions & 2 deletions e2e/node/canisters/counter.ts
@@ -1,9 +1,9 @@
import { Actor } from '@dfinity/agent';
import { Actor, HttpAgent } from '@dfinity/agent';
import { IDL } from '@dfinity/candid';
import { Principal } from '@dfinity/principal';
import { readFileSync } from 'fs';
import path from 'path';
import agent from '../utils/agent';
import agent, { port, identity } from '../utils/agent';

let cache: {
canisterId: Principal;
Expand All @@ -27,6 +27,7 @@ export default async function (): Promise<{
const idl: IDL.InterfaceFactory = ({ IDL }) => {
return IDL.Service({
inc: IDL.Func([], [], []),
inc_read: IDL.Func([], [IDL.Nat], []),
read: IDL.Func([], [IDL.Nat], ['query']),
greet: IDL.Func([IDL.Text], [IDL.Text], []),
queryGreet: IDL.Func([IDL.Text], [IDL.Text], ['query']),
Expand All @@ -42,3 +43,41 @@ export default async function (): Promise<{

return cache;
}
/**
* With no cache and nonce disabled
*/
export async function noncelessCanister(): Promise<{
canisterId: Principal;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: any;
}> {
const module = readFileSync(path.join(__dirname, 'counter.wasm'));
const disableNonceAgent = await Promise.resolve(
new HttpAgent({
host: 'http://127.0.0.1:' + port,
identity,
disableNonce: true,
}),
).then(async agent => {
await agent.fetchRootKey();
return agent;
});

const canisterId = await Actor.createCanister({ agent: disableNonceAgent });
await Actor.install({ module }, { canisterId, agent: disableNonceAgent });
const idl: IDL.InterfaceFactory = ({ IDL }) => {
return IDL.Service({
inc: IDL.Func([], [], []),
inc_read: IDL.Func([], [IDL.Nat], []),
read: IDL.Func([], [IDL.Nat], ['query']),
greet: IDL.Func([IDL.Text], [IDL.Text], []),
queryGreet: IDL.Func([IDL.Text], [IDL.Text], ['query']),
});
};

return {
canisterId,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
actor: Actor.createActor(idl, { canisterId, agent: await disableNonceAgent }) as any,
};
}
7 changes: 3 additions & 4 deletions e2e/node/utils/agent.ts
@@ -1,18 +1,17 @@
import { HttpAgent, makeNonceTransform } from '@dfinity/agent';
import { HttpAgent } from '@dfinity/agent';
import { Ed25519KeyIdentity } from '@dfinity/identity';

const identity = Ed25519KeyIdentity.generate();
export const identity = Ed25519KeyIdentity.generate();
export const principal = identity.getPrincipal();

const port = parseInt(process.env['REPLICA_PORT'] || '', 10);
export const port = parseInt(process.env['REPLICA_PORT'] || '', 10);
if (Number.isNaN(port)) {
throw new Error('The environment variable REPLICA_PORT is not a number.');
}

const agent = Promise.resolve(new HttpAgent({ host: 'http://127.0.0.1:' + port, identity })).then(
async agent => {
await agent.fetchRootKey();
agent.addTransform(makeNonceTransform());
return agent;
},
);
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/actor.test.ts
Expand Up @@ -98,7 +98,7 @@ describe('makeActor', () => {

let nonceCount = 0;

const httpAgent = new HttpAgent({ fetch: mockFetch });
const httpAgent = new HttpAgent({ fetch: mockFetch, disableNonce: true });
httpAgent.addTransform(makeNonceTransform(() => nonces[nonceCount++]));

const actor = Actor.createActor(actorInterface, { canisterId, agent: httpAgent });
Expand Down
13 changes: 10 additions & 3 deletions packages/agent/src/agent/http/http.test.ts
Expand Up @@ -49,7 +49,6 @@ test('call', async () => {
const principal = Principal.anonymous();

const httpAgent = new HttpAgent({ fetch: mockFetch, host: 'http://localhost' });
httpAgent.addTransform(makeNonceTransform(() => nonce));

const methodName = 'greet';
const arg = new Uint8Array([]);
Expand Down Expand Up @@ -116,7 +115,11 @@ test('queries with the same content should have the same signature', async () =>

const principal = await Principal.anonymous();

const httpAgent = new HttpAgent({ fetch: mockFetch, host: 'http://localhost' });
const httpAgent = new HttpAgent({
fetch: mockFetch,
host: 'http://localhost',
disableNonce: true,
});
httpAgent.addTransform(makeNonceTransform(() => nonce));

const methodName = 'greet';
Expand Down Expand Up @@ -188,7 +191,11 @@ test('use anonymous principal if unspecified', async () => {
const nonce = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) as Nonce;
const principal = Principal.anonymous();

const httpAgent = new HttpAgent({ fetch: mockFetch, host: 'http://localhost' });
const httpAgent = new HttpAgent({
fetch: mockFetch,
host: 'http://localhost',
disableNonce: true,
});
httpAgent.addTransform(makeNonceTransform(() => nonce));

const methodName = 'greet';
Expand Down
21 changes: 20 additions & 1 deletion packages/agent/src/agent/http/index.ts
Expand Up @@ -13,13 +13,14 @@ import {
ReadStateResponse,
SubmitResponse,
} from '../api';
import { Expiry } from './transforms';
import { Expiry, makeNonceTransform } from './transforms';
import {
CallRequest,
Endpoint,
HttpAgentRequest,
HttpAgentRequestTransformFn,
HttpAgentSubmitRequest,
makeNonce,
QueryRequest,
ReadRequestType,
SubmitRequestType,
Expand Down Expand Up @@ -83,6 +84,19 @@ export interface HttpAgentOptions {
name: string;
password?: string;
};
/**
* Prevents the agent from providing a unique {@link Nonce} with each call.
* Enabling may cause rate limiting of identical requests
* at the boundary nodes.
*
* To add your own nonce generation logic, you can use the following:
* @example
* import {makeNonceTransform, makeNonce} from '@dfinity/agent';
* const agent = new HttpAgent({ disableNonce: true });
* agent.addTransform(makeNonceTransform(makeNonce);
* @default false
*/
disableNonce?: boolean;
}

function getDefaultFetch(): typeof fetch {
Expand Down Expand Up @@ -178,6 +192,11 @@ export class HttpAgent implements Agent {
this._credentials = `${name}${password ? ':' + password : ''}`;
}
this._identity = Promise.resolve(options.identity || new AnonymousIdentity());

// Add a nonce transform to ensure calls are unique
if (!options.disableNonce) {
this.addTransform(makeNonceTransform(makeNonce));
}
}

public addTransform(fn: HttpAgentRequestTransformFn, priority = fn.priority || 0): void {
Expand Down