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: allow for setting HttpAgent ingress expiry using ingressExpiryInMinutes option #905

Merged
6 commits merged into from
Oct 23, 2024
Merged
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: allow for setting HttpAgent ingress expiry using `ingressExpiryInMinutes` option

## [2.0.0] - 2024-07-16

### Changed
Expand Down
46 changes: 46 additions & 0 deletions e2e/node/basic/mainnet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,49 @@ describe('call forwarding', () => {
reply; // ArrayBuffer
}, 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`);
});
4 changes: 2 additions & 2 deletions e2e/node/integration/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ test("Legacy Agent interface should be accepted by Actor's createActor", async (
);

// Verify that update calls work
await actor.write(8n); //?
await actor.write(8n);
// Verify that query calls work
const count = await actor.read(); //?
const count = await actor.read();
expect(count).toBe(8n);
}, 15_000);
// TODO: tests for rejected, unknown time out
4 changes: 2 additions & 2 deletions packages/agent/src/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ describe('makeActor', () => {
expect(reply).toEqual(canisterDecodedReturnValue);
expect(replyUpdate).toEqual(canisterDecodedReturnValue);
expect(replyWithHttpDetails.result).toEqual(canisterDecodedReturnValue);
replyWithHttpDetails.httpDetails['requestDetails']; //?
replyWithHttpDetails.httpDetails['requestDetails'];
expect(replyWithHttpDetails.httpDetails).toMatchInlineSnapshot(`
{
"headers": [],
Expand Down Expand Up @@ -330,7 +330,7 @@ describe('makeActor', () => {
`);
expect(replyUpdateWithHttpDetails.result).toEqual(canisterDecodedReturnValue);

replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array(); //?
replyUpdateWithHttpDetails.httpDetails['requestDetails']['nonce'] = new Uint8Array();

expect(replyUpdateWithHttpDetails.httpDetails).toMatchSnapshot();
});
Expand Down
11 changes: 11 additions & 0 deletions packages/agent/src/agent/http/http.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,3 +809,14 @@ test('it should log errors to console if the option is set', async () => {
const agent = new HttpAgent({ host: HTTP_AGENT_HOST, fetch: jest.fn(), logToConsole: true });
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`);
});
33 changes: 27 additions & 6 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,7 @@ export enum RequestStatusResponseStatus {
Done = 'done',
}

// Default delta for ingress expiry is 5 minutes.
const DEFAULT_INGRESS_EXPIRY_DELTA_IN_MSECS = 5 * 60 * 1000;
const MINUTE_TO_MSECS = 60 * 1000;

// Root public key for the IC, encoded as hex
export const IC_ROOT_KEY =
Expand Down Expand Up @@ -107,6 +106,12 @@ export interface HttpAgentOptions {
// time (will throw).
identity?: Identity | Promise<Identity>;

/**
* The maximum time a request can be delayed before being rejected.
* @default 5 minutes
*/
ingressExpiryInMinutes?: number;

credentials?: {
name: string;
password?: string;
Expand Down Expand Up @@ -243,6 +248,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;
Expand Down Expand Up @@ -304,6 +310,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) {
Expand Down Expand Up @@ -419,11 +438,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 = {
Expand Down Expand Up @@ -713,7 +734,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);
Expand Down Expand Up @@ -900,7 +921,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),
},
});

Expand Down
13 changes: 13 additions & 0 deletions packages/agent/src/agent/http/transforms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
11 changes: 10 additions & 1 deletion packages/agent/src/agent/http/transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading