diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8f617854..f0973d4c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- feat: allow for setting HttpAgent ingress expiry using `ingressExpiryInMinutes` option + ### Changed - test: automatically deploys trap canister if it doesn't exist yet during e2e diff --git a/e2e/node/basic/mainnet.test.ts b/e2e/node/basic/mainnet.test.ts index b3a530ad..001aa01d 100644 --- a/e2e/node/basic/mainnet.test.ts +++ b/e2e/node/basic/mainnet.test.ts @@ -142,6 +142,49 @@ describe('call forwarding', () => { }, 15_000); }); +// TODO: change Expiry logic rounding to make <= 1 minute expiry work +test('it should succeed when setting an expiry in the near future', async () => { + const agent = await HttpAgent.create({ + host: 'https://icp-api.io', + ingressExpiryInMinutes: 1, + }); + + await agent.syncTime(); + + expect( + agent.call('tnnnb-2yaaa-aaaab-qaiiq-cai', { + methodName: 'inc_read', + arg: fromHex('4449444c0000'), + effectiveCanisterId: 'tnnnb-2yaaa-aaaab-qaiiq-cai', + }), + ).resolves.toBeDefined(); +}); + +test('it should succeed when setting an expiry in the future', async () => { + const agent = await HttpAgent.create({ + host: 'https://icp-api.io', + ingressExpiryInMinutes: 5, + }); + + await agent.syncTime(); + + expect( + agent.call('tnnnb-2yaaa-aaaab-qaiiq-cai', { + methodName: 'inc_read', + arg: fromHex('4449444c0000'), + effectiveCanisterId: 'tnnnb-2yaaa-aaaab-qaiiq-cai', + }), + ).resolves.toBeDefined(); +}); + +test('it should fail when setting an expiry in the far future', async () => { + expect( + HttpAgent.create({ + host: 'https://icp-api.io', + ingressExpiryInMinutes: 100, + }), + ).rejects.toThrowError(`The maximum ingress expiry time is 5 minutes`); +}); test('it should allow you to set an incorrect root key', async () => { const agent = HttpAgent.createSync({ diff --git a/packages/agent/src/agent/http/http.test.ts b/packages/agent/src/agent/http/http.test.ts index 17b618ef..1b66cd93 100644 --- a/packages/agent/src/agent/http/http.test.ts +++ b/packages/agent/src/agent/http/http.test.ts @@ -820,6 +820,16 @@ test('it should log errors to console if the option is set', async () => { await agent.syncTime(); }); +test('it should fail when setting an expiry in the past', async () => { + expect(() => + HttpAgent.createSync({ + host: 'https://icp-api.io', + ingressExpiryInMinutes: -1, + fetch: jest.fn(), + }), + ).toThrow(`Ingress expiry time must be greater than 0`); +}); + test('it should handle calls against the ic-management canister that are rejected', async () => { const identity = new AnonymousIdentity(); identity.getPrincipal().toString(); diff --git a/packages/agent/src/agent/http/index.ts b/packages/agent/src/agent/http/index.ts index f3aa840e..22346eb6 100644 --- a/packages/agent/src/agent/http/index.ts +++ b/packages/agent/src/agent/http/index.ts @@ -55,6 +55,8 @@ export enum RequestStatusResponseStatus { Done = 'done', } +const MINUTE_TO_MSECS = 60 * 1000; + // Root public key for the IC, encoded as hex export const IC_ROOT_KEY = '308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814' + @@ -106,6 +108,12 @@ export interface HttpAgentOptions { // time (will throw). identity?: Identity | Promise; + /** + * The maximum time a request can be delayed before being rejected. + * @default 5 minutes + */ + ingressExpiryInMinutes?: number; + credentials?: { name: string; password?: string; @@ -248,6 +256,7 @@ export class HttpAgent implements Agent { #rootKeyFetched = false; readonly #retryTimes; // Retry requests N times before erroring by default #backoffStrategy: BackoffStrategyFactory; + readonly #maxIngressExpiryInMinutes: number; // Public signature to help with type checking. public readonly _isAgent = true; @@ -310,6 +319,19 @@ export class HttpAgent implements Agent { } this.#identity = Promise.resolve(options.identity || new AnonymousIdentity()); + if (options.ingressExpiryInMinutes && options.ingressExpiryInMinutes > 5) { + throw new AgentError( + `The maximum ingress expiry time is 5 minutes. Provided ingress expiry time is ${options.ingressExpiryInMinutes} minutes.`, + ); + } + if (options.ingressExpiryInMinutes && options.ingressExpiryInMinutes <= 0) { + throw new AgentError( + `Ingress expiry time must be greater than 0. Provided ingress expiry time is ${options.ingressExpiryInMinutes} minutes.`, + ); + } + + this.#maxIngressExpiryInMinutes = options.ingressExpiryInMinutes || 5; + // Add a nonce transform to ensure calls are unique this.addTransform('update', makeNonceTransform(makeNonce)); if (options.useQueryNonces) { @@ -428,11 +450,13 @@ export class HttpAgent implements Agent { const sender: Principal = id.getPrincipal() || Principal.anonymous(); - let ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS); + let ingress_expiry = new Expiry(this.#maxIngressExpiryInMinutes * MINUTE_TO_MSECS); // If the value is off by more than 30 seconds, reconcile system time with the network if (Math.abs(this.#timeDiffMsecs) > 1_000 * 30) { - ingress_expiry = new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS + this.#timeDiffMsecs); + ingress_expiry = new Expiry( + this.#maxIngressExpiryInMinutes * MINUTE_TO_MSECS + this.#timeDiffMsecs, + ); } const submit: CallRequest = { @@ -772,7 +796,7 @@ export class HttpAgent implements Agent { method_name: fields.methodName, arg: fields.arg, sender, - ingress_expiry: new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS), + ingress_expiry: new Expiry(this.#maxIngressExpiryInMinutes * MINUTE_TO_MSECS), }; const requestId = await requestIdOf(request); @@ -957,7 +981,7 @@ export class HttpAgent implements Agent { request_type: ReadRequestType.ReadState, paths: fields.paths, sender, - ingress_expiry: new Expiry(DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS), + ingress_expiry: new Expiry(this.#maxIngressExpiryInMinutes * MINUTE_TO_MSECS), }, }); diff --git a/packages/agent/src/agent/http/transforms.test.ts b/packages/agent/src/agent/http/transforms.test.ts index 7521309c..a4981976 100644 --- a/packages/agent/src/agent/http/transforms.test.ts +++ b/packages/agent/src/agent/http/transforms.test.ts @@ -13,3 +13,16 @@ test('it should round down to the nearest minute', () => { ).toISOString(); expect(expiry_as_date_string).toBe('2021-04-26T17:51:00.000Z'); }); + +test('it should round down to the nearest second if less than 90 seconds', () => { + // 2021-04-26T17:47:11.314Z - high precision + jest.setSystemTime(new Date(1619459231314)); + + const expiry = new Expiry(89 * 1000); + expect(expiry['_value']).toEqual(BigInt(1619459320000000000n)); + + const expiry_as_date_string = new Date( + Number(expiry['_value'] / BigInt(1_000_000)), + ).toISOString(); + expect(expiry_as_date_string).toBe('2021-04-26T17:48:40.000Z'); +}); diff --git a/packages/agent/src/agent/http/transforms.ts b/packages/agent/src/agent/http/transforms.ts index 934dfbfb..cc3b58d3 100644 --- a/packages/agent/src/agent/http/transforms.ts +++ b/packages/agent/src/agent/http/transforms.ts @@ -17,12 +17,21 @@ export class Expiry { private readonly _value: bigint; constructor(deltaInMSec: number) { + // if ingress as seconds is less than 90, round to nearest second + if (deltaInMSec < 90 * 1_000) { + // Raw value without subtraction of REPLICA_PERMITTED_DRIFT_MILLISECONDS + const raw_value = BigInt(Date.now() + deltaInMSec) * NANOSECONDS_PER_MILLISECONDS; + const ingress_as_seconds = raw_value / BigInt(1_000_000_000); + this._value = ingress_as_seconds * BigInt(1_000_000_000); + return; + } + // Use bigint because it can overflow the maximum number allowed in a double float. const raw_value = BigInt(Math.floor(Date.now() + deltaInMSec - REPLICA_PERMITTED_DRIFT_MILLISECONDS)) * NANOSECONDS_PER_MILLISECONDS; - // round down to the nearest second + // round down to the nearest second (since ) const ingress_as_seconds = raw_value / BigInt(1_000_000_000); // round down to nearest minute