Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
18296ae
refactor: improve error message for certificate signature verificatio…
ilbertt Jul 24, 2025
ff5713f
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 25, 2025
e713ce8
fix: account for clock drift in certificate freshness check
ilbertt Jul 25, 2025
c3a73a4
chore: update changelog
ilbertt Jul 25, 2025
4298f27
chore: update changelog
ilbertt Jul 25, 2025
7f2577c
docs: fix typos
ilbertt Jul 27, 2025
9f8ed1f
refactor: rename certificate arg and helper function
ilbertt Jul 29, 2025
f42dc33
test: `getAdjustedCurrentTime` tests
ilbertt Jul 29, 2025
f41244a
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 29, 2025
cb04911
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 30, 2025
0dc1643
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Jul 31, 2025
8602840
fix: calculate clock drift inside error
ilbertt Aug 4, 2025
6f72994
Revert "refactor: rename certificate arg and helper function"
ilbertt Aug 4, 2025
57ae00d
test: update after revert
ilbertt Aug 4, 2025
4069b9d
test: remove unused variable
ilbertt Aug 4, 2025
0856683
Merge remote-tracking branch 'origin/main' into luca/SDK-2204-clock-d…
ilbertt Aug 4, 2025
a7ead48
refactor: address PR feedback
ilbertt Aug 4, 2025
2830e41
test: refine certificate creation tests logic
ilbertt Aug 4, 2025
cc1c01c
test: add more cases for certificate freshness checks
ilbertt Aug 4, 2025
ad20d57
chore: update changelog
ilbertt Aug 4, 2025
5ff4b96
fix: address PR feedback
ilbertt Aug 4, 2025
efc9e5d
docs: timeDiffMsecs jsdocs
ilbertt Aug 4, 2025
331b454
docs: timediffmsecs jsdocs
ilbertt Aug 4, 2025
5f27310
fix: we don't need the timeDiffMsecs param
ilbertt Aug 4, 2025
de5e3af
Apply suggestion from @mraszyk
ilbertt Aug 4, 2025
5bfc26d
Apply suggestion from @mraszyk
ilbertt Aug 4, 2025
175aa08
chore: update clock drift explanation
ilbertt Aug 5, 2025
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
- feat: enables type inference for the arguments and return types of `FuncClass`.
- feat: enables type inference for the fields of `ServiceClass`.
- fix: perform the canister range checks unconditionally for delegations when constructing a `Certificate` instance.
- fix: account for clock drift when verifying the certificate freshness, and syncs time with the IC network if the certificate fails the freshness check and the agent's time is not already synced.
- feat: adds the `agent` optional field to the `CreateCertificateOptions` interface, which is used to sync time with the IC network if the certificate fails the freshness check, if provided.
- feat: adds the `getTimeDiffMsecs` method to the `HttpAgent` class, which returns the time difference in milliseconds between the client's clock and the IC network clock.
- feat: adds the `hasSyncedTime` method to the `HttpAgent` class, which returns `true` if the time has been synced at least once with the IC network, `false` otherwise.

## [3.1.0] - 2025-07-24

Expand Down
14 changes: 11 additions & 3 deletions e2e/node/basic/syncTime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe('syncTime', () => {
},
'V3 call body',
);
expect(agent.hasSyncedTime()).toBe(false);
});

it('should sync time when the local time does not match the subnet time', async () => {
Expand Down Expand Up @@ -179,6 +180,7 @@ describe('syncTime', () => {
},
'V3 read state body three',
);
expect(agent.hasSyncedTime()).toBe(true);
});
});

Expand All @@ -197,7 +199,7 @@ describe('syncTime', () => {
res.status(200).send(readStateResponse);
});

await HttpAgent.create({
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: keyPair.publicKeyDer,
shouldSyncTime: true,
Expand Down Expand Up @@ -225,6 +227,7 @@ describe('syncTime', () => {
},
'V3 read state body three',
);
expect(agent.hasSyncedTime()).toBe(true);
});

it('should not sync time by default', async () => {
Expand All @@ -235,14 +238,15 @@ describe('syncTime', () => {
res.status(200).send(readStateResponse);
});

await HttpAgent.create({
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: keyPair.publicKeyDer,
shouldSyncTime: false,
identity: anonIdentity,
});

expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0);
expect(agent.hasSyncedTime()).toBe(false);
});

it('should not sync time when explicitly disabled', async () => {
Expand All @@ -253,14 +257,15 @@ describe('syncTime', () => {
res.status(200).send(readStateResponse);
});

await HttpAgent.create({
const agent = await HttpAgent.create({
host: mockReplica.address,
rootKey: keyPair.publicKeyDer,
shouldSyncTime: false,
identity: anonIdentity,
});

expect(mockReplica.getV2ReadStateSpy(ICP_LEDGER)).toHaveBeenCalledTimes(0);
expect(agent.hasSyncedTime()).toBe(false);
});
});

Expand Down Expand Up @@ -340,6 +345,7 @@ describe('syncTime', () => {
},
'V3 read state body three',
);
expect(agent.hasSyncedTime()).toBe(true);
});

it('should not sync time by default', async () => {
Expand Down Expand Up @@ -389,6 +395,7 @@ describe('syncTime', () => {
);

expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0);
expect(agent.hasSyncedTime()).toBe(false);
});

it('should not sync time when explicitly disabled', async () => {
Expand Down Expand Up @@ -440,6 +447,7 @@ describe('syncTime', () => {
);

expect(mockReplica.getV2ReadStateSpy(canisterId.toString())).toHaveBeenCalledTimes(0);
expect(agent.hasSyncedTime()).toBe(false);
});
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/actor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ function _createActorMethod(
rootKey: agent.rootKey,
canisterId: Principal.from(canisterId),
blsVerify,
agent,
});
const path = [utf8ToBytes('request_status'), requestId];
const status = new TextDecoder().decode(
Expand Down
62 changes: 50 additions & 12 deletions packages/agent/src/agent/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ export enum RequestStatusResponseStatus {
const MINUTE_TO_MSECS = 60 * 1_000;
const MSECS_TO_NANOSECONDS = 1_000_000;

const DEFAULT_TIME_DIFF_MSECS = 0;

// Root public key for the IC, encoded as hex
export const IC_ROOT_KEY =
'308182301d060d2b0601040182dc7c0503010201060c2b0601040182dc7c05030201036100814' +
Expand Down Expand Up @@ -181,6 +183,15 @@ export interface HttpAgentOptions {
* Whether or not to sync the time with the network during construction. Defaults to false.
*/
shouldSyncTime?: boolean;

/**
* The time difference in milliseconds: IC network time - local time.
* This is used to adjust the current time when verifying the certificate freshness.
* If a value different from `0` or `undefined` is provided, the {@link HttpAgent.syncTime} method will not be called during construction,
* even if {@link HttpAgentOptions.shouldSyncTime} is set to `true`.
* @default 0
*/
timeDiffMsecs?: number;
}

function getDefaultFetch(): typeof fetch {
Expand Down Expand Up @@ -286,7 +297,8 @@ export class HttpAgent implements Agent {
#rootKeyPromise: Promise<Uint8Array> | null = null;
readonly #shouldFetchRootKey: boolean = false;

#timeDiffMsecs = 0;
#timeDiffMsecs = DEFAULT_TIME_DIFF_MSECS;
#hasSyncedTime = false;
#syncTimePromise: Promise<void> | null = null;
readonly #shouldSyncTime: boolean = false;

Expand All @@ -313,7 +325,7 @@ export class HttpAgent implements Agent {
#updatePipeline: HttpAgentRequestTransformFn[] = [];

#subnetKeys: ExpirableMap<string, SubnetStatus> = new ExpirableMap({
expirationTime: 5 * 60 * 1000, // 5 minutes
expirationTime: 5 * MINUTE_TO_MSECS,
});
#verifyQuerySignatures = true;

Expand All @@ -328,6 +340,7 @@ export class HttpAgent implements Agent {
this.#callOptions = options.callOptions;
this.#shouldFetchRootKey = options.shouldFetchRootKey ?? false;
this.#shouldSyncTime = options.shouldSyncTime ?? false;
this.#timeDiffMsecs = options.timeDiffMsecs ?? DEFAULT_TIME_DIFF_MSECS;

// Use provided root key, otherwise fall back to IC_ROOT_KEY for mainnet or null if the key needs to be fetched
if (options.rootKey) {
Expand Down Expand Up @@ -949,7 +962,13 @@ export class HttpAgent implements Agent {
// Attempt to make the query i=retryTimes times
// Make query and fetch subnet keys in parallel
try {
const [queryResult, subnetStatus] = await Promise.all([makeQuery(), getSubnetStatus()]);
const [queryResult, subnetStatus] = await Promise.all([
makeQuery(),
getSubnetStatus().catch(err => {
this.log.warn('Failed to fetch subnet keys. Error:', err);
return undefined;
}),
]);
const { requestDetails, query } = queryResult;

const queryWithDetails = {
Expand All @@ -968,10 +987,8 @@ export class HttpAgent implements Agent {
} catch {
// 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(ecid.toString());

const updatedSubnetStatus = this.#subnetKeys.get(canisterId.toString());
this.#subnetKeys.delete(ecid.toString());
const updatedSubnetStatus = await getSubnetStatus();
if (!updatedSubnetStatus) {
throw TrustError.fromCode(new MissingSignatureErrorCode());
}
Expand Down Expand Up @@ -1208,8 +1225,8 @@ export class HttpAgent implements Agent {
}
const date = decodeTime(timeLookup.value);
this.log.print('Time from response:', date);
this.log.print('Time from response in milliseconds:', Number(date));
return Number(date);
this.log.print('Time from response in milliseconds:', date.getTime());
return date.getTime();
} else {
this.log.warn('No certificate found in response');
}
Expand Down Expand Up @@ -1241,6 +1258,8 @@ export class HttpAgent implements Agent {
fetch: this.#fetch,
retryTimes: 0,
rootKey: this.rootKey ?? undefined,
shouldSyncTime: false,
timeDiffMsecs: this.#timeDiffMsecs,
});

const replicaTimes = await Promise.all(
Expand All @@ -1264,8 +1283,9 @@ export class HttpAgent implements Agent {
return typeof current === 'number' && current > max ? current : max;
}, 0);

if (maxReplicaTime > BigInt(0)) {
this.#timeDiffMsecs = Number(maxReplicaTime) - Number(callTime);
if (maxReplicaTime > 0) {
this.#timeDiffMsecs = maxReplicaTime - callTime;
this.#hasSyncedTime = true;
this.log.notify({
message: `Syncing time: offset of ${this.#timeDiffMsecs}`,
level: 'info',
Expand Down Expand Up @@ -1341,7 +1361,7 @@ export class HttpAgent implements Agent {
}

async #syncTimeGuard(canisterIdOverride?: Principal): Promise<void> {
if (this.#shouldSyncTime && this.#timeDiffMsecs === 0) {
if (this.#shouldSyncTime && !this.hasSyncedTime()) {
await this.syncTime(canisterIdOverride);
}
}
Expand Down Expand Up @@ -1386,6 +1406,24 @@ export class HttpAgent implements Agent {

return p;
}

/**
* Returns the time difference in milliseconds between the client's clock and the IC network clock,
* after the clock has been synced using the {@link HttpAgent.syncTime} method
* or during agent creation if {@link HttpAgentOptions.shouldSyncTime} was set to `true`.
*
* If the time has not been synced, returns `0`.
*/
public getTimeDiffMsecs(): number {
return this.#timeDiffMsecs;
}

/**
* Returns `true` if the time has been synced at least once with the IC network, `false` otherwise.
*/
public hasSyncedTime(): boolean {
return this.#hasSyncedTime;
}
}

/**
Expand Down
Loading
Loading