Skip to content

Commit feb5082

Browse files
fix(frontend): Single canister instances for each identity (#12151)
# Motivation The `AgentManager` from `@dfinity/utils` caches `HttpAgent` instances by principal text, and all the API modules cache canister instances at module scope. When the identity rotates within the same session (via `forceSync`, `BroadcastChannel` login sync, or `signIn`), these caches return agents/canisters that still hold the previous identity. # Credits Suggestion by @peterpeterparker 's in #12146 (comment) : the code is inspired by [Juno codebase](https://github.com/junobuild/juno/blob/main/src/frontend/src/lib/api/actors/actor.api.ts#L20). # Changes - Create `CanisterApi` class that manages each instance of the API classes per-identity, upserting it. - Use it in all the API modules when calling the creation method. # Tests Added unittests. The other tests should work as usual.
1 parent 9cd6290 commit feb5082

File tree

8 files changed

+187
-66
lines changed

8 files changed

+187
-66
lines changed

src/frontend/src/lib/api/backend.api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type {
77
TokenId,
88
UserProfile
99
} from '$declarations/backend/backend.did';
10+
import { CanisterApi } from '$lib/api/canister.api';
1011
import { BackendCanister } from '$lib/canisters/backend.canister';
1112
import { BACKEND_CANISTER_ID } from '$lib/constants/app.constants';
1213
import type {
@@ -36,10 +37,9 @@ import type {
3637
} from '$lib/types/api';
3738
import type { CanisterApiFunctionParams } from '$lib/types/canister';
3839
import type { BackendExchangeRate } from '$lib/types/exchange';
39-
import { assertNonNullish, isNullish, type QueryParams } from '@dfinity/utils';
40-
import { Principal } from '@icp-sdk/core/principal';
40+
import { assertNonNullish, type QueryParams } from '@dfinity/utils';
4141

42-
let canister: BackendCanister | undefined = undefined;
42+
const backendApi = new CanisterApi<BackendCanister>();
4343

4444
export const listCustomTokens = async ({
4545
identity
@@ -292,12 +292,9 @@ const backendCanister = async ({
292292
}: CanisterApiFunctionParams): Promise<BackendCanister> => {
293293
assertNonNullish(identity, nullishIdentityErrorMessage);
294294

295-
if (isNullish(canister)) {
296-
canister = await BackendCanister.create({
297-
identity,
298-
canisterId: Principal.fromText(canisterId)
299-
});
300-
}
301-
302-
return canister;
295+
return await backendApi.getCanister({
296+
identity,
297+
canisterId,
298+
create: BackendCanister.create
299+
});
303300
};
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { CanisterIdText } from '$lib/types/canister';
2+
import { nonNullish, type Canister } from '@dfinity/utils';
3+
import type { PrincipalText } from '@dfinity/zod-schemas';
4+
import type { Identity } from '@icp-sdk/core/agent';
5+
import { Principal } from '@icp-sdk/core/principal';
6+
7+
/**
8+
* Generic cache for canister instances keyed by the caller's principal.
9+
*
10+
* Each identity gets its own canister instance, so switching identity
11+
* automatically uses a fresh canister without any explicit reset.
12+
*/
13+
export class CanisterApi<T extends Canister<S>, S extends object = {}> {
14+
readonly #instances = new Map<PrincipalText, Promise<T>>();
15+
16+
getCanister = ({
17+
identity,
18+
canisterId,
19+
create
20+
}: {
21+
identity: Identity;
22+
canisterId: CanisterIdText;
23+
create: (options: { identity: Identity; canisterId: Principal }) => Promise<T>;
24+
}): Promise<T> => {
25+
const principal: PrincipalText = identity.getPrincipal().toText();
26+
27+
const existing = this.#instances.get(principal);
28+
29+
if (nonNullish(existing)) {
30+
return existing;
31+
}
32+
33+
const promise = create({ identity, canisterId: Principal.fromText(canisterId) });
34+
35+
this.#instances.set(principal, promise);
36+
37+
return promise;
38+
};
39+
}

src/frontend/src/lib/api/icp-swap-factory.api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { PoolData } from '$declarations/icp_swap_factory/icp_swap_factory.did';
2+
import { CanisterApi } from '$lib/api/canister.api';
23
import { ICPSwapFactoryCanister } from '$lib/canisters/icp-swap-factory.canister';
34
import { ICP_SWAP_FACTORY_CANISTER_ID } from '$lib/constants/app.constants';
45
import type { ICPSwapGetPoolParams } from '$lib/types/api';
56
import type { CanisterApiFunctionParams } from '$lib/types/canister';
6-
import { assertNonNullish, isNullish } from '@dfinity/utils';
7-
import { Principal } from '@icp-sdk/core/principal';
7+
import { assertNonNullish } from '@dfinity/utils';
88

9-
let canister: ICPSwapFactoryCanister | undefined = undefined;
9+
const icpSwapApi = new CanisterApi<ICPSwapFactoryCanister>();
1010

1111
export const getPoolCanister = async ({
1212
identity,
@@ -30,12 +30,9 @@ const icpSwapFactoryCanister = async ({
3030
}: CanisterApiFunctionParams): Promise<ICPSwapFactoryCanister> => {
3131
assertNonNullish(identity, nullishIdentityErrorMessage);
3232

33-
if (isNullish(canister)) {
34-
canister = await ICPSwapFactoryCanister.create({
35-
identity,
36-
canisterId: Principal.fromText(canisterId)
37-
});
38-
}
39-
40-
return canister;
33+
return await icpSwapApi.getCanister({
34+
identity,
35+
canisterId,
36+
create: ICPSwapFactoryCanister.create
37+
});
4138
};

src/frontend/src/lib/api/kong_backend.api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { SwapAmountsReply, TokenReply } from '$declarations/kong_backend/kong_backend.did';
2+
import { CanisterApi } from '$lib/api/canister.api';
23
import { KongBackendCanister } from '$lib/canisters/kong_backend.canister';
34
import { KONG_BACKEND_CANISTER_ID } from '$lib/constants/app.constants';
45
import type { KongSwapAmountsParams, KongSwapParams } from '$lib/types/api';
56
import type { CanisterApiFunctionParams } from '$lib/types/canister';
6-
import { assertNonNullish, isNullish } from '@dfinity/utils';
7-
import { Principal } from '@icp-sdk/core/principal';
7+
import { assertNonNullish } from '@dfinity/utils';
88

9-
let canister: KongBackendCanister | undefined = undefined;
9+
const kongApi = new CanisterApi<KongBackendCanister>();
1010

1111
export const kongSwapAmounts = async ({
1212
identity,
@@ -56,12 +56,9 @@ const kongBackendCanister = async ({
5656
}: CanisterApiFunctionParams): Promise<KongBackendCanister> => {
5757
assertNonNullish(identity, nullishIdentityErrorMessage);
5858

59-
if (isNullish(canister)) {
60-
canister = await KongBackendCanister.create({
61-
identity,
62-
canisterId: Principal.fromText(canisterId)
63-
});
64-
}
65-
66-
return canister;
59+
return await kongApi.getCanister({
60+
identity,
61+
canisterId,
62+
create: KongBackendCanister.create
63+
});
6764
};

src/frontend/src/lib/api/llm.api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { chat_request_v1, chat_response_v1 } from '$declarations/llm/llm.did';
2+
import { CanisterApi } from '$lib/api/canister.api';
23
import { LlmCanister } from '$lib/canisters/llm.canister';
34
import { LLM_CANISTER_ID } from '$lib/constants/app.constants';
45
import type { CanisterApiFunctionParams } from '$lib/types/canister';
5-
import { assertNonNullish, isNullish } from '@dfinity/utils';
6-
import { Principal } from '@icp-sdk/core/principal';
6+
import { assertNonNullish } from '@dfinity/utils';
77

8-
let canister: LlmCanister | undefined = undefined;
8+
const llmApi = new CanisterApi<LlmCanister>();
99

1010
export const llmChat = async ({
1111
request,
@@ -25,12 +25,9 @@ const llmCanister = async ({
2525
}: CanisterApiFunctionParams): Promise<LlmCanister> => {
2626
assertNonNullish(identity, nullishIdentityErrorMessage);
2727

28-
if (isNullish(canister)) {
29-
canister = await LlmCanister.create({
30-
identity,
31-
canisterId: Principal.fromText(canisterId)
32-
});
33-
}
34-
35-
return canister;
28+
return await llmApi.getCanister({
29+
identity,
30+
canisterId,
31+
create: LlmCanister.create
32+
});
3633
};

src/frontend/src/lib/api/reward.api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ import type {
88
UserSnapshot,
99
VipReward
1010
} from '$declarations/rewards/rewards.did';
11+
import { CanisterApi } from '$lib/api/canister.api';
1112
import { RewardCanister } from '$lib/canisters/reward.canister';
1213
import { REWARDS_CANISTER_ID } from '$lib/constants/app.constants';
1314
import type { CanisterApiFunctionParams } from '$lib/types/canister';
1415
import type { RewardClaimApiResponse } from '$lib/types/reward';
15-
import { assertNonNullish, isNullish, type QueryParams } from '@dfinity/utils';
16-
import { Principal } from '@icp-sdk/core/principal';
16+
import { assertNonNullish, type QueryParams } from '@dfinity/utils';
1717

18-
let canister: RewardCanister | undefined = undefined;
18+
const rewardApi = new CanisterApi<RewardCanister>();
1919

2020
export const isEligible = async ({
2121
identity,
@@ -95,12 +95,9 @@ const rewardCanister = async ({
9595
}: CanisterApiFunctionParams): Promise<RewardCanister> => {
9696
assertNonNullish(identity, nullishIdentityErrorMessage);
9797

98-
if (isNullish(canister)) {
99-
canister = await RewardCanister.create({
100-
identity,
101-
canisterId: Principal.fromText(canisterId)
102-
});
103-
}
104-
105-
return canister;
98+
return await rewardApi.getCanister({
99+
identity,
100+
canisterId,
101+
create: RewardCanister.create
102+
});
106103
};

src/frontend/src/lib/api/signer.api.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
SignBtcResponse
77
} from '$declarations/signer/signer.did';
88
import type { EthAddress } from '$eth/types/address';
9+
import { CanisterApi } from '$lib/api/canister.api';
910
import { SignerCanister } from '$lib/canisters/signer.canister';
1011
import { SIGNER_CANISTER_ID } from '$lib/constants/app.constants';
1112
import type {
@@ -14,10 +15,9 @@ import type {
1415
SignWithSchnorrParams
1516
} from '$lib/types/api';
1617
import type { CanisterApiFunctionParams } from '$lib/types/canister';
17-
import { assertNonNullish, isNullish } from '@dfinity/utils';
18-
import { Principal } from '@icp-sdk/core/principal';
18+
import { assertNonNullish } from '@dfinity/utils';
1919

20-
let canister: SignerCanister | undefined = undefined;
20+
const signerApi = new CanisterApi<SignerCanister>();
2121

2222
export const getBtcAddress = async ({
2323
identity,
@@ -126,12 +126,9 @@ const signerCanister = async ({
126126
}: CanisterApiFunctionParams): Promise<SignerCanister> => {
127127
assertNonNullish(identity, nullishIdentityErrorMessage);
128128

129-
if (isNullish(canister)) {
130-
canister = await SignerCanister.create({
131-
identity,
132-
canisterId: Principal.fromText(canisterId)
133-
});
134-
}
135-
136-
return canister;
129+
return await signerApi.getCanister({
130+
identity,
131+
canisterId,
132+
create: SignerCanister.create
133+
});
137134
};
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { CanisterApi } from '$lib/api/canister.api';
2+
import type { Canister } from '@dfinity/utils';
3+
import { Ed25519KeyIdentity } from '@icp-sdk/core/identity';
4+
import { Principal } from '@icp-sdk/core/principal';
5+
6+
describe('canister.api', () => {
7+
const identityA = Ed25519KeyIdentity.generate();
8+
const identityB = Ed25519KeyIdentity.generate();
9+
const mockCanisterId = 'aaaaa-aa';
10+
const mockCanisterPrincipal = Principal.fromText(mockCanisterId);
11+
12+
interface CanisterType {
13+
id: string;
14+
}
15+
16+
let api: CanisterApi<Canister<CanisterType>, CanisterType>;
17+
18+
beforeEach(() => {
19+
api = new CanisterApi();
20+
});
21+
22+
it('should create a new canister instance on first call', async () => {
23+
const create = vi.fn().mockResolvedValue({ id: 'canister-a' });
24+
25+
const result = await api.getCanister({
26+
identity: identityA,
27+
canisterId: mockCanisterId,
28+
create
29+
});
30+
31+
expect(result).toEqual({ id: 'canister-a' });
32+
expect(create).toHaveBeenCalledExactlyOnceWith({
33+
identity: identityA,
34+
canisterId: mockCanisterPrincipal
35+
});
36+
});
37+
38+
it('should return cached instance for the same principal', async () => {
39+
const create = vi.fn().mockResolvedValue({ id: 'canister-a' });
40+
41+
const first = await api.getCanister({
42+
identity: identityA,
43+
canisterId: mockCanisterId,
44+
create
45+
});
46+
const second = await api.getCanister({
47+
identity: identityA,
48+
canisterId: mockCanisterId,
49+
create
50+
});
51+
52+
expect(first).toBe(second);
53+
expect(create).toHaveBeenCalledOnce();
54+
});
55+
56+
it('should create separate instances for different principals', async () => {
57+
const createA = vi.fn().mockResolvedValue({ id: 'canister-a' });
58+
const createB = vi.fn().mockResolvedValue({ id: 'canister-b' });
59+
60+
const resultA = await api.getCanister({
61+
identity: identityA,
62+
canisterId: mockCanisterId,
63+
create: createA
64+
});
65+
const resultB = await api.getCanister({
66+
identity: identityB,
67+
canisterId: mockCanisterId,
68+
create: createB
69+
});
70+
71+
expect(resultA).toEqual({ id: 'canister-a' });
72+
expect(resultB).toEqual({ id: 'canister-b' });
73+
expect(createA).toHaveBeenCalledOnce();
74+
expect(createB).toHaveBeenCalledOnce();
75+
});
76+
77+
it('should not call create again after caching for a given principal', async () => {
78+
const create = vi.fn().mockResolvedValue({ id: 'canister-a' });
79+
80+
await api.getCanister({ identity: identityA, canisterId: mockCanisterId, create });
81+
await api.getCanister({ identity: identityA, canisterId: mockCanisterId, create });
82+
await api.getCanister({ identity: identityA, canisterId: mockCanisterId, create });
83+
84+
expect(create).toHaveBeenCalledOnce();
85+
});
86+
87+
it('should call create only once for concurrent calls with the same principal', async () => {
88+
const create = vi.fn().mockResolvedValue({ id: 'canister-a' });
89+
90+
const results = await Promise.all([
91+
api.getCanister({ identity: identityA, canisterId: mockCanisterId, create }),
92+
api.getCanister({ identity: identityA, canisterId: mockCanisterId, create }),
93+
api.getCanister({ identity: identityA, canisterId: mockCanisterId, create })
94+
]);
95+
96+
expect(create).toHaveBeenCalledOnce();
97+
expect(results[1]).toBe(results[0]);
98+
expect(results[2]).toBe(results[0]);
99+
});
100+
});

0 commit comments

Comments
 (0)