@temple-digital-group/temple-canton-js 2.0.2 → 2.0.3-beta.1

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 (38) hide show
  1. package/README.md +457 -457
  2. package/index.js +15 -15
  3. package/package.json +50 -49
  4. package/src/api/config.d.ts +20 -20
  5. package/src/api/index.ts +322 -322
  6. package/src/api/tokenStore.ts +30 -30
  7. package/src/api/types.ts +196 -196
  8. package/src/auth0/index.d.ts +1 -1
  9. package/src/auth0/index.js +50 -50
  10. package/src/canton/deposits.ts +563 -563
  11. package/src/canton/helpers.ts +266 -266
  12. package/src/canton/index.d.ts +41 -41
  13. package/src/canton/index.js +3301 -3472
  14. package/src/canton/instrumentCatalog.d.ts +7 -7
  15. package/src/canton/instrumentCatalog.js +283 -283
  16. package/src/canton/request_schemas/cancel_orders_amulet.json +77 -77
  17. package/src/canton/request_schemas/cancel_orders_utility.json +68 -68
  18. package/src/canton/request_schemas/create_order_proposal_amulet.json +94 -94
  19. package/src/canton/request_schemas/create_order_proposal_utility.json +121 -121
  20. package/src/canton/request_schemas/create_utility_credential.json +31 -31
  21. package/src/canton/request_schemas/execute_transfer_factory.json +43 -43
  22. package/src/canton/request_schemas/get_allocation_factory.json +21 -21
  23. package/src/canton/request_schemas/get_amulet_holdings.json +21 -21
  24. package/src/canton/request_schemas/get_instrument_configurations.json +21 -21
  25. package/src/canton/request_schemas/get_locked_amulet_holdings.json +21 -21
  26. package/src/canton/request_schemas/get_order_proposals.json +21 -21
  27. package/src/canton/request_schemas/get_orders.json +21 -21
  28. package/src/canton/request_schemas/get_sender_credentials.json +22 -22
  29. package/src/canton/request_schemas/get_transfer_factory.json +28 -28
  30. package/src/canton/request_schemas/get_utility_holdings.json +21 -21
  31. package/src/canton/request_schemas/unlock_amulet.json +38 -38
  32. package/src/canton/walletAdapter.d.ts +7 -7
  33. package/src/canton/walletAdapter.js +112 -112
  34. package/src/canton/withdrawals.ts +511 -511
  35. package/src/config/index.d.ts +63 -63
  36. package/src/config/index.js +188 -188
  37. package/src/websocket/index.ts +341 -341
  38. package/src/websocket/ws.d.ts +24 -24
@@ -1,511 +1,511 @@
1
- import config from "../../src/config/index.js";
2
- import axios from "axios";
3
- import {
4
- getDisclosures,
5
- createWithdrawalRequest,
6
- getWithdrawalRequestStatus,
7
- getDelegation,
8
- } from "../api/index.js";
9
- import { getUserId } from "../api/tokenStore.js";
10
- import { getAdapterPartyId, getWalletAdapter, submitCommand, payDueGasIfAny } from "../../src/canton/walletAdapter.js";
11
- import {
12
- randomUUID,
13
- shouldUseLedgerForMetadata,
14
- normalizeContractId,
15
- resolveInstrumentDefinition,
16
- getInstrumentRegistrar,
17
- resolveProvider,
18
- dedupeDisclosedContracts,
19
- buildHeaders,
20
- DEFAULT_AMULET_CONTEXT_KEYS,
21
- DEFAULT_UTILITY_CONTEXT_KEYS,
22
- } from "./helpers.js";
23
- import type { DisclosedContract, ContractMetadata } from "./helpers.js";
24
- import {
25
- resolveAmuletContext,
26
- resolveUtilityInstrumentConfiguration,
27
- resolveUtilityAllocationFactory,
28
- getUtxoCount,
29
- } from "../../src/canton/index.js";
30
- import { normalizeAssetId } from "../../src/canton/instrumentCatalog.js";
31
-
32
- // ─── Constants ───────────────────────────────────────────────────────────────
33
-
34
- const CC_FEE_RESERVE = 10;
35
-
36
- // ─── Types ───────────────────────────────────────────────────────────────────
37
-
38
- export interface FinalizeWithdrawOpts {
39
- allocationId: string;
40
- sender?: string;
41
- assetId: string;
42
- disclosures?: {
43
- disclosures?: {
44
- choiceContext?: {
45
- choiceContextData?: Record<string, unknown>;
46
- disclosedContracts?: DisclosedContract[];
47
- };
48
- };
49
- choiceContext?: {
50
- choiceContextData?: Record<string, unknown>;
51
- disclosedContracts?: DisclosedContract[];
52
- };
53
- };
54
- userId?: string;
55
- }
56
-
57
- export interface WithdrawFundsOpts {
58
- asset_id: string;
59
- amount: string | number;
60
- pollIntervalMs?: number;
61
- maxPollAttempts?: number;
62
- }
63
-
64
- // ─── Withdrawal Functions ────────────────────────────────────────────────────
65
-
66
- /**
67
- * Finalize a withdrawal by exercising the Allocation_Withdraw choice.
68
- *
69
- * Supports three metadata resolution paths:
70
- * 1. Localhost: resolves context from ledger directly
71
- * 2. Amulet via disclosures (FE/proxy path — no Scan API access)
72
- * 3. Remote: Scan API / Registry API
73
- */
74
- export async function finalizeWithdrawFunds(
75
- opts: FinalizeWithdrawOpts,
76
- returnCommand = false,
77
- ): Promise<Record<string, unknown>> {
78
- const { allocationId, sender: senderOpt, assetId: rawAssetId, disclosures, userId } = opts;
79
- const assetId = normalizeAssetId(rawAssetId);
80
- const sender = getAdapterPartyId() ?? senderOpt ?? config.VALIDATOR_USER_PARTY_ID;
81
-
82
- if (!allocationId || !sender || !assetId) {
83
- const msg = "finalizeWithdrawFunds: allocationId, sender, and assetId are required";
84
- console.error(msg);
85
- return { error: msg };
86
- }
87
-
88
- const isAmulet = assetId === "Amulet";
89
-
90
- const allocationCid = normalizeContractId(allocationId) as string;
91
- let choiceContextData: Record<string, unknown> = {};
92
- let disclosedContracts: DisclosedContract[] = [];
93
-
94
- if (shouldUseLedgerForMetadata()) {
95
- // Localhost: resolve context from ledger directly
96
- if (isAmulet) {
97
- const amuletCtx = await resolveAmuletContext({ investor: sender, holdingIds: [], transferAmount: 0 });
98
- if (!amuletCtx) {
99
- const msg = "finalizeWithdrawFunds: failed to resolve Amulet context from ledger";
100
- console.error(msg);
101
- return { error: msg };
102
- }
103
- const ctx = amuletCtx as Record<string, unknown>;
104
- const contextKeys = ctx.contextKeys as Record<string, string>;
105
- const amuletRules = ctx.amuletRules as ContractMetadata;
106
- const openMiningRound = ctx.openMiningRound as ContractMetadata;
107
- const featuredAppRight = ctx.featuredAppRight as ContractMetadata;
108
- const externalAmuletRules = ctx.externalAmuletRules as ContractMetadata;
109
-
110
- choiceContextData = {
111
- values: {
112
- [contextKeys.amuletRules]: { tag: "AV_ContractId", value: amuletRules.contractCid },
113
- [contextKeys.openRound]: { tag: "AV_ContractId", value: openMiningRound.contractCid },
114
- [contextKeys.featuredAppRight]: { tag: "AV_ContractId", value: featuredAppRight.contractCid },
115
- [contextKeys.expireLock]: { tag: "AV_Bool", value: true },
116
- },
117
- };
118
- disclosedContracts = [
119
- {
120
- templateId: amuletRules.templateId ?? null,
121
- contractId: amuletRules.contractCid,
122
- createdEventBlob: amuletRules.disclosureCid,
123
- synchronizerId: amuletRules.synchronizerId,
124
- },
125
- {
126
- templateId: openMiningRound.templateId ?? null,
127
- contractId: openMiningRound.contractCid,
128
- createdEventBlob: openMiningRound.disclosureCid,
129
- synchronizerId: openMiningRound.synchronizerId,
130
- },
131
- {
132
- templateId: externalAmuletRules.templateId ?? null,
133
- contractId: externalAmuletRules.contractCid,
134
- createdEventBlob: externalAmuletRules.disclosureCid,
135
- synchronizerId: externalAmuletRules.synchronizerId,
136
- },
137
- ];
138
- if (featuredAppRight?.contractCid && featuredAppRight?.disclosureCid) {
139
- disclosedContracts.push({
140
- templateId: featuredAppRight.templateId ?? null,
141
- contractId: featuredAppRight.contractCid,
142
- createdEventBlob: featuredAppRight.disclosureCid,
143
- synchronizerId: featuredAppRight.synchronizerId,
144
- });
145
- }
146
- } else {
147
- const instrumentDef = resolveInstrumentDefinition(assetId);
148
- const networkDef = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
149
- const registrar =
150
- (networkDef?.registrar as string) || getInstrumentRegistrar(assetId) || config.VALIDATOR_REGISTRAR_PARTY_ID;
151
- const [allocFactory, instConfig] = await Promise.all([
152
- resolveUtilityAllocationFactory(registrar, sender),
153
- resolveUtilityInstrumentConfiguration(assetId, registrar),
154
- ]);
155
- if (instConfig) {
156
- choiceContextData = {
157
- values: {
158
- [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfiguration]: {
159
- tag: "AV_ContractId",
160
- value: instConfig.contractCid,
161
- },
162
- [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfigurationPrefixed]: {
163
- tag: "AV_ContractId",
164
- value: instConfig.contractCid,
165
- },
166
- [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentials]: { tag: "AV_List", value: [] },
167
- [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentialsPrefixed]: { tag: "AV_List", value: [] },
168
- [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentials]: { tag: "AV_List", value: [] },
169
- [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentialsPrefixed]: { tag: "AV_List", value: [] },
170
- [DEFAULT_AMULET_CONTEXT_KEYS.expireLock]: { tag: "AV_Bool", value: true },
171
- },
172
- };
173
- disclosedContracts.push({
174
- templateId: null,
175
- contractId: instConfig.contractCid,
176
- createdEventBlob: instConfig.disclosureCid,
177
- synchronizerId: instConfig.synchronizerId,
178
- });
179
- }
180
- if (allocFactory) {
181
- disclosedContracts.push({
182
- templateId: null,
183
- contractId: allocFactory.contractCid,
184
- createdEventBlob: allocFactory.disclosureCid,
185
- synchronizerId: allocFactory.synchronizerId,
186
- });
187
- }
188
- }
189
- } else if (isAmulet && !config.VALIDATOR_SCAN_API_URL) {
190
- // Amulet via disclosures (FE/proxy path — no Scan API access)
191
- // Use pre-fetched disclosures if provided, otherwise fetch from API
192
- const factoryData = (disclosures as Record<string, unknown>)?.disclosures || disclosures || null;
193
- let resolvedData = factoryData as Record<string, unknown> | null;
194
-
195
- if (!resolvedData?.choiceContext) {
196
- const disclosuresResult = (await getDisclosures(sender)) as unknown as Record<string, unknown>;
197
- resolvedData = disclosuresResult?.disclosures as Record<string, unknown>;
198
- if (disclosuresResult?.error || !resolvedData?.choiceContext) {
199
- const detail =
200
- (disclosuresResult?.message as string) ||
201
- (disclosuresResult?.error as string) ||
202
- "missing choiceContext in response";
203
- const msg = `finalizeWithdrawFunds: failed to resolve Amulet disclosures: ${detail}`;
204
- console.error(msg);
205
- return { error: msg };
206
- }
207
- }
208
-
209
- const choiceContext = resolvedData.choiceContext as Record<string, unknown>;
210
- choiceContextData = (choiceContext.choiceContextData as Record<string, unknown>) || {};
211
- // Ensure expire-lock is present (required by Allocation_Withdraw but not always in API response)
212
- const values = choiceContextData.values as Record<string, unknown> | undefined;
213
- if (values && !values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock]) {
214
- values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock] = { tag: "AV_Bool", value: true };
215
- }
216
- disclosedContracts = ((choiceContext.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
217
- templateId: dc.templateId,
218
- contractId: dc.contractId,
219
- createdEventBlob: dc.createdEventBlob,
220
- synchronizerId: dc.synchronizerId,
221
- }));
222
- } else {
223
- // Remote: fetch choice context from Scan API / Registry API
224
- let baseUrl: string;
225
- let contextHeaders: Record<string, string> = {};
226
-
227
- if (isAmulet) {
228
- if (!config.VALIDATOR_SCAN_API_URL) {
229
- const msg = "finalizeWithdrawFunds: VALIDATOR_SCAN_API_URL is required for Amulet allocations";
230
- console.error(msg);
231
- return { error: msg };
232
- }
233
- baseUrl = `${config.VALIDATOR_SCAN_API_URL}/registry`;
234
- contextHeaders = await buildHeaders();
235
- } else {
236
- const instrumentDef = resolveInstrumentDefinition(assetId);
237
- const networkContracts = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
238
- const registryAPI = networkContracts?.registryAPI as string | undefined;
239
-
240
- if (!registryAPI) {
241
- const msg = `finalizeWithdrawFunds: no registryAPI defined for ${assetId} on ${config.NETWORK}`;
242
- console.error(msg);
243
- return { error: msg };
244
- }
245
- baseUrl = registryAPI;
246
- }
247
-
248
- const contextUrl = `${baseUrl}/allocations/v1/${encodeURIComponent(allocationCid)}/choice-contexts/withdraw`;
249
- let contextData: Record<string, unknown>;
250
- try {
251
- const contextResponse = await axios.post(
252
- contextUrl,
253
- {
254
- meta: {},
255
- excludeDebugFields: true,
256
- },
257
- Object.keys(contextHeaders).length > 0 ? { headers: contextHeaders } : undefined,
258
- );
259
- contextData = contextResponse.data;
260
- } catch (error: unknown) {
261
- const axiosErr = error as { response?: { data?: unknown }; message?: string };
262
- const detail = axiosErr.response?.data ? JSON.stringify(axiosErr.response.data) : axiosErr.message;
263
- const msg = `finalizeWithdrawFunds: error fetching withdraw context from ${isAmulet ? "Scan API" : "Registry API"}: ${detail}`;
264
- console.error(msg);
265
- return { error: msg };
266
- }
267
-
268
- choiceContextData = (contextData?.choiceContextData as Record<string, unknown>) || {};
269
- disclosedContracts = ((contextData?.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
270
- templateId: dc.templateId,
271
- contractId: dc.contractId,
272
- createdEventBlob: dc.createdEventBlob,
273
- synchronizerId: dc.synchronizerId,
274
- }));
275
- }
276
-
277
- // --- Build the ExerciseCommand for Allocation_Withdraw ---
278
- const command = {
279
- commands: [
280
- {
281
- ExerciseCommand: {
282
- templateId: "#splice-api-token-allocation-v1:Splice.Api.Token.AllocationV1:Allocation",
283
- contractId: allocationCid,
284
- choice: "Allocation_Withdraw",
285
- choiceArgument: {
286
- extraArgs: {
287
- context: choiceContextData,
288
- meta: { values: {} },
289
- },
290
- },
291
- },
292
- },
293
- ],
294
- commandId: randomUUID(),
295
- userId: userId || getUserId() || config.AUTH0_USER_ID || "temple",
296
- applicationId: "temple",
297
- actAs: [sender],
298
- disclosedContracts: disclosedContracts,
299
- };
300
-
301
- dedupeDisclosedContracts(command);
302
-
303
- const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
304
-
305
- if (returnCommand) {
306
- return { command, endpoint };
307
- }
308
-
309
- // Auto-submit via wallet adapter if available
310
- if (getWalletAdapter()) {
311
- try {
312
- await payDueGasIfAny();
313
- return (await submitCommand(command)) as Record<string, unknown>;
314
- } catch (error: unknown) {
315
- const msg = `finalizeWithdrawFunds: wallet adapter submission failed: ${(error as Error).message}`;
316
- console.error(msg);
317
- return { error: msg };
318
- }
319
- }
320
-
321
- const headers = await buildHeaders();
322
- try {
323
- const response = await axios.post(endpoint, command, { headers });
324
- return response.data;
325
- } catch (error: unknown) {
326
- const axiosErr = error as { response?: { data?: unknown }; message?: string };
327
- const errorData = axiosErr?.response?.data;
328
- const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
329
- const msg = `finalizeWithdrawFunds: error submitting command: ${errorDetail}`;
330
- console.error(msg);
331
- return { error: msg };
332
- }
333
- }
334
-
335
- /**
336
- * High-level withdrawal flow:
337
- * 1. Creates a withdrawal request via the backend API.
338
- * 2. Polls the request status until it is no longer "pending".
339
- * 3. Exercises Allocation_Withdraw on the allocation contract to release holdings back to the user.
340
- */
341
- export async function withdrawFunds(
342
- opts: WithdrawFundsOpts,
343
- returnCommand = false,
344
- ): Promise<Record<string, unknown>> {
345
- const { amount, pollIntervalMs = 2000, maxPollAttempts = 30 } = opts || {};
346
- const asset_id = normalizeAssetId(opts?.asset_id);
347
-
348
- const sender = getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
349
- if (!sender) {
350
- const msg = "withdrawFunds: sender party is required. Connect a wallet adapter or configure VALIDATOR_USER_PARTY_ID.";
351
- console.error(msg);
352
- return { error: msg };
353
- }
354
-
355
- // Pay any outstanding network gas first so the fee-reserve check below is accurate.
356
- await payDueGasIfAny();
357
-
358
- // Ensure the user has enough CC unlocked to pay gas for the eventual Allocation_Withdraw.
359
- const provider = resolveProvider(null);
360
- const ccBalance = await getUtxoCount(sender, "Amulet", provider);
361
- const availableCC = ccBalance.unlockedBalance || 0;
362
- if (availableCC < CC_FEE_RESERVE) {
363
- return {
364
- error: `withdrawFunds: insufficient CC for fees. You have ${availableCC} CC but need at least ${CC_FEE_RESERVE} CC to cover transaction fees.`,
365
- data: { ccBalance: availableCC, feeReserve: CC_FEE_RESERVE },
366
- };
367
- }
368
-
369
- // 1. Submit the withdrawal request
370
- const createResult = (await createWithdrawalRequest(asset_id, amount)) as Record<string, unknown>;
371
- if (createResult?.error) {
372
- return createResult;
373
- }
374
-
375
- const requestId = createResult?.request_id;
376
- if (requestId == null) {
377
- const msg = "withdrawFunds: backend did not return a request_id";
378
- console.error(msg);
379
- return { error: msg };
380
- }
381
-
382
- console.log(`withdrawFunds: withdrawal request ${requestId} submitted, polling for status...`);
383
-
384
- // 2. Poll until status is "ready" and allocation_cid is available
385
- let allocationCid: string | null = null;
386
- for (let attempt = 1; attempt <= maxPollAttempts; attempt++) {
387
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
388
-
389
- const status = (await getWithdrawalRequestStatus(String(requestId))) as Record<string, unknown>;
390
- if (status?.error) {
391
- return status;
392
- }
393
-
394
- // Terminal failure states — stop polling
395
- if (status.status === "failed" || status.status === "rejected") {
396
- console.error(`withdrawFunds: request ${requestId} ${status.status}`);
397
- return status;
398
- }
399
-
400
- // Ready with an allocation_cid — proceed to withdraw
401
- if (status.status === "ready" && status.allocation_cid) {
402
- console.log(`withdrawFunds: request ${requestId} ready — allocation_cid: ${status.allocation_cid}`);
403
- allocationCid = status.allocation_cid as string;
404
- break;
405
- }
406
-
407
- if (attempt % 5 === 0) {
408
- console.log(`withdrawFunds: still waiting (status=${status.status}) after ${attempt} poll(s)...`);
409
- }
410
- }
411
-
412
- if (!allocationCid) {
413
- const msg = `withdrawFunds: request ${requestId} not ready after ${maxPollAttempts} attempts`;
414
- console.error(msg);
415
- return { error: msg, request_id: requestId };
416
- }
417
-
418
- // 3. Exercise Allocation_Withdraw to release held funds back to the user
419
- console.log(`withdrawFunds: exercising Allocation_Withdraw on ${allocationCid}...`);
420
- const assetId = asset_id;
421
- const withdrawResult = await finalizeWithdrawFunds({ allocationId: allocationCid, sender, assetId }, returnCommand);
422
-
423
- if (withdrawResult?.error) {
424
- console.error(`withdrawFunds: Allocation_Withdraw failed for request ${requestId}: ${withdrawResult.error}`);
425
- return { error: withdrawResult.error as string, request_id: requestId, allocation_cid: allocationCid };
426
- }
427
-
428
- console.log(`withdrawFunds: withdrawal complete for request ${requestId}`);
429
- return { ...withdrawResult, request_id: requestId, allocation_cid: allocationCid };
430
- }
431
-
432
- /**
433
- * Withdraw a Delegation contract (user-initiated).
434
- *
435
- * Exercises the WithdrawDelegation choice on the Delegation interface,
436
- * which archives the delegation and revokes the operator's authority.
437
- */
438
- export async function withdrawDelegation(
439
- delegationId: string | null = null,
440
- user: string | null = null,
441
- returnCommand = false,
442
- ): Promise<Record<string, unknown>> {
443
- let resolvedUser = user ?? getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
444
- let resolvedDelegationId = delegationId;
445
-
446
- // If no delegationId passed, fetch it from the API
447
- if (!resolvedDelegationId) {
448
- const result = (await getDelegation()) as unknown as Record<string, unknown>;
449
- const delegation = result?.delegation as Record<string, unknown> | undefined;
450
- if (result?.error || !delegation) {
451
- const msg =
452
- "withdrawDelegation: could not resolve delegation. Pass delegationId directly or ensure the user is onboarded.";
453
- console.error(msg);
454
- return { error: msg };
455
- }
456
- resolvedDelegationId = delegation.delegation_cid as string;
457
- }
458
- if (!resolvedUser) {
459
- const msg =
460
- "withdrawDelegation: user party is required. Pass it directly, set WALLET_ADAPTER, or configure VALIDATOR_USER_PARTY_ID.";
461
- console.error(msg);
462
- return { error: msg };
463
- }
464
-
465
- const command = {
466
- commands: [
467
- {
468
- ExerciseCommand: {
469
- templateId: `${config.PACKAGE_NAME_SETTLEMENT_INTERFACE}:Temple.Settlement.Interface.Delegation:Delegation`,
470
- contractId: normalizeContractId(resolvedDelegationId),
471
- choice: "WithdrawDelegation",
472
- choiceArgument: {},
473
- },
474
- },
475
- ],
476
- commandId: randomUUID(),
477
- userId: getUserId() || config.AUTH0_USER_ID || "temple",
478
- applicationId: "temple",
479
- actAs: [resolvedUser],
480
- };
481
-
482
- if (returnCommand) {
483
- return { command, endpoint: `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait` };
484
- }
485
-
486
- // Auto-submit via wallet adapter if available
487
- if (getWalletAdapter()) {
488
- try {
489
- await payDueGasIfAny();
490
- return (await submitCommand(command)) as Record<string, unknown>;
491
- } catch (error: unknown) {
492
- const msg = `withdrawDelegation: wallet adapter submission failed: ${(error as Error).message}`;
493
- console.error(msg);
494
- return { error: msg };
495
- }
496
- }
497
-
498
- const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
499
- const headers = await buildHeaders();
500
- try {
501
- const response = await axios.post(endpoint, command, { headers });
502
- return response.data;
503
- } catch (error: unknown) {
504
- const axiosErr = error as { response?: { data?: unknown }; message?: string };
505
- const errorData = axiosErr?.response?.data;
506
- const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
507
- const msg = `withdrawDelegation: error submitting command: ${errorDetail}`;
508
- console.error(msg);
509
- return { error: msg };
510
- }
511
- }
1
+ import config from "../../src/config/index.js";
2
+ import axios from "axios";
3
+ import {
4
+ getDisclosures,
5
+ createWithdrawalRequest,
6
+ getWithdrawalRequestStatus,
7
+ getDelegation,
8
+ } from "../api/index.js";
9
+ import { getUserId } from "../api/tokenStore.js";
10
+ import { getAdapterPartyId, getWalletAdapter, submitCommand, payDueGasIfAny } from "../../src/canton/walletAdapter.js";
11
+ import {
12
+ randomUUID,
13
+ shouldUseLedgerForMetadata,
14
+ normalizeContractId,
15
+ resolveInstrumentDefinition,
16
+ getInstrumentRegistrar,
17
+ resolveProvider,
18
+ dedupeDisclosedContracts,
19
+ buildHeaders,
20
+ DEFAULT_AMULET_CONTEXT_KEYS,
21
+ DEFAULT_UTILITY_CONTEXT_KEYS,
22
+ } from "./helpers.js";
23
+ import type { DisclosedContract, ContractMetadata } from "./helpers.js";
24
+ import {
25
+ resolveAmuletContext,
26
+ resolveUtilityInstrumentConfiguration,
27
+ resolveUtilityAllocationFactory,
28
+ getUtxoCount,
29
+ } from "../../src/canton/index.js";
30
+ import { normalizeAssetId } from "../../src/canton/instrumentCatalog.js";
31
+
32
+ // ─── Constants ───────────────────────────────────────────────────────────────
33
+
34
+ const CC_FEE_RESERVE = 10;
35
+
36
+ // ─── Types ───────────────────────────────────────────────────────────────────
37
+
38
+ export interface FinalizeWithdrawOpts {
39
+ allocationId: string;
40
+ sender?: string;
41
+ assetId: string;
42
+ disclosures?: {
43
+ disclosures?: {
44
+ choiceContext?: {
45
+ choiceContextData?: Record<string, unknown>;
46
+ disclosedContracts?: DisclosedContract[];
47
+ };
48
+ };
49
+ choiceContext?: {
50
+ choiceContextData?: Record<string, unknown>;
51
+ disclosedContracts?: DisclosedContract[];
52
+ };
53
+ };
54
+ userId?: string;
55
+ }
56
+
57
+ export interface WithdrawFundsOpts {
58
+ asset_id: string;
59
+ amount: string | number;
60
+ pollIntervalMs?: number;
61
+ maxPollAttempts?: number;
62
+ }
63
+
64
+ // ─── Withdrawal Functions ────────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Finalize a withdrawal by exercising the Allocation_Withdraw choice.
68
+ *
69
+ * Supports three metadata resolution paths:
70
+ * 1. Localhost: resolves context from ledger directly
71
+ * 2. Amulet via disclosures (FE/proxy path — no Scan API access)
72
+ * 3. Remote: Scan API / Registry API
73
+ */
74
+ export async function finalizeWithdrawFunds(
75
+ opts: FinalizeWithdrawOpts,
76
+ returnCommand = false,
77
+ ): Promise<Record<string, unknown>> {
78
+ const { allocationId, sender: senderOpt, assetId: rawAssetId, disclosures, userId } = opts;
79
+ const assetId = normalizeAssetId(rawAssetId);
80
+ const sender = getAdapterPartyId() ?? senderOpt ?? config.VALIDATOR_USER_PARTY_ID;
81
+
82
+ if (!allocationId || !sender || !assetId) {
83
+ const msg = "finalizeWithdrawFunds: allocationId, sender, and assetId are required";
84
+ console.error(msg);
85
+ return { error: msg };
86
+ }
87
+
88
+ const isAmulet = assetId === "Amulet";
89
+
90
+ const allocationCid = normalizeContractId(allocationId) as string;
91
+ let choiceContextData: Record<string, unknown> = {};
92
+ let disclosedContracts: DisclosedContract[] = [];
93
+
94
+ if (shouldUseLedgerForMetadata()) {
95
+ // Localhost: resolve context from ledger directly
96
+ if (isAmulet) {
97
+ const amuletCtx = await resolveAmuletContext({ investor: sender, holdingIds: [], transferAmount: 0 });
98
+ if (!amuletCtx) {
99
+ const msg = "finalizeWithdrawFunds: failed to resolve Amulet context from ledger";
100
+ console.error(msg);
101
+ return { error: msg };
102
+ }
103
+ const ctx = amuletCtx as Record<string, unknown>;
104
+ const contextKeys = ctx.contextKeys as Record<string, string>;
105
+ const amuletRules = ctx.amuletRules as ContractMetadata;
106
+ const openMiningRound = ctx.openMiningRound as ContractMetadata;
107
+ const featuredAppRight = ctx.featuredAppRight as ContractMetadata;
108
+ const externalAmuletRules = ctx.externalAmuletRules as ContractMetadata;
109
+
110
+ choiceContextData = {
111
+ values: {
112
+ [contextKeys.amuletRules]: { tag: "AV_ContractId", value: amuletRules.contractCid },
113
+ [contextKeys.openRound]: { tag: "AV_ContractId", value: openMiningRound.contractCid },
114
+ [contextKeys.featuredAppRight]: { tag: "AV_ContractId", value: featuredAppRight.contractCid },
115
+ [contextKeys.expireLock]: { tag: "AV_Bool", value: true },
116
+ },
117
+ };
118
+ disclosedContracts = [
119
+ {
120
+ templateId: amuletRules.templateId ?? null,
121
+ contractId: amuletRules.contractCid,
122
+ createdEventBlob: amuletRules.disclosureCid,
123
+ synchronizerId: amuletRules.synchronizerId,
124
+ },
125
+ {
126
+ templateId: openMiningRound.templateId ?? null,
127
+ contractId: openMiningRound.contractCid,
128
+ createdEventBlob: openMiningRound.disclosureCid,
129
+ synchronizerId: openMiningRound.synchronizerId,
130
+ },
131
+ {
132
+ templateId: externalAmuletRules.templateId ?? null,
133
+ contractId: externalAmuletRules.contractCid,
134
+ createdEventBlob: externalAmuletRules.disclosureCid,
135
+ synchronizerId: externalAmuletRules.synchronizerId,
136
+ },
137
+ ];
138
+ if (featuredAppRight?.contractCid && featuredAppRight?.disclosureCid) {
139
+ disclosedContracts.push({
140
+ templateId: featuredAppRight.templateId ?? null,
141
+ contractId: featuredAppRight.contractCid,
142
+ createdEventBlob: featuredAppRight.disclosureCid,
143
+ synchronizerId: featuredAppRight.synchronizerId,
144
+ });
145
+ }
146
+ } else {
147
+ const instrumentDef = resolveInstrumentDefinition(assetId);
148
+ const networkDef = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
149
+ const registrar =
150
+ (networkDef?.registrar as string) || getInstrumentRegistrar(assetId) || config.VALIDATOR_REGISTRAR_PARTY_ID;
151
+ const [allocFactory, instConfig] = await Promise.all([
152
+ resolveUtilityAllocationFactory(registrar, sender),
153
+ resolveUtilityInstrumentConfiguration(assetId, registrar),
154
+ ]);
155
+ if (instConfig) {
156
+ choiceContextData = {
157
+ values: {
158
+ [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfiguration]: {
159
+ tag: "AV_ContractId",
160
+ value: instConfig.contractCid,
161
+ },
162
+ [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfigurationPrefixed]: {
163
+ tag: "AV_ContractId",
164
+ value: instConfig.contractCid,
165
+ },
166
+ [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentials]: { tag: "AV_List", value: [] },
167
+ [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentialsPrefixed]: { tag: "AV_List", value: [] },
168
+ [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentials]: { tag: "AV_List", value: [] },
169
+ [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentialsPrefixed]: { tag: "AV_List", value: [] },
170
+ [DEFAULT_AMULET_CONTEXT_KEYS.expireLock]: { tag: "AV_Bool", value: true },
171
+ },
172
+ };
173
+ disclosedContracts.push({
174
+ templateId: null,
175
+ contractId: instConfig.contractCid,
176
+ createdEventBlob: instConfig.disclosureCid,
177
+ synchronizerId: instConfig.synchronizerId,
178
+ });
179
+ }
180
+ if (allocFactory) {
181
+ disclosedContracts.push({
182
+ templateId: null,
183
+ contractId: allocFactory.contractCid,
184
+ createdEventBlob: allocFactory.disclosureCid,
185
+ synchronizerId: allocFactory.synchronizerId,
186
+ });
187
+ }
188
+ }
189
+ } else if (isAmulet && !config.VALIDATOR_SCAN_API_URL) {
190
+ // Amulet via disclosures (FE/proxy path — no Scan API access)
191
+ // Use pre-fetched disclosures if provided, otherwise fetch from API
192
+ const factoryData = (disclosures as Record<string, unknown>)?.disclosures || disclosures || null;
193
+ let resolvedData = factoryData as Record<string, unknown> | null;
194
+
195
+ if (!resolvedData?.choiceContext) {
196
+ const disclosuresResult = (await getDisclosures(sender)) as unknown as Record<string, unknown>;
197
+ resolvedData = disclosuresResult?.disclosures as Record<string, unknown>;
198
+ if (disclosuresResult?.error || !resolvedData?.choiceContext) {
199
+ const detail =
200
+ (disclosuresResult?.message as string) ||
201
+ (disclosuresResult?.error as string) ||
202
+ "missing choiceContext in response";
203
+ const msg = `finalizeWithdrawFunds: failed to resolve Amulet disclosures: ${detail}`;
204
+ console.error(msg);
205
+ return { error: msg };
206
+ }
207
+ }
208
+
209
+ const choiceContext = resolvedData.choiceContext as Record<string, unknown>;
210
+ choiceContextData = (choiceContext.choiceContextData as Record<string, unknown>) || {};
211
+ // Ensure expire-lock is present (required by Allocation_Withdraw but not always in API response)
212
+ const values = choiceContextData.values as Record<string, unknown> | undefined;
213
+ if (values && !values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock]) {
214
+ values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock] = { tag: "AV_Bool", value: true };
215
+ }
216
+ disclosedContracts = ((choiceContext.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
217
+ templateId: dc.templateId,
218
+ contractId: dc.contractId,
219
+ createdEventBlob: dc.createdEventBlob,
220
+ synchronizerId: dc.synchronizerId,
221
+ }));
222
+ } else {
223
+ // Remote: fetch choice context from Scan API / Registry API
224
+ let baseUrl: string;
225
+ let contextHeaders: Record<string, string> = {};
226
+
227
+ if (isAmulet) {
228
+ if (!config.VALIDATOR_SCAN_API_URL) {
229
+ const msg = "finalizeWithdrawFunds: VALIDATOR_SCAN_API_URL is required for Amulet allocations";
230
+ console.error(msg);
231
+ return { error: msg };
232
+ }
233
+ baseUrl = `${config.VALIDATOR_SCAN_API_URL}/registry`;
234
+ contextHeaders = await buildHeaders();
235
+ } else {
236
+ const instrumentDef = resolveInstrumentDefinition(assetId);
237
+ const networkContracts = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
238
+ const registryAPI = networkContracts?.registryAPI as string | undefined;
239
+
240
+ if (!registryAPI) {
241
+ const msg = `finalizeWithdrawFunds: no registryAPI defined for ${assetId} on ${config.NETWORK}`;
242
+ console.error(msg);
243
+ return { error: msg };
244
+ }
245
+ baseUrl = registryAPI;
246
+ }
247
+
248
+ const contextUrl = `${baseUrl}/allocations/v1/${encodeURIComponent(allocationCid)}/choice-contexts/withdraw`;
249
+ let contextData: Record<string, unknown>;
250
+ try {
251
+ const contextResponse = await axios.post(
252
+ contextUrl,
253
+ {
254
+ meta: {},
255
+ excludeDebugFields: true,
256
+ },
257
+ Object.keys(contextHeaders).length > 0 ? { headers: contextHeaders } : undefined,
258
+ );
259
+ contextData = contextResponse.data;
260
+ } catch (error: unknown) {
261
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
262
+ const detail = axiosErr.response?.data ? JSON.stringify(axiosErr.response.data) : axiosErr.message;
263
+ const msg = `finalizeWithdrawFunds: error fetching withdraw context from ${isAmulet ? "Scan API" : "Registry API"}: ${detail}`;
264
+ console.error(msg);
265
+ return { error: msg };
266
+ }
267
+
268
+ choiceContextData = (contextData?.choiceContextData as Record<string, unknown>) || {};
269
+ disclosedContracts = ((contextData?.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
270
+ templateId: dc.templateId,
271
+ contractId: dc.contractId,
272
+ createdEventBlob: dc.createdEventBlob,
273
+ synchronizerId: dc.synchronizerId,
274
+ }));
275
+ }
276
+
277
+ // --- Build the ExerciseCommand for Allocation_Withdraw ---
278
+ const command = {
279
+ commands: [
280
+ {
281
+ ExerciseCommand: {
282
+ templateId: "#splice-api-token-allocation-v1:Splice.Api.Token.AllocationV1:Allocation",
283
+ contractId: allocationCid,
284
+ choice: "Allocation_Withdraw",
285
+ choiceArgument: {
286
+ extraArgs: {
287
+ context: choiceContextData,
288
+ meta: { values: {} },
289
+ },
290
+ },
291
+ },
292
+ },
293
+ ],
294
+ commandId: randomUUID(),
295
+ userId: userId || getUserId() || config.AUTH0_USER_ID || "temple",
296
+ applicationId: "temple",
297
+ actAs: [sender],
298
+ disclosedContracts: disclosedContracts,
299
+ };
300
+
301
+ dedupeDisclosedContracts(command);
302
+
303
+ const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
304
+
305
+ if (returnCommand) {
306
+ return { command, endpoint };
307
+ }
308
+
309
+ // Auto-submit via wallet adapter if available
310
+ if (getWalletAdapter()) {
311
+ try {
312
+ await payDueGasIfAny();
313
+ return (await submitCommand(command)) as Record<string, unknown>;
314
+ } catch (error: unknown) {
315
+ const msg = `finalizeWithdrawFunds: wallet adapter submission failed: ${(error as Error).message}`;
316
+ console.error(msg);
317
+ return { error: msg };
318
+ }
319
+ }
320
+
321
+ const headers = await buildHeaders();
322
+ try {
323
+ const response = await axios.post(endpoint, command, { headers });
324
+ return response.data;
325
+ } catch (error: unknown) {
326
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
327
+ const errorData = axiosErr?.response?.data;
328
+ const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
329
+ const msg = `finalizeWithdrawFunds: error submitting command: ${errorDetail}`;
330
+ console.error(msg);
331
+ return { error: msg };
332
+ }
333
+ }
334
+
335
+ /**
336
+ * High-level withdrawal flow:
337
+ * 1. Creates a withdrawal request via the backend API.
338
+ * 2. Polls the request status until it is no longer "pending".
339
+ * 3. Exercises Allocation_Withdraw on the allocation contract to release holdings back to the user.
340
+ */
341
+ export async function withdrawFunds(
342
+ opts: WithdrawFundsOpts,
343
+ returnCommand = false,
344
+ ): Promise<Record<string, unknown>> {
345
+ const { amount, pollIntervalMs = 2000, maxPollAttempts = 30 } = opts || {};
346
+ const asset_id = normalizeAssetId(opts?.asset_id);
347
+
348
+ const sender = getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
349
+ if (!sender) {
350
+ const msg = "withdrawFunds: sender party is required. Connect a wallet adapter or configure VALIDATOR_USER_PARTY_ID.";
351
+ console.error(msg);
352
+ return { error: msg };
353
+ }
354
+
355
+ // Pay any outstanding network gas first so the fee-reserve check below is accurate.
356
+ await payDueGasIfAny();
357
+
358
+ // Ensure the user has enough CC unlocked to pay gas for the eventual Allocation_Withdraw.
359
+ const provider = resolveProvider(null);
360
+ const ccBalance = await getUtxoCount(sender, "Amulet", provider);
361
+ const availableCC = ccBalance.unlockedBalance || 0;
362
+ if (availableCC < CC_FEE_RESERVE) {
363
+ return {
364
+ error: `withdrawFunds: insufficient CC for fees. You have ${availableCC} CC but need at least ${CC_FEE_RESERVE} CC to cover transaction fees.`,
365
+ data: { ccBalance: availableCC, feeReserve: CC_FEE_RESERVE },
366
+ };
367
+ }
368
+
369
+ // 1. Submit the withdrawal request
370
+ const createResult = (await createWithdrawalRequest(asset_id, amount)) as Record<string, unknown>;
371
+ if (createResult?.error) {
372
+ return createResult;
373
+ }
374
+
375
+ const requestId = createResult?.request_id;
376
+ if (requestId == null) {
377
+ const msg = "withdrawFunds: backend did not return a request_id";
378
+ console.error(msg);
379
+ return { error: msg };
380
+ }
381
+
382
+ console.log(`withdrawFunds: withdrawal request ${requestId} submitted, polling for status...`);
383
+
384
+ // 2. Poll until status is "ready" and allocation_cid is available
385
+ let allocationCid: string | null = null;
386
+ for (let attempt = 1; attempt <= maxPollAttempts; attempt++) {
387
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
388
+
389
+ const status = (await getWithdrawalRequestStatus(String(requestId))) as Record<string, unknown>;
390
+ if (status?.error) {
391
+ return status;
392
+ }
393
+
394
+ // Terminal failure states — stop polling
395
+ if (status.status === "failed" || status.status === "rejected") {
396
+ console.error(`withdrawFunds: request ${requestId} ${status.status}`);
397
+ return status;
398
+ }
399
+
400
+ // Ready with an allocation_cid — proceed to withdraw
401
+ if (status.status === "ready" && status.allocation_cid) {
402
+ console.log(`withdrawFunds: request ${requestId} ready — allocation_cid: ${status.allocation_cid}`);
403
+ allocationCid = status.allocation_cid as string;
404
+ break;
405
+ }
406
+
407
+ if (attempt % 5 === 0) {
408
+ console.log(`withdrawFunds: still waiting (status=${status.status}) after ${attempt} poll(s)...`);
409
+ }
410
+ }
411
+
412
+ if (!allocationCid) {
413
+ const msg = `withdrawFunds: request ${requestId} not ready after ${maxPollAttempts} attempts`;
414
+ console.error(msg);
415
+ return { error: msg, request_id: requestId };
416
+ }
417
+
418
+ // 3. Exercise Allocation_Withdraw to release held funds back to the user
419
+ console.log(`withdrawFunds: exercising Allocation_Withdraw on ${allocationCid}...`);
420
+ const assetId = asset_id;
421
+ const withdrawResult = await finalizeWithdrawFunds({ allocationId: allocationCid, sender, assetId }, returnCommand);
422
+
423
+ if (withdrawResult?.error) {
424
+ console.error(`withdrawFunds: Allocation_Withdraw failed for request ${requestId}: ${withdrawResult.error}`);
425
+ return { error: withdrawResult.error as string, request_id: requestId, allocation_cid: allocationCid };
426
+ }
427
+
428
+ console.log(`withdrawFunds: withdrawal complete for request ${requestId}`);
429
+ return { ...withdrawResult, request_id: requestId, allocation_cid: allocationCid };
430
+ }
431
+
432
+ /**
433
+ * Withdraw a Delegation contract (user-initiated).
434
+ *
435
+ * Exercises the WithdrawDelegation choice on the Delegation interface,
436
+ * which archives the delegation and revokes the operator's authority.
437
+ */
438
+ export async function withdrawDelegation(
439
+ delegationId: string | null = null,
440
+ user: string | null = null,
441
+ returnCommand = false,
442
+ ): Promise<Record<string, unknown>> {
443
+ let resolvedUser = user ?? getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
444
+ let resolvedDelegationId = delegationId;
445
+
446
+ // If no delegationId passed, fetch it from the API
447
+ if (!resolvedDelegationId) {
448
+ const result = (await getDelegation()) as unknown as Record<string, unknown>;
449
+ const delegation = result?.delegation as Record<string, unknown> | undefined;
450
+ if (result?.error || !delegation) {
451
+ const msg =
452
+ "withdrawDelegation: could not resolve delegation. Pass delegationId directly or ensure the user is onboarded.";
453
+ console.error(msg);
454
+ return { error: msg };
455
+ }
456
+ resolvedDelegationId = delegation.delegation_cid as string;
457
+ }
458
+ if (!resolvedUser) {
459
+ const msg =
460
+ "withdrawDelegation: user party is required. Pass it directly, set WALLET_ADAPTER, or configure VALIDATOR_USER_PARTY_ID.";
461
+ console.error(msg);
462
+ return { error: msg };
463
+ }
464
+
465
+ const command = {
466
+ commands: [
467
+ {
468
+ ExerciseCommand: {
469
+ templateId: `${config.PACKAGE_NAME_SETTLEMENT_INTERFACE}:Temple.Settlement.Interface.Delegation:Delegation`,
470
+ contractId: normalizeContractId(resolvedDelegationId),
471
+ choice: "WithdrawDelegation",
472
+ choiceArgument: {},
473
+ },
474
+ },
475
+ ],
476
+ commandId: randomUUID(),
477
+ userId: getUserId() || config.AUTH0_USER_ID || "temple",
478
+ applicationId: "temple",
479
+ actAs: [resolvedUser],
480
+ };
481
+
482
+ if (returnCommand) {
483
+ return { command, endpoint: `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait` };
484
+ }
485
+
486
+ // Auto-submit via wallet adapter if available
487
+ if (getWalletAdapter()) {
488
+ try {
489
+ await payDueGasIfAny();
490
+ return (await submitCommand(command)) as Record<string, unknown>;
491
+ } catch (error: unknown) {
492
+ const msg = `withdrawDelegation: wallet adapter submission failed: ${(error as Error).message}`;
493
+ console.error(msg);
494
+ return { error: msg };
495
+ }
496
+ }
497
+
498
+ const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
499
+ const headers = await buildHeaders();
500
+ try {
501
+ const response = await axios.post(endpoint, command, { headers });
502
+ return response.data;
503
+ } catch (error: unknown) {
504
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
505
+ const errorData = axiosErr?.response?.data;
506
+ const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
507
+ const msg = `withdrawDelegation: error submitting command: ${errorDetail}`;
508
+ console.error(msg);
509
+ return { error: msg };
510
+ }
511
+ }