@ledgerhq/coin-canton 0.12.0-nightly.20251211024123 → 0.12.0-nightly.20251213023821

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/CHANGELOG.md +15 -9
  2. package/lib/bridge/buildSubAccounts.d.ts +23 -0
  3. package/lib/bridge/buildSubAccounts.d.ts.map +1 -0
  4. package/lib/bridge/buildSubAccounts.js +80 -0
  5. package/lib/bridge/buildSubAccounts.js.map +1 -0
  6. package/lib/bridge/getTransactionStatus.d.ts.map +1 -1
  7. package/lib/bridge/getTransactionStatus.js +45 -24
  8. package/lib/bridge/getTransactionStatus.js.map +1 -1
  9. package/lib/bridge/index.d.ts.map +1 -1
  10. package/lib/bridge/index.js +2 -0
  11. package/lib/bridge/index.js.map +1 -1
  12. package/lib/bridge/prepareTransaction.d.ts.map +1 -1
  13. package/lib/bridge/prepareTransaction.js +19 -3
  14. package/lib/bridge/prepareTransaction.js.map +1 -1
  15. package/lib/bridge/signOperation.d.ts.map +1 -1
  16. package/lib/bridge/signOperation.js +3 -0
  17. package/lib/bridge/signOperation.js.map +1 -1
  18. package/lib/bridge/sync.d.ts +3 -0
  19. package/lib/bridge/sync.d.ts.map +1 -1
  20. package/lib/bridge/sync.js +115 -19
  21. package/lib/bridge/sync.js.map +1 -1
  22. package/lib/bridge/validateAddress.d.ts +3 -0
  23. package/lib/bridge/validateAddress.d.ts.map +1 -0
  24. package/lib/bridge/validateAddress.js +8 -0
  25. package/lib/bridge/validateAddress.js.map +1 -0
  26. package/lib/common-logic/account/getBalance.d.ts +1 -0
  27. package/lib/common-logic/account/getBalance.d.ts.map +1 -1
  28. package/lib/common-logic/account/getBalance.js +1 -0
  29. package/lib/common-logic/account/getBalance.js.map +1 -1
  30. package/lib/common-logic/transaction/craftTransaction.d.ts +1 -0
  31. package/lib/common-logic/transaction/craftTransaction.d.ts.map +1 -1
  32. package/lib/common-logic/transaction/craftTransaction.js +3 -0
  33. package/lib/common-logic/transaction/craftTransaction.js.map +1 -1
  34. package/lib/network/gateway.d.ts +30 -12
  35. package/lib/network/gateway.d.ts.map +1 -1
  36. package/lib/network/gateway.js +44 -1
  37. package/lib/network/gateway.js.map +1 -1
  38. package/lib/types/bridge.d.ts +2 -0
  39. package/lib/types/bridge.d.ts.map +1 -1
  40. package/lib-es/bridge/buildSubAccounts.d.ts +23 -0
  41. package/lib-es/bridge/buildSubAccounts.d.ts.map +1 -0
  42. package/lib-es/bridge/buildSubAccounts.js +74 -0
  43. package/lib-es/bridge/buildSubAccounts.js.map +1 -0
  44. package/lib-es/bridge/getTransactionStatus.d.ts.map +1 -1
  45. package/lib-es/bridge/getTransactionStatus.js +46 -25
  46. package/lib-es/bridge/getTransactionStatus.js.map +1 -1
  47. package/lib-es/bridge/index.d.ts.map +1 -1
  48. package/lib-es/bridge/index.js +2 -0
  49. package/lib-es/bridge/index.js.map +1 -1
  50. package/lib-es/bridge/prepareTransaction.d.ts.map +1 -1
  51. package/lib-es/bridge/prepareTransaction.js +19 -3
  52. package/lib-es/bridge/prepareTransaction.js.map +1 -1
  53. package/lib-es/bridge/signOperation.d.ts.map +1 -1
  54. package/lib-es/bridge/signOperation.js +3 -0
  55. package/lib-es/bridge/signOperation.js.map +1 -1
  56. package/lib-es/bridge/sync.d.ts +3 -0
  57. package/lib-es/bridge/sync.d.ts.map +1 -1
  58. package/lib-es/bridge/sync.js +115 -20
  59. package/lib-es/bridge/sync.js.map +1 -1
  60. package/lib-es/bridge/validateAddress.d.ts +3 -0
  61. package/lib-es/bridge/validateAddress.d.ts.map +1 -0
  62. package/lib-es/bridge/validateAddress.js +5 -0
  63. package/lib-es/bridge/validateAddress.js.map +1 -0
  64. package/lib-es/common-logic/account/getBalance.d.ts +1 -0
  65. package/lib-es/common-logic/account/getBalance.d.ts.map +1 -1
  66. package/lib-es/common-logic/account/getBalance.js +1 -0
  67. package/lib-es/common-logic/account/getBalance.js.map +1 -1
  68. package/lib-es/common-logic/transaction/craftTransaction.d.ts +1 -0
  69. package/lib-es/common-logic/transaction/craftTransaction.d.ts.map +1 -1
  70. package/lib-es/common-logic/transaction/craftTransaction.js +3 -0
  71. package/lib-es/common-logic/transaction/craftTransaction.js.map +1 -1
  72. package/lib-es/network/gateway.d.ts +30 -12
  73. package/lib-es/network/gateway.d.ts.map +1 -1
  74. package/lib-es/network/gateway.js +41 -0
  75. package/lib-es/network/gateway.js.map +1 -1
  76. package/lib-es/types/bridge.d.ts +2 -0
  77. package/lib-es/types/bridge.d.ts.map +1 -1
  78. package/package.json +9 -9
  79. package/src/bridge/buildSubAccounts.test.ts +120 -0
  80. package/src/bridge/buildSubAccounts.ts +132 -0
  81. package/src/bridge/getTransactionStatus.ts +53 -22
  82. package/src/bridge/index.ts +2 -0
  83. package/src/bridge/prepareTransaction.ts +29 -4
  84. package/src/bridge/signOperation.ts +4 -0
  85. package/src/bridge/sync.test.ts +237 -191
  86. package/src/bridge/sync.ts +154 -24
  87. package/src/bridge/validateAddress.test.ts +25 -0
  88. package/src/bridge/validateAddress.ts +9 -0
  89. package/src/common-logic/account/getBalance.ts +2 -0
  90. package/src/common-logic/transaction/craftTransaction.ts +5 -0
  91. package/src/network/gateway.test.ts +169 -0
  92. package/src/network/gateway.ts +83 -12
  93. package/src/types/bridge.ts +2 -0
  94. package/tsconfig.json +7 -2
@@ -1,5 +1,6 @@
1
1
  import BigNumber from "bignumber.js";
2
- import { Operation, OperationType } from "@ledgerhq/types-live";
2
+ import { Operation, OperationType, TokenAccount } from "@ledgerhq/types-live";
3
+ import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets";
3
4
  import { encodeAccountId } from "@ledgerhq/coin-framework/account/index";
4
5
  import { GetAccountShape, mergeOps } from "@ledgerhq/coin-framework/bridge/jsHelpers";
5
6
  import { encodeOperationId } from "@ledgerhq/coin-framework/operation";
@@ -9,18 +10,27 @@ import {
9
10
  getOperations,
10
11
  type OperationInfo,
11
12
  getPendingTransferProposals,
13
+ getEnabledInstrumentsCached,
14
+ getCalTokensCached,
12
15
  } from "../network/gateway";
13
- import { getBalance, type CantonBalance } from "../common-logic/account/getBalance";
16
+ import { getBalance } from "../common-logic/account/getBalance";
14
17
  import coinConfig from "../config";
15
18
  import resolver from "../signer";
16
19
  import { CantonAccount, CantonResources, CantonSigner } from "../types";
17
20
  import { isAccountOnboarded } from "./onboard";
18
21
  import { isCantonAccountEmpty } from "../helpers";
22
+ import { getCryptoAssetsStore } from "@ledgerhq/cryptoassets/state";
23
+ import { buildSubAccounts } from "./buildSubAccounts";
24
+
25
+ const SEPARATOR = "____";
26
+
27
+ const getKey = (id: string, adminId: string) => `${id}${SEPARATOR}${adminId}`;
19
28
 
20
29
  const txInfoToOperationAdapter =
21
30
  (accountId: string, partyId: string) =>
22
31
  (txInfo: OperationInfo): Operation => {
23
32
  const {
33
+ asset: { instrumentId, instrumentAdmin },
24
34
  transaction_hash,
25
35
  uid,
26
36
  block: { height, hash },
@@ -73,6 +83,8 @@ const txInfoToOperationAdapter =
73
83
  extra: {
74
84
  uid,
75
85
  memo,
86
+ instrumentId,
87
+ instrumentAdmin,
76
88
  },
77
89
  };
78
90
 
@@ -87,6 +99,35 @@ const filterOperations = (
87
99
  return transactions.map(txInfoToOperationAdapter(accountId, partyId));
88
100
  };
89
101
 
102
+ export async function filterDisabledTokenAccounts(
103
+ currency: CryptoCurrency,
104
+ subAccounts: TokenAccount[] | undefined,
105
+ ): Promise<TokenAccount[]> {
106
+ if (!subAccounts || subAccounts.length === 0) {
107
+ return [];
108
+ }
109
+
110
+ let enabledInstruments: Set<string>;
111
+
112
+ try {
113
+ const enabledList = await getEnabledInstrumentsCached(currency);
114
+ enabledInstruments = new Set(enabledList);
115
+ } catch (error) {
116
+ // If API fails, hide all token accounts
117
+ return [];
118
+ }
119
+
120
+ return subAccounts.filter(subAccount => {
121
+ const instrumentId = subAccount.token.contractAddress;
122
+
123
+ if (!instrumentId) {
124
+ return false;
125
+ }
126
+
127
+ return enabledInstruments.has(instrumentId);
128
+ });
129
+ }
130
+
90
131
  export function makeGetAccountShape(
91
132
  signerContext: SignerContext<CantonSigner>,
92
133
  ): GetAccountShape<CantonAccount> {
@@ -129,31 +170,96 @@ export function makeGetAccountShape(
129
170
  ? await getPendingTransferProposals(currency, xpubOrAddress)
130
171
  : [];
131
172
 
132
- const balancesData = (balances || []).reduce(
133
- (acc, balance) => {
173
+ // Aggregate all balances by instrument (unlocked + locked)
174
+ const aggregatedBalances = new Map<
175
+ string,
176
+ {
177
+ unlockedBalance: bigint;
178
+ lockedBalance: bigint;
179
+ utxoCount: number;
180
+ token: TokenCurrency | null;
181
+ adminId: string;
182
+ }
183
+ >();
184
+
185
+ const proposalInstrumentKeys = new Set(
186
+ pendingTransferProposals.map(proposal =>
187
+ getKey(proposal.instrument_id, proposal.instrument_admin),
188
+ ),
189
+ );
190
+ for (const key of proposalInstrumentKeys) {
191
+ if (aggregatedBalances.has(key)) continue;
192
+
193
+ const [instrumentId, adminId] = key.split(SEPARATOR);
194
+ balances.push({
195
+ value: 0n,
196
+ locked: 0n,
197
+ utxoCount: 0,
198
+ instrumentId,
199
+ adminId,
200
+ asset: { type: "token", assetReference: instrumentId },
201
+ });
202
+ }
203
+
204
+ const tokensByKey = new Map<string, TokenCurrency>();
205
+ for await (const balance of balances) {
206
+ const token = await getCryptoAssetsStore().findTokenByAddressInCurrency(
207
+ balance.adminId,
208
+ currency.id,
209
+ );
210
+ if (!token) continue;
211
+ tokensByKey.set(getKey(balance.instrumentId, balance.adminId), token);
212
+ }
213
+
214
+ for await (const balance of balances) {
215
+ const isNative = balance.instrumentId === nativeInstrumentId;
216
+ // Use just instrumentId for native (no admin), composite key for tokens
217
+ const balanceKey = isNative
218
+ ? nativeInstrumentId
219
+ : getKey(balance.instrumentId, balance.adminId);
220
+ const token: TokenCurrency | null = isNative ? null : tokensByKey.get(balanceKey) ?? null;
221
+
222
+ const existing = aggregatedBalances.get(balanceKey);
223
+ if (existing) {
134
224
  if (balance.locked) {
135
- acc[`Locked${balance.instrumentId}`] = balance;
225
+ existing.lockedBalance += balance.value;
136
226
  } else {
137
- acc[balance.instrumentId] = balance;
227
+ existing.unlockedBalance += balance.value;
138
228
  }
139
- return acc;
140
- },
141
- {} as Record<string, CantonBalance>,
142
- );
229
+ existing.utxoCount += balance.utxoCount;
230
+ } else {
231
+ aggregatedBalances.set(balanceKey, {
232
+ unlockedBalance: balance.locked ? 0n : balance.value,
233
+ lockedBalance: balance.locked ? balance.value : 0n,
234
+ utxoCount: balance.utxoCount,
235
+ token,
236
+ adminId: balance.adminId,
237
+ });
238
+ }
239
+ }
143
240
 
144
- const unlockedAmount = new BigNumber(balancesData[nativeInstrumentId]?.value.toString() || "0");
145
- const lockedAmount = new BigNumber(
146
- balancesData[`Locked${nativeInstrumentId}`]?.value.toString() || "0",
147
- );
241
+ // Find native balance (token is null for native)
242
+ const nativeBalance = Array.from(aggregatedBalances.values()).find(data => data.token === null);
243
+ const unlockedAmount = new BigNumber((nativeBalance?.unlockedBalance ?? 0n).toString());
244
+ const lockedAmount = new BigNumber((nativeBalance?.lockedBalance ?? 0n).toString());
148
245
  const totalBalance = unlockedAmount.plus(lockedAmount);
149
246
  const reserveMin = new BigNumber(coinConfig.getCoinConfig(currency).minReserve || 0);
150
- const spendableBalance = BigNumber.max(0, totalBalance.minus(reserveMin));
247
+ const spendableBalance = BigNumber.max(0, unlockedAmount.minus(reserveMin));
151
248
 
152
249
  const instrumentUtxoCounts: Record<string, number> = {};
153
- for (const [instrumentId, balance] of Object.entries(balancesData)) {
154
- instrumentUtxoCounts[instrumentId] = balance.utxoCount;
250
+ for (const [key, data] of aggregatedBalances) {
251
+ instrumentUtxoCounts[key] = data.utxoCount;
155
252
  }
156
253
 
254
+ const tokenBalances = Array.from(aggregatedBalances.entries())
255
+ .filter(([, data]) => data.token !== null)
256
+ .map(([, { unlockedBalance, lockedBalance, token, adminId }]) => ({
257
+ totalBalance: unlockedBalance + lockedBalance,
258
+ spendableBalance: unlockedBalance,
259
+ token: token!,
260
+ adminId,
261
+ }));
262
+
157
263
  let operations: Operation[] = [];
158
264
  if (xpubOrAddress) {
159
265
  const oldOperations = initialAccount?.operations || [];
@@ -166,26 +272,49 @@ export function makeGetAccountShape(
166
272
  operations = mergeOps(oldOperations, newOperations);
167
273
  }
168
274
 
275
+ // Filter main account operations (native instrument only)
276
+ const mainAccountOperations = operations.filter(op => {
277
+ const extra = op.extra as { instrumentId?: string };
278
+ return extra?.instrumentId === nativeInstrumentId;
279
+ });
280
+
281
+ // Build sub-accounts for tokens with their filtered operations
282
+
283
+ const calTokens = await getCalTokensCached(currency);
284
+
285
+ const subAccounts = buildSubAccounts({
286
+ accountId,
287
+ tokenBalances,
288
+ existingSubAccounts: initialAccount?.subAccounts ?? [],
289
+ allOperations: operations,
290
+ pendingTransferProposals,
291
+ calTokens,
292
+ });
293
+
169
294
  const cantonResources: CantonResources = {
170
295
  isOnboarded,
171
296
  instrumentUtxoCounts,
172
- pendingTransferProposals,
297
+ pendingTransferProposals: pendingTransferProposals.filter(
298
+ proposal => proposal.instrument_id === nativeInstrumentId,
299
+ ),
173
300
  ...(publicKey && { publicKey }),
174
301
  xpub: xpubOrAddress,
175
302
  };
176
303
 
304
+ const filteredSubAccounts = await filterDisabledTokenAccounts(currency, subAccounts);
305
+
177
306
  const used = !isCantonAccountEmpty({
178
- operationsCount: operations.length,
307
+ operationsCount: mainAccountOperations.length,
179
308
  balance: totalBalance,
180
- subAccounts: initialAccount?.subAccounts ?? [],
309
+ subAccounts: filteredSubAccounts,
181
310
  cantonResources,
182
311
  });
183
312
 
184
313
  const blockHeight = await getLedgerEnd(currency);
185
314
 
186
315
  const creationDate =
187
- operations.length > 0
188
- ? new Date(Math.min(...operations.map(op => op.date.getTime())))
316
+ mainAccountOperations.length > 0
317
+ ? new Date(Math.min(...mainAccountOperations.map(op => op.date.getTime())))
189
318
  : new Date();
190
319
 
191
320
  const shape = {
@@ -197,9 +326,10 @@ export function makeGetAccountShape(
197
326
  lastSyncDate: new Date(),
198
327
  freshAddress: address,
199
328
  seedIdentifier: address,
200
- operations,
201
- operationsCount: operations.length,
329
+ operations: mainAccountOperations,
330
+ operationsCount: mainAccountOperations.length,
202
331
  spendableBalance,
332
+ subAccounts: filteredSubAccounts,
203
333
  xpub: xpubOrAddress,
204
334
  used,
205
335
  cantonResources,
@@ -0,0 +1,25 @@
1
+ import { isRecipientValid } from "../common-logic";
2
+ import { validateAddress } from "./validateAddress";
3
+
4
+ jest.mock("../common-logic");
5
+
6
+ describe("validateAddress", () => {
7
+ const mockedIsRecipientValid = jest.mocked(isRecipientValid);
8
+
9
+ beforeEach(() => {
10
+ mockedIsRecipientValid.mockClear();
11
+ });
12
+
13
+ it.each([true, false])(
14
+ "should call correctly isRecipientValid and return the correct value",
15
+ async (expectedValue: boolean) => {
16
+ mockedIsRecipientValid.mockReturnValueOnce(expectedValue);
17
+
18
+ const result = await validateAddress("some random address", {});
19
+ expect(result).toEqual(expectedValue);
20
+
21
+ expect(mockedIsRecipientValid).toHaveBeenCalledTimes(1);
22
+ expect(mockedIsRecipientValid).toHaveBeenCalledWith("some random address");
23
+ },
24
+ );
25
+ });
@@ -0,0 +1,9 @@
1
+ import { isRecipientValid } from "../common-logic";
2
+ import { AddressValidationCurrencyParameters } from "@ledgerhq/types-live";
3
+
4
+ export async function validateAddress(
5
+ address: string,
6
+ _parameters: Partial<AddressValidationCurrencyParameters>,
7
+ ): Promise<boolean> {
8
+ return isRecipientValid(address);
9
+ }
@@ -6,6 +6,7 @@ import { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
6
6
  export type CantonBalance = Balance & {
7
7
  utxoCount: number;
8
8
  instrumentId: string;
9
+ adminId: string;
9
10
  };
10
11
 
11
12
  const useGateway = (currency: CryptoCurrency) =>
@@ -23,6 +24,7 @@ function adaptInstrument(currency: CryptoCurrency, instrument: InstrumentBalance
23
24
  : { type: "token", assetReference: instrument.instrument_id },
24
25
  utxoCount: instrument.utxo_count,
25
26
  instrumentId: instrument.instrument_id,
27
+ adminId: instrument.admin_id,
26
28
  };
27
29
  }
28
30
 
@@ -15,6 +15,7 @@ export async function craftTransaction(
15
15
  },
16
16
  transaction: {
17
17
  recipient?: string;
18
+ instrumentAdmin?: string;
18
19
  amount: BigNumber;
19
20
  tokenId: string;
20
21
  expireInSeconds: number;
@@ -33,6 +34,10 @@ export async function craftTransaction(
33
34
  instrument_id: transaction.tokenId,
34
35
  };
35
36
 
37
+ if (transaction.instrumentAdmin) {
38
+ params.instrument_admin = transaction.instrumentAdmin;
39
+ }
40
+
36
41
  if (transaction.memo) {
37
42
  params.reason = transaction.memo;
38
43
  }
@@ -2,8 +2,12 @@ import {
2
2
  getBalance,
3
3
  isPartyAlreadyExists,
4
4
  submitOnboarding,
5
+ getEnabledInstruments,
6
+ getEnabledInstrumentsCached,
7
+ clearEnabledInstrumentsCache,
5
8
  type GetBalanceResponse,
6
9
  type InstrumentBalance,
10
+ type InstrumentsResponse,
7
11
  type OnboardingPrepareResponse,
8
12
  } from "./gateway";
9
13
  import type { CryptoCurrency } from "@ledgerhq/types-cryptoassets";
@@ -126,3 +130,168 @@ describe("getBalance", () => {
126
130
  await expect(getBalance(mockCurrency, "test-party-id")).rejects.toThrow(TopologyChangeError);
127
131
  });
128
132
  });
133
+
134
+ describe("getEnabledInstruments", () => {
135
+ const mockCurrency = {
136
+ id: "canton_network",
137
+ } as unknown as CryptoCurrency;
138
+
139
+ const mockNetwork = network as jest.MockedFunction<typeof network>;
140
+
141
+ beforeAll(() => {
142
+ coinConfig.setCoinConfig(() => ({
143
+ gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com",
144
+ nodeId: "test-node-id",
145
+ useGateway: true,
146
+ networkType: "devnet",
147
+ nativeInstrumentId: "Amulet",
148
+ status: {
149
+ type: "active",
150
+ },
151
+ }));
152
+ });
153
+
154
+ beforeEach(() => {
155
+ jest.clearAllMocks();
156
+ });
157
+
158
+ it("should fetch and return enabled instruments from API", async () => {
159
+ const mockInstrumentsResponse: InstrumentsResponse = {
160
+ instruments: [
161
+ { instrument_id: "Amulet", display_name: "Canton Coin" },
162
+ { instrument_id: "0x1234567890abcdef", display_name: "Test Token 1" },
163
+ { instrument_id: "0xabcdef1234567890", display_name: "Test Token 2" },
164
+ ],
165
+ };
166
+
167
+ mockNetwork.mockResolvedValue({ data: mockInstrumentsResponse, status: 200 });
168
+
169
+ const result = await getEnabledInstruments(mockCurrency);
170
+
171
+ expect(result).toEqual(["Amulet", "0x1234567890abcdef", "0xabcdef1234567890"]);
172
+ expect(mockNetwork).toHaveBeenCalledWith(
173
+ expect.objectContaining({
174
+ method: "GET",
175
+ url: expect.stringContaining("/v1/node/test-node-id/instruments"),
176
+ }),
177
+ );
178
+ });
179
+
180
+ it("should return empty array on API failure (fail-safe)", async () => {
181
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
182
+ mockNetwork.mockRejectedValue(new Error("Network error"));
183
+
184
+ const result = await getEnabledInstruments(mockCurrency);
185
+
186
+ expect(result).toEqual([]);
187
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
188
+ "Failed to fetch enabled instruments:",
189
+ expect.any(Error),
190
+ );
191
+
192
+ consoleErrorSpy.mockRestore();
193
+ });
194
+
195
+ it("should return empty array when API returns empty instruments list", async () => {
196
+ const mockEmptyResponse: InstrumentsResponse = {
197
+ instruments: [],
198
+ };
199
+
200
+ mockNetwork.mockResolvedValue({ data: mockEmptyResponse, status: 200 });
201
+
202
+ const result = await getEnabledInstruments(mockCurrency);
203
+
204
+ expect(result).toEqual([]);
205
+ });
206
+
207
+ it("should handle malformed API response gracefully", async () => {
208
+ const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
209
+ // API returns data without instruments property
210
+ mockNetwork.mockResolvedValue({ data: { invalid: "response" }, status: 200 });
211
+
212
+ const result = await getEnabledInstruments(mockCurrency);
213
+
214
+ expect(result).toEqual([]);
215
+ expect(consoleErrorSpy).toHaveBeenCalled();
216
+
217
+ consoleErrorSpy.mockRestore();
218
+ });
219
+ });
220
+
221
+ describe("getEnabledInstrumentsCached", () => {
222
+ const mockCurrency = {
223
+ id: "canton_network",
224
+ } as unknown as CryptoCurrency;
225
+
226
+ const mockNetwork = network as jest.MockedFunction<typeof network>;
227
+
228
+ beforeAll(() => {
229
+ coinConfig.setCoinConfig(() => ({
230
+ gatewayUrl: "https://canton-gateway-devnet.api.live.ledger-test.com",
231
+ nodeId: "test-node-id",
232
+ useGateway: true,
233
+ networkType: "devnet",
234
+ nativeInstrumentId: "Amulet",
235
+ status: {
236
+ type: "active",
237
+ },
238
+ }));
239
+ });
240
+
241
+ beforeEach(() => {
242
+ jest.clearAllMocks();
243
+ // Clear cache before each test
244
+ clearEnabledInstrumentsCache(mockCurrency);
245
+ });
246
+
247
+ it("should cache results and return cached value on subsequent calls", async () => {
248
+ const mockInstrumentsResponse: InstrumentsResponse = {
249
+ instruments: [{ instrument_id: "Amulet" }, { instrument_id: "0x1234567890abcdef" }],
250
+ };
251
+
252
+ mockNetwork.mockResolvedValue({ data: mockInstrumentsResponse, status: 200 });
253
+
254
+ // First call - should fetch from API
255
+ const result1 = await getEnabledInstrumentsCached(mockCurrency);
256
+ expect(result1).toEqual(["Amulet", "0x1234567890abcdef"]);
257
+ expect(mockNetwork).toHaveBeenCalledTimes(1);
258
+
259
+ // Second call - should return cached value
260
+ const result2 = await getEnabledInstrumentsCached(mockCurrency);
261
+ expect(result2).toEqual(["Amulet", "0x1234567890abcdef"]);
262
+ expect(mockNetwork).toHaveBeenCalledTimes(1); // Still only 1 call
263
+
264
+ // Third call - should still return cached value
265
+ const result3 = await getEnabledInstrumentsCached(mockCurrency);
266
+ expect(result3).toEqual(["Amulet", "0x1234567890abcdef"]);
267
+ expect(mockNetwork).toHaveBeenCalledTimes(1); // Still only 1 call
268
+ });
269
+
270
+ it("should fetch fresh data after cache is cleared", async () => {
271
+ const mockResponse1: InstrumentsResponse = {
272
+ instruments: [{ instrument_id: "Amulet" }],
273
+ };
274
+
275
+ const mockResponse2: InstrumentsResponse = {
276
+ instruments: [{ instrument_id: "Amulet" }, { instrument_id: "0xnewtoken" }],
277
+ };
278
+
279
+ mockNetwork.mockResolvedValueOnce({ data: mockResponse1, status: 200 });
280
+
281
+ // First call
282
+ const result1 = await getEnabledInstrumentsCached(mockCurrency);
283
+ expect(result1).toEqual(["Amulet"]);
284
+ expect(mockNetwork).toHaveBeenCalledTimes(1);
285
+
286
+ // Clear cache
287
+ clearEnabledInstrumentsCache(mockCurrency);
288
+
289
+ // Set up new response
290
+ mockNetwork.mockResolvedValueOnce({ data: mockResponse2, status: 200 });
291
+
292
+ // Second call after clear - should fetch fresh data
293
+ const result2 = await getEnabledInstrumentsCached(mockCurrency);
294
+ expect(result2).toEqual(["Amulet", "0xnewtoken"]);
295
+ expect(mockNetwork).toHaveBeenCalledTimes(2);
296
+ });
297
+ });
@@ -69,6 +69,7 @@ export type PrepareTransferRequest = {
69
69
  recipient: string;
70
70
  execute_before_secs: number;
71
71
  instrument_id: string;
72
+ instrument_admin?: string;
72
73
  reason?: string;
73
74
  };
74
75
 
@@ -87,6 +88,7 @@ export type TransferProposal = {
87
88
  receiver: string;
88
89
  amount: string;
89
90
  instrument_id: string;
91
+ instrument_admin: string;
90
92
  memo: string;
91
93
  expires_at_micros: number;
92
94
  };
@@ -112,6 +114,13 @@ type TransactionSubmitRequest = {
112
114
 
113
115
  type TransactionSubmitResponse = { update_id: string };
114
116
 
117
+ type Asset = {
118
+ instrumentAdmin: string;
119
+ instrumentId: string;
120
+ issuer: string | null;
121
+ type: "token" | "native";
122
+ };
123
+
115
124
  export type GetBalanceResponse =
116
125
  | {
117
126
  at_round: number;
@@ -228,10 +237,7 @@ export type OperationInfo =
228
237
  type: string;
229
238
  };
230
239
  };
231
- asset: {
232
- type: "token";
233
- issuer: string;
234
- };
240
+ asset: Asset;
235
241
  details: {
236
242
  operationType: OperationType;
237
243
  };
@@ -273,10 +279,7 @@ export type OperationInfo =
273
279
  type: string;
274
280
  };
275
281
  };
276
- asset: {
277
- type: "native";
278
- issuer: null;
279
- };
282
+ asset: Asset;
280
283
  details: {
281
284
  operationType: OperationType;
282
285
  };
@@ -318,10 +321,7 @@ export type OperationInfo =
318
321
  type: string;
319
322
  };
320
323
  };
321
- asset: {
322
- type: "native";
323
- issuer: null;
324
- };
324
+ asset: Asset;
325
325
  details: {
326
326
  operationType: OperationType;
327
327
  };
@@ -426,6 +426,45 @@ export function clearIsTopologyChangeRequiredCache(currency: CryptoCurrency, pub
426
426
  isTopologyChangeRequiredCached.clear(cacheKey);
427
427
  }
428
428
 
429
+ export type InstrumentInfo = {
430
+ instrument_id: string;
431
+ display_name?: string;
432
+ };
433
+
434
+ export type InstrumentsResponse = {
435
+ instruments: InstrumentInfo[];
436
+ };
437
+
438
+ export async function getEnabledInstruments(currency: CryptoCurrency): Promise<string[]> {
439
+ try {
440
+ const { data } = await gatewayNetwork<InstrumentsResponse>({
441
+ method: "GET",
442
+ url: `${getGatewayUrl(currency)}/v1/node/${getNodeId(currency)}/instruments`,
443
+ });
444
+ return data.instruments.map(instrument => instrument.instrument_id);
445
+ } catch (error) {
446
+ // If API fails, return empty array (fail-safe: only native instrument will work)
447
+ console.error("Failed to fetch enabled instruments:", error);
448
+ return [];
449
+ }
450
+ }
451
+
452
+ const getEnabledInstrumentsCacheKey = (currency: CryptoCurrency): string => {
453
+ const nodeId = getNodeId(currency);
454
+ return `instruments_${nodeId}`;
455
+ };
456
+
457
+ export const getEnabledInstrumentsCached = makeLRUCache(
458
+ getEnabledInstruments,
459
+ getEnabledInstrumentsCacheKey,
460
+ minutes(15),
461
+ );
462
+
463
+ export function clearEnabledInstrumentsCache(currency: CryptoCurrency): void {
464
+ const cacheKey = getEnabledInstrumentsCacheKey(currency);
465
+ getEnabledInstrumentsCached.clear(cacheKey);
466
+ }
467
+
429
468
  export async function submitOnboarding(
430
469
  currency: CryptoCurrency,
431
470
  publicKey: string,
@@ -704,3 +743,35 @@ export async function getPendingTransferProposals(currency: CryptoCurrency, part
704
743
  });
705
744
  return data;
706
745
  }
746
+
747
+ // CAL API types
748
+ export type CalToken = {
749
+ id: string;
750
+ name: string;
751
+ ticker: string;
752
+ network: string;
753
+ contract_address: string;
754
+ token_identifier: string;
755
+ };
756
+
757
+ /**
758
+ * Fetch Canton tokens from CAL service and create a map of id -> token_identifier
759
+ */
760
+ async function getCalTokens(currency: CryptoCurrency): Promise<Map<string, string>> {
761
+ const calUrl = getEnv("CAL_SERVICE_URL");
762
+ const { data: calTokens } = await gatewayNetwork<CalToken[]>({
763
+ method: "GET",
764
+ url: `${calUrl}/v1/tokens?network=${currency.id}&output=id,name,ticker,network,contract_address,token_identifier,units,standard`,
765
+ });
766
+
767
+ // Map id -> token_identifier
768
+ const tokenIdentifierMap = new Map<string, string>();
769
+ for (const token of calTokens) {
770
+ tokenIdentifierMap.set(token.id, token.token_identifier);
771
+ }
772
+ return tokenIdentifierMap;
773
+ }
774
+
775
+ const getCalTokensCacheKey = (currency: CryptoCurrency): string => currency.id;
776
+
777
+ export const getCalTokensCached = makeLRUCache(getCalTokens, getCalTokensCacheKey, minutes(30));
@@ -62,6 +62,7 @@ export type Transaction = TransactionCommon & {
62
62
  memo?: string;
63
63
  tokenId: string;
64
64
  expireInSeconds?: number;
65
+ instrumentAdmin?: string;
65
66
  };
66
67
 
67
68
  export type TransactionRaw = TransactionCommonRaw & {
@@ -70,6 +71,7 @@ export type TransactionRaw = TransactionCommonRaw & {
70
71
  memo?: string;
71
72
  tokenId: string;
72
73
  expireInSeconds?: number;
74
+ instrumentAdmin?: string;
73
75
  };
74
76
 
75
77
  export type TransactionStatus = TransactionStatusCommon;
package/tsconfig.json CHANGED
@@ -4,9 +4,14 @@
4
4
  "declaration": true,
5
5
  "declarationMap": true,
6
6
  "downlevelIteration": true,
7
- "lib": ["es2020", "dom"],
7
+ "lib": [
8
+ "es2020",
9
+ "dom"
10
+ ],
8
11
  "outDir": "lib",
9
12
  "exactOptionalPropertyTypes": true
10
13
  },
11
- "include": ["src/**/*"]
14
+ "include": [
15
+ "src/**/*"
16
+ ]
12
17
  }