@temple-digital-group/temple-canton-js 2.0.0-beta.9 → 2.0.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 (41) hide show
  1. package/README.md +455 -457
  2. package/dist/canton/deposits.js +4 -1
  3. package/dist/canton/withdrawals.d.ts +20 -0
  4. package/dist/canton/withdrawals.js +284 -3
  5. package/index.js +15 -15
  6. package/package.json +49 -49
  7. package/src/api/config.d.ts +20 -20
  8. package/src/api/index.ts +322 -322
  9. package/src/api/tokenStore.ts +30 -30
  10. package/src/api/types.ts +196 -196
  11. package/src/auth0/index.d.ts +1 -1
  12. package/src/auth0/index.js +50 -50
  13. package/src/canton/deposits.ts +563 -559
  14. package/src/canton/helpers.ts +266 -266
  15. package/src/canton/index.d.ts +41 -41
  16. package/src/canton/index.js +3116 -3294
  17. package/src/canton/instrumentCatalog.d.ts +6 -6
  18. package/src/canton/instrumentCatalog.js +183 -183
  19. package/src/canton/request_schemas/cancel_orders_amulet.json +77 -77
  20. package/src/canton/request_schemas/cancel_orders_utility.json +68 -68
  21. package/src/canton/request_schemas/create_order_proposal_amulet.json +94 -94
  22. package/src/canton/request_schemas/create_order_proposal_utility.json +121 -121
  23. package/src/canton/request_schemas/create_utility_credential.json +31 -31
  24. package/src/canton/request_schemas/execute_transfer_factory.json +43 -43
  25. package/src/canton/request_schemas/get_allocation_factory.json +21 -21
  26. package/src/canton/request_schemas/get_amulet_holdings.json +21 -21
  27. package/src/canton/request_schemas/get_instrument_configurations.json +21 -21
  28. package/src/canton/request_schemas/get_locked_amulet_holdings.json +21 -21
  29. package/src/canton/request_schemas/get_order_proposals.json +21 -21
  30. package/src/canton/request_schemas/get_orders.json +21 -21
  31. package/src/canton/request_schemas/get_sender_credentials.json +22 -22
  32. package/src/canton/request_schemas/get_transfer_factory.json +28 -28
  33. package/src/canton/request_schemas/get_utility_holdings.json +21 -21
  34. package/src/canton/request_schemas/unlock_amulet.json +38 -38
  35. package/src/canton/walletAdapter.d.ts +7 -6
  36. package/src/canton/walletAdapter.js +112 -89
  37. package/src/canton/withdrawals.ts +810 -489
  38. package/src/config/index.d.ts +63 -63
  39. package/src/config/index.js +188 -188
  40. package/src/websocket/index.ts +341 -341
  41. package/src/websocket/ws.d.ts +24 -24
@@ -1,489 +1,810 @@
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 } from "../../src/canton/walletAdapter.js";
11
- import {
12
- randomUUID,
13
- shouldUseLedgerForMetadata,
14
- normalizeContractId,
15
- resolveInstrumentDefinition,
16
- getInstrumentRegistrar,
17
- dedupeDisclosedContracts,
18
- buildHeaders,
19
- DEFAULT_AMULET_CONTEXT_KEYS,
20
- DEFAULT_UTILITY_CONTEXT_KEYS,
21
- } from "./helpers.js";
22
- import type { DisclosedContract, ContractMetadata } from "./helpers.js";
23
- import {
24
- resolveAmuletContext,
25
- resolveUtilityInstrumentConfiguration,
26
- resolveUtilityAllocationFactory,
27
- } from "../../src/canton/index.js";
28
- import { normalizeAssetId } from "../../src/canton/instrumentCatalog.js";
29
-
30
- // ─── Types ───────────────────────────────────────────────────────────────────
31
-
32
- export interface FinalizeWithdrawOpts {
33
- allocationId: string;
34
- sender?: string;
35
- assetId: string;
36
- disclosures?: {
37
- disclosures?: {
38
- choiceContext?: {
39
- choiceContextData?: Record<string, unknown>;
40
- disclosedContracts?: DisclosedContract[];
41
- };
42
- };
43
- choiceContext?: {
44
- choiceContextData?: Record<string, unknown>;
45
- disclosedContracts?: DisclosedContract[];
46
- };
47
- };
48
- userId?: string;
49
- }
50
-
51
- export interface WithdrawFundsOpts {
52
- asset_id: string;
53
- amount: string | number;
54
- pollIntervalMs?: number;
55
- maxPollAttempts?: number;
56
- }
57
-
58
- // ─── Withdrawal Functions ────────────────────────────────────────────────────
59
-
60
- /**
61
- * Finalize a withdrawal by exercising the Allocation_Withdraw choice.
62
- *
63
- * Supports three metadata resolution paths:
64
- * 1. Localhost: resolves context from ledger directly
65
- * 2. Amulet via disclosures (FE/proxy path — no Scan API access)
66
- * 3. Remote: Scan API / Registry API
67
- */
68
- export async function finalizeWithdrawFunds(
69
- opts: FinalizeWithdrawOpts,
70
- returnCommand = false,
71
- ): Promise<Record<string, unknown>> {
72
- const { allocationId, sender: senderOpt, assetId: rawAssetId, disclosures, userId } = opts;
73
- const assetId = normalizeAssetId(rawAssetId);
74
- const sender = getAdapterPartyId() ?? senderOpt ?? config.VALIDATOR_USER_PARTY_ID;
75
-
76
- if (!allocationId || !sender || !assetId) {
77
- const msg = "finalizeWithdrawFunds: allocationId, sender, and assetId are required";
78
- console.error(msg);
79
- return { error: msg };
80
- }
81
-
82
- const isAmulet = assetId === "Amulet";
83
-
84
- const allocationCid = normalizeContractId(allocationId) as string;
85
- let choiceContextData: Record<string, unknown> = {};
86
- let disclosedContracts: DisclosedContract[] = [];
87
-
88
- if (shouldUseLedgerForMetadata()) {
89
- // Localhost: resolve context from ledger directly
90
- if (isAmulet) {
91
- const amuletCtx = await resolveAmuletContext({ investor: sender, holdingIds: [], transferAmount: 0 });
92
- if (!amuletCtx) {
93
- const msg = "finalizeWithdrawFunds: failed to resolve Amulet context from ledger";
94
- console.error(msg);
95
- return { error: msg };
96
- }
97
- const ctx = amuletCtx as Record<string, unknown>;
98
- const contextKeys = ctx.contextKeys as Record<string, string>;
99
- const amuletRules = ctx.amuletRules as ContractMetadata;
100
- const openMiningRound = ctx.openMiningRound as ContractMetadata;
101
- const featuredAppRight = ctx.featuredAppRight as ContractMetadata;
102
- const externalAmuletRules = ctx.externalAmuletRules as ContractMetadata;
103
-
104
- choiceContextData = {
105
- values: {
106
- [contextKeys.amuletRules]: { tag: "AV_ContractId", value: amuletRules.contractCid },
107
- [contextKeys.openRound]: { tag: "AV_ContractId", value: openMiningRound.contractCid },
108
- [contextKeys.featuredAppRight]: { tag: "AV_ContractId", value: featuredAppRight.contractCid },
109
- [contextKeys.expireLock]: { tag: "AV_Bool", value: true },
110
- },
111
- };
112
- disclosedContracts = [
113
- {
114
- templateId: amuletRules.templateId ?? null,
115
- contractId: amuletRules.contractCid,
116
- createdEventBlob: amuletRules.disclosureCid,
117
- synchronizerId: amuletRules.synchronizerId,
118
- },
119
- {
120
- templateId: openMiningRound.templateId ?? null,
121
- contractId: openMiningRound.contractCid,
122
- createdEventBlob: openMiningRound.disclosureCid,
123
- synchronizerId: openMiningRound.synchronizerId,
124
- },
125
- {
126
- templateId: externalAmuletRules.templateId ?? null,
127
- contractId: externalAmuletRules.contractCid,
128
- createdEventBlob: externalAmuletRules.disclosureCid,
129
- synchronizerId: externalAmuletRules.synchronizerId,
130
- },
131
- ];
132
- if (featuredAppRight?.contractCid && featuredAppRight?.disclosureCid) {
133
- disclosedContracts.push({
134
- templateId: featuredAppRight.templateId ?? null,
135
- contractId: featuredAppRight.contractCid,
136
- createdEventBlob: featuredAppRight.disclosureCid,
137
- synchronizerId: featuredAppRight.synchronizerId,
138
- });
139
- }
140
- } else {
141
- const instrumentDef = resolveInstrumentDefinition(assetId);
142
- const networkDef = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
143
- const registrar =
144
- (networkDef?.registrar as string) || getInstrumentRegistrar(assetId) || config.VALIDATOR_REGISTRAR_PARTY_ID;
145
- const [allocFactory, instConfig] = await Promise.all([
146
- resolveUtilityAllocationFactory(registrar, sender),
147
- resolveUtilityInstrumentConfiguration(assetId, registrar),
148
- ]);
149
- if (instConfig) {
150
- choiceContextData = {
151
- values: {
152
- [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfiguration]: {
153
- tag: "AV_ContractId",
154
- value: instConfig.contractCid,
155
- },
156
- [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfigurationPrefixed]: {
157
- tag: "AV_ContractId",
158
- value: instConfig.contractCid,
159
- },
160
- [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentials]: { tag: "AV_List", value: [] },
161
- [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentialsPrefixed]: { tag: "AV_List", value: [] },
162
- [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentials]: { tag: "AV_List", value: [] },
163
- [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentialsPrefixed]: { tag: "AV_List", value: [] },
164
- [DEFAULT_AMULET_CONTEXT_KEYS.expireLock]: { tag: "AV_Bool", value: true },
165
- },
166
- };
167
- disclosedContracts.push({
168
- templateId: null,
169
- contractId: instConfig.contractCid,
170
- createdEventBlob: instConfig.disclosureCid,
171
- synchronizerId: instConfig.synchronizerId,
172
- });
173
- }
174
- if (allocFactory) {
175
- disclosedContracts.push({
176
- templateId: null,
177
- contractId: allocFactory.contractCid,
178
- createdEventBlob: allocFactory.disclosureCid,
179
- synchronizerId: allocFactory.synchronizerId,
180
- });
181
- }
182
- }
183
- } else if (isAmulet && !config.VALIDATOR_SCAN_API_URL) {
184
- // Amulet via disclosures (FE/proxy path — no Scan API access)
185
- // Use pre-fetched disclosures if provided, otherwise fetch from API
186
- const factoryData = (disclosures as Record<string, unknown>)?.disclosures || disclosures || null;
187
- let resolvedData = factoryData as Record<string, unknown> | null;
188
-
189
- if (!resolvedData?.choiceContext) {
190
- const disclosuresResult = (await getDisclosures(sender)) as unknown as Record<string, unknown>;
191
- resolvedData = disclosuresResult?.disclosures as Record<string, unknown>;
192
- if (disclosuresResult?.error || !resolvedData?.choiceContext) {
193
- const detail =
194
- (disclosuresResult?.message as string) ||
195
- (disclosuresResult?.error as string) ||
196
- "missing choiceContext in response";
197
- const msg = `finalizeWithdrawFunds: failed to resolve Amulet disclosures: ${detail}`;
198
- console.error(msg);
199
- return { error: msg };
200
- }
201
- }
202
-
203
- const choiceContext = resolvedData.choiceContext as Record<string, unknown>;
204
- choiceContextData = (choiceContext.choiceContextData as Record<string, unknown>) || {};
205
- // Ensure expire-lock is present (required by Allocation_Withdraw but not always in API response)
206
- const values = choiceContextData.values as Record<string, unknown> | undefined;
207
- if (values && !values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock]) {
208
- values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock] = { tag: "AV_Bool", value: true };
209
- }
210
- disclosedContracts = ((choiceContext.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
211
- templateId: dc.templateId,
212
- contractId: dc.contractId,
213
- createdEventBlob: dc.createdEventBlob,
214
- synchronizerId: dc.synchronizerId,
215
- }));
216
- } else {
217
- // Remote: fetch choice context from Scan API / Registry API
218
- let baseUrl: string;
219
- let contextHeaders: Record<string, string> = {};
220
-
221
- if (isAmulet) {
222
- if (!config.VALIDATOR_SCAN_API_URL) {
223
- const msg = "finalizeWithdrawFunds: VALIDATOR_SCAN_API_URL is required for Amulet allocations";
224
- console.error(msg);
225
- return { error: msg };
226
- }
227
- baseUrl = `${config.VALIDATOR_SCAN_API_URL}/registry`;
228
- contextHeaders = await buildHeaders();
229
- } else {
230
- const instrumentDef = resolveInstrumentDefinition(assetId);
231
- const networkContracts = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
232
- const registryAPI = networkContracts?.registryAPI as string | undefined;
233
-
234
- if (!registryAPI) {
235
- const msg = `finalizeWithdrawFunds: no registryAPI defined for ${assetId} on ${config.NETWORK}`;
236
- console.error(msg);
237
- return { error: msg };
238
- }
239
- baseUrl = registryAPI;
240
- }
241
-
242
- const contextUrl = `${baseUrl}/allocations/v1/${encodeURIComponent(allocationCid)}/choice-contexts/withdraw`;
243
- let contextData: Record<string, unknown>;
244
- try {
245
- const contextResponse = await axios.post(
246
- contextUrl,
247
- {
248
- meta: {},
249
- excludeDebugFields: true,
250
- },
251
- Object.keys(contextHeaders).length > 0 ? { headers: contextHeaders } : undefined,
252
- );
253
- contextData = contextResponse.data;
254
- } catch (error: unknown) {
255
- const axiosErr = error as { response?: { data?: unknown }; message?: string };
256
- const detail = axiosErr.response?.data ? JSON.stringify(axiosErr.response.data) : axiosErr.message;
257
- const msg = `finalizeWithdrawFunds: error fetching withdraw context from ${isAmulet ? "Scan API" : "Registry API"}: ${detail}`;
258
- console.error(msg);
259
- return { error: msg };
260
- }
261
-
262
- choiceContextData = (contextData?.choiceContextData as Record<string, unknown>) || {};
263
- disclosedContracts = ((contextData?.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
264
- templateId: dc.templateId,
265
- contractId: dc.contractId,
266
- createdEventBlob: dc.createdEventBlob,
267
- synchronizerId: dc.synchronizerId,
268
- }));
269
- }
270
-
271
- // --- Build the ExerciseCommand for Allocation_Withdraw ---
272
- const command = {
273
- commands: [
274
- {
275
- ExerciseCommand: {
276
- templateId: "#splice-api-token-allocation-v1:Splice.Api.Token.AllocationV1:Allocation",
277
- contractId: allocationCid,
278
- choice: "Allocation_Withdraw",
279
- choiceArgument: {
280
- extraArgs: {
281
- context: choiceContextData,
282
- meta: { values: {} },
283
- },
284
- },
285
- },
286
- },
287
- ],
288
- commandId: randomUUID(),
289
- userId: userId || getUserId() || config.AUTH0_USER_ID || "temple",
290
- applicationId: "temple",
291
- actAs: [sender],
292
- disclosedContracts: disclosedContracts,
293
- };
294
-
295
- dedupeDisclosedContracts(command);
296
-
297
- const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
298
-
299
- if (returnCommand) {
300
- return { command, endpoint };
301
- }
302
-
303
- // Auto-submit via wallet adapter if available
304
- if (getWalletAdapter()) {
305
- try {
306
- return (await submitCommand(command)) as Record<string, unknown>;
307
- } catch (error: unknown) {
308
- const msg = `finalizeWithdrawFunds: wallet adapter submission failed: ${(error as Error).message}`;
309
- console.error(msg);
310
- return { error: msg };
311
- }
312
- }
313
-
314
- const headers = await buildHeaders();
315
- try {
316
- const response = await axios.post(endpoint, command, { headers });
317
- return response.data;
318
- } catch (error: unknown) {
319
- const axiosErr = error as { response?: { data?: unknown }; message?: string };
320
- const errorData = axiosErr?.response?.data;
321
- const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
322
- const msg = `finalizeWithdrawFunds: error submitting command: ${errorDetail}`;
323
- console.error(msg);
324
- return { error: msg };
325
- }
326
- }
327
-
328
- /**
329
- * High-level withdrawal flow:
330
- * 1. Creates a withdrawal request via the backend API.
331
- * 2. Polls the request status until it is no longer "pending".
332
- * 3. Exercises Allocation_Withdraw on the allocation contract to release holdings back to the user.
333
- */
334
- export async function withdrawFunds(
335
- opts: WithdrawFundsOpts,
336
- returnCommand = false,
337
- ): Promise<Record<string, unknown>> {
338
- const { amount, pollIntervalMs = 2000, maxPollAttempts = 30 } = opts || {};
339
- const asset_id = normalizeAssetId(opts?.asset_id);
340
-
341
- const sender = getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
342
- if (!sender) {
343
- const msg = "withdrawFunds: sender party is required. Connect a wallet adapter or configure VALIDATOR_USER_PARTY_ID.";
344
- console.error(msg);
345
- return { error: msg };
346
- }
347
-
348
- // 1. Submit the withdrawal request
349
- const createResult = (await createWithdrawalRequest(asset_id, amount)) as Record<string, unknown>;
350
- if (createResult?.error) {
351
- return createResult;
352
- }
353
-
354
- const requestId = createResult?.request_id;
355
- if (requestId == null) {
356
- const msg = "withdrawFunds: backend did not return a request_id";
357
- console.error(msg);
358
- return { error: msg };
359
- }
360
-
361
- console.log(`withdrawFunds: withdrawal request ${requestId} submitted, polling for status...`);
362
-
363
- // 2. Poll until status is "ready" and allocation_cid is available
364
- let allocationCid: string | null = null;
365
- for (let attempt = 1; attempt <= maxPollAttempts; attempt++) {
366
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
367
-
368
- const status = (await getWithdrawalRequestStatus(String(requestId))) as Record<string, unknown>;
369
- if (status?.error) {
370
- return status;
371
- }
372
-
373
- // Terminal failure states — stop polling
374
- if (status.status === "failed" || status.status === "rejected") {
375
- console.error(`withdrawFunds: request ${requestId} ${status.status}`);
376
- return status;
377
- }
378
-
379
- // Ready with an allocation_cid — proceed to withdraw
380
- if (status.status === "ready" && status.allocation_cid) {
381
- console.log(`withdrawFunds: request ${requestId} ready — allocation_cid: ${status.allocation_cid}`);
382
- allocationCid = status.allocation_cid as string;
383
- break;
384
- }
385
-
386
- if (attempt % 5 === 0) {
387
- console.log(`withdrawFunds: still waiting (status=${status.status}) after ${attempt} poll(s)...`);
388
- }
389
- }
390
-
391
- if (!allocationCid) {
392
- const msg = `withdrawFunds: request ${requestId} not ready after ${maxPollAttempts} attempts`;
393
- console.error(msg);
394
- return { error: msg, request_id: requestId };
395
- }
396
-
397
- // 3. Exercise Allocation_Withdraw to release held funds back to the user
398
- console.log(`withdrawFunds: exercising Allocation_Withdraw on ${allocationCid}...`);
399
- const assetId = asset_id;
400
- const withdrawResult = await finalizeWithdrawFunds({ allocationId: allocationCid, sender, assetId }, returnCommand);
401
-
402
- if (withdrawResult?.error) {
403
- console.error(`withdrawFunds: Allocation_Withdraw failed for request ${requestId}: ${withdrawResult.error}`);
404
- return { error: withdrawResult.error as string, request_id: requestId, allocation_cid: allocationCid };
405
- }
406
-
407
- console.log(`withdrawFunds: withdrawal complete for request ${requestId}`);
408
- return { ...withdrawResult, request_id: requestId, allocation_cid: allocationCid };
409
- }
410
-
411
- /**
412
- * Withdraw a Delegation contract (user-initiated).
413
- *
414
- * Exercises the WithdrawDelegation choice on the Delegation interface,
415
- * which archives the delegation and revokes the operator's authority.
416
- */
417
- export async function withdrawDelegation(
418
- delegationId: string | null = null,
419
- user: string | null = null,
420
- returnCommand = false,
421
- ): Promise<Record<string, unknown>> {
422
- let resolvedUser = user ?? getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
423
- let resolvedDelegationId = delegationId;
424
-
425
- // If no delegationId passed, fetch it from the API
426
- if (!resolvedDelegationId) {
427
- const result = (await getDelegation()) as unknown as Record<string, unknown>;
428
- const delegation = result?.delegation as Record<string, unknown> | undefined;
429
- if (result?.error || !delegation) {
430
- const msg =
431
- "withdrawDelegation: could not resolve delegation. Pass delegationId directly or ensure the user is onboarded.";
432
- console.error(msg);
433
- return { error: msg };
434
- }
435
- resolvedDelegationId = delegation.delegation_cid as string;
436
- }
437
- if (!resolvedUser) {
438
- const msg =
439
- "withdrawDelegation: user party is required. Pass it directly, set WALLET_ADAPTER, or configure VALIDATOR_USER_PARTY_ID.";
440
- console.error(msg);
441
- return { error: msg };
442
- }
443
-
444
- const command = {
445
- commands: [
446
- {
447
- ExerciseCommand: {
448
- templateId: `${config.PACKAGE_NAME_SETTLEMENT_INTERFACE}:Temple.Settlement.Interface.Delegation:Delegation`,
449
- contractId: normalizeContractId(resolvedDelegationId),
450
- choice: "WithdrawDelegation",
451
- choiceArgument: {},
452
- },
453
- },
454
- ],
455
- commandId: randomUUID(),
456
- userId: getUserId() || config.AUTH0_USER_ID || "temple",
457
- applicationId: "temple",
458
- actAs: [resolvedUser],
459
- };
460
-
461
- if (returnCommand) {
462
- return { command, endpoint: `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait` };
463
- }
464
-
465
- // Auto-submit via wallet adapter if available
466
- if (getWalletAdapter()) {
467
- try {
468
- return (await submitCommand(command)) as Record<string, unknown>;
469
- } catch (error: unknown) {
470
- const msg = `withdrawDelegation: wallet adapter submission failed: ${(error as Error).message}`;
471
- console.error(msg);
472
- return { error: msg };
473
- }
474
- }
475
-
476
- const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
477
- const headers = await buildHeaders();
478
- try {
479
- const response = await axios.post(endpoint, command, { headers });
480
- return response.data;
481
- } catch (error: unknown) {
482
- const axiosErr = error as { response?: { data?: unknown }; message?: string };
483
- const errorData = axiosErr?.response?.data;
484
- const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
485
- const msg = `withdrawDelegation: error submitting command: ${errorDetail}`;
486
- console.error(msg);
487
- return { error: msg };
488
- }
489
- }
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 BulkWithdrawFundsOpts {
58
+ allocationIds: string[];
59
+ sender?: string;
60
+ assetId: string;
61
+ disclosures?: FinalizeWithdrawOpts["disclosures"];
62
+ userId?: string;
63
+ }
64
+
65
+ export interface WithdrawFundsOpts {
66
+ asset_id: string;
67
+ amount: string | number;
68
+ pollIntervalMs?: number;
69
+ maxPollAttempts?: number;
70
+ }
71
+
72
+ // ─── Withdrawal Functions ────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Finalize a withdrawal by exercising the Allocation_Withdraw choice.
76
+ *
77
+ * Supports three metadata resolution paths:
78
+ * 1. Localhost: resolves context from ledger directly
79
+ * 2. Amulet via disclosures (FE/proxy path — no Scan API access)
80
+ * 3. Remote: Scan API / Registry API
81
+ */
82
+ export async function finalizeWithdrawFunds(
83
+ opts: FinalizeWithdrawOpts,
84
+ returnCommand = false,
85
+ ): Promise<Record<string, unknown>> {
86
+ const { allocationId, sender: senderOpt, assetId: rawAssetId, disclosures, userId } = opts;
87
+ const assetId = normalizeAssetId(rawAssetId);
88
+ const sender = getAdapterPartyId() ?? senderOpt ?? config.VALIDATOR_USER_PARTY_ID;
89
+
90
+ if (!allocationId || !sender || !assetId) {
91
+ const msg = "finalizeWithdrawFunds: allocationId, sender, and assetId are required";
92
+ console.error(msg);
93
+ return { error: msg };
94
+ }
95
+
96
+ const isAmulet = assetId === "Amulet";
97
+
98
+ const allocationCid = normalizeContractId(allocationId) as string;
99
+ let choiceContextData: Record<string, unknown> = {};
100
+ let disclosedContracts: DisclosedContract[] = [];
101
+
102
+ if (shouldUseLedgerForMetadata()) {
103
+ // Localhost: resolve context from ledger directly
104
+ if (isAmulet) {
105
+ const amuletCtx = await resolveAmuletContext({ investor: sender, holdingIds: [], transferAmount: 0 });
106
+ if (!amuletCtx) {
107
+ const msg = "finalizeWithdrawFunds: failed to resolve Amulet context from ledger";
108
+ console.error(msg);
109
+ return { error: msg };
110
+ }
111
+ const ctx = amuletCtx as Record<string, unknown>;
112
+ const contextKeys = ctx.contextKeys as Record<string, string>;
113
+ const amuletRules = ctx.amuletRules as ContractMetadata;
114
+ const openMiningRound = ctx.openMiningRound as ContractMetadata;
115
+ const featuredAppRight = ctx.featuredAppRight as ContractMetadata;
116
+ const externalAmuletRules = ctx.externalAmuletRules as ContractMetadata;
117
+
118
+ choiceContextData = {
119
+ values: {
120
+ [contextKeys.amuletRules]: { tag: "AV_ContractId", value: amuletRules.contractCid },
121
+ [contextKeys.openRound]: { tag: "AV_ContractId", value: openMiningRound.contractCid },
122
+ [contextKeys.featuredAppRight]: { tag: "AV_ContractId", value: featuredAppRight.contractCid },
123
+ [contextKeys.expireLock]: { tag: "AV_Bool", value: true },
124
+ },
125
+ };
126
+ disclosedContracts = [
127
+ {
128
+ templateId: amuletRules.templateId ?? null,
129
+ contractId: amuletRules.contractCid,
130
+ createdEventBlob: amuletRules.disclosureCid,
131
+ synchronizerId: amuletRules.synchronizerId,
132
+ },
133
+ {
134
+ templateId: openMiningRound.templateId ?? null,
135
+ contractId: openMiningRound.contractCid,
136
+ createdEventBlob: openMiningRound.disclosureCid,
137
+ synchronizerId: openMiningRound.synchronizerId,
138
+ },
139
+ {
140
+ templateId: externalAmuletRules.templateId ?? null,
141
+ contractId: externalAmuletRules.contractCid,
142
+ createdEventBlob: externalAmuletRules.disclosureCid,
143
+ synchronizerId: externalAmuletRules.synchronizerId,
144
+ },
145
+ ];
146
+ if (featuredAppRight?.contractCid && featuredAppRight?.disclosureCid) {
147
+ disclosedContracts.push({
148
+ templateId: featuredAppRight.templateId ?? null,
149
+ contractId: featuredAppRight.contractCid,
150
+ createdEventBlob: featuredAppRight.disclosureCid,
151
+ synchronizerId: featuredAppRight.synchronizerId,
152
+ });
153
+ }
154
+ } else {
155
+ const instrumentDef = resolveInstrumentDefinition(assetId);
156
+ const networkDef = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
157
+ const registrar =
158
+ (networkDef?.registrar as string) || getInstrumentRegistrar(assetId) || config.VALIDATOR_REGISTRAR_PARTY_ID;
159
+ const [allocFactory, instConfig] = await Promise.all([
160
+ resolveUtilityAllocationFactory(registrar, sender),
161
+ resolveUtilityInstrumentConfiguration(assetId, registrar),
162
+ ]);
163
+ if (instConfig) {
164
+ choiceContextData = {
165
+ values: {
166
+ [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfiguration]: {
167
+ tag: "AV_ContractId",
168
+ value: instConfig.contractCid,
169
+ },
170
+ [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfigurationPrefixed]: {
171
+ tag: "AV_ContractId",
172
+ value: instConfig.contractCid,
173
+ },
174
+ [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentials]: { tag: "AV_List", value: [] },
175
+ [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentialsPrefixed]: { tag: "AV_List", value: [] },
176
+ [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentials]: { tag: "AV_List", value: [] },
177
+ [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentialsPrefixed]: { tag: "AV_List", value: [] },
178
+ [DEFAULT_AMULET_CONTEXT_KEYS.expireLock]: { tag: "AV_Bool", value: true },
179
+ },
180
+ };
181
+ disclosedContracts.push({
182
+ templateId: null,
183
+ contractId: instConfig.contractCid,
184
+ createdEventBlob: instConfig.disclosureCid,
185
+ synchronizerId: instConfig.synchronizerId,
186
+ });
187
+ }
188
+ if (allocFactory) {
189
+ disclosedContracts.push({
190
+ templateId: null,
191
+ contractId: allocFactory.contractCid,
192
+ createdEventBlob: allocFactory.disclosureCid,
193
+ synchronizerId: allocFactory.synchronizerId,
194
+ });
195
+ }
196
+ }
197
+ } else if (isAmulet && !config.VALIDATOR_SCAN_API_URL) {
198
+ // Amulet via disclosures (FE/proxy path — no Scan API access)
199
+ // Use pre-fetched disclosures if provided, otherwise fetch from API
200
+ const factoryData = (disclosures as Record<string, unknown>)?.disclosures || disclosures || null;
201
+ let resolvedData = factoryData as Record<string, unknown> | null;
202
+
203
+ if (!resolvedData?.choiceContext) {
204
+ const disclosuresResult = (await getDisclosures(sender)) as unknown as Record<string, unknown>;
205
+ resolvedData = disclosuresResult?.disclosures as Record<string, unknown>;
206
+ if (disclosuresResult?.error || !resolvedData?.choiceContext) {
207
+ const detail =
208
+ (disclosuresResult?.message as string) ||
209
+ (disclosuresResult?.error as string) ||
210
+ "missing choiceContext in response";
211
+ const msg = `finalizeWithdrawFunds: failed to resolve Amulet disclosures: ${detail}`;
212
+ console.error(msg);
213
+ return { error: msg };
214
+ }
215
+ }
216
+
217
+ const choiceContext = resolvedData.choiceContext as Record<string, unknown>;
218
+ choiceContextData = (choiceContext.choiceContextData as Record<string, unknown>) || {};
219
+ // Ensure expire-lock is present (required by Allocation_Withdraw but not always in API response)
220
+ const values = choiceContextData.values as Record<string, unknown> | undefined;
221
+ if (values && !values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock]) {
222
+ values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock] = { tag: "AV_Bool", value: true };
223
+ }
224
+ disclosedContracts = ((choiceContext.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
225
+ templateId: dc.templateId,
226
+ contractId: dc.contractId,
227
+ createdEventBlob: dc.createdEventBlob,
228
+ synchronizerId: dc.synchronizerId,
229
+ }));
230
+ } else {
231
+ // Remote: fetch choice context from Scan API / Registry API
232
+ let baseUrl: string;
233
+ let contextHeaders: Record<string, string> = {};
234
+
235
+ if (isAmulet) {
236
+ if (!config.VALIDATOR_SCAN_API_URL) {
237
+ const msg = "finalizeWithdrawFunds: VALIDATOR_SCAN_API_URL is required for Amulet allocations";
238
+ console.error(msg);
239
+ return { error: msg };
240
+ }
241
+ baseUrl = `${config.VALIDATOR_SCAN_API_URL}/registry`;
242
+ contextHeaders = await buildHeaders();
243
+ } else {
244
+ const instrumentDef = resolveInstrumentDefinition(assetId);
245
+ const networkContracts = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
246
+ const registryAPI = networkContracts?.registryAPI as string | undefined;
247
+
248
+ if (!registryAPI) {
249
+ const msg = `finalizeWithdrawFunds: no registryAPI defined for ${assetId} on ${config.NETWORK}`;
250
+ console.error(msg);
251
+ return { error: msg };
252
+ }
253
+ baseUrl = registryAPI;
254
+ }
255
+
256
+ const contextUrl = `${baseUrl}/allocations/v1/${encodeURIComponent(allocationCid)}/choice-contexts/withdraw`;
257
+ let contextData: Record<string, unknown>;
258
+ try {
259
+ const contextResponse = await axios.post(
260
+ contextUrl,
261
+ {
262
+ meta: {},
263
+ excludeDebugFields: true,
264
+ },
265
+ Object.keys(contextHeaders).length > 0 ? { headers: contextHeaders } : undefined,
266
+ );
267
+ contextData = contextResponse.data;
268
+ } catch (error: unknown) {
269
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
270
+ const detail = axiosErr.response?.data ? JSON.stringify(axiosErr.response.data) : axiosErr.message;
271
+ const msg = `finalizeWithdrawFunds: error fetching withdraw context from ${isAmulet ? "Scan API" : "Registry API"}: ${detail}`;
272
+ console.error(msg);
273
+ return { error: msg };
274
+ }
275
+
276
+ choiceContextData = (contextData?.choiceContextData as Record<string, unknown>) || {};
277
+ disclosedContracts = ((contextData?.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
278
+ templateId: dc.templateId,
279
+ contractId: dc.contractId,
280
+ createdEventBlob: dc.createdEventBlob,
281
+ synchronizerId: dc.synchronizerId,
282
+ }));
283
+ }
284
+
285
+ // --- Build the ExerciseCommand for Allocation_Withdraw ---
286
+ const command = {
287
+ commands: [
288
+ {
289
+ ExerciseCommand: {
290
+ templateId: "#splice-api-token-allocation-v1:Splice.Api.Token.AllocationV1:Allocation",
291
+ contractId: allocationCid,
292
+ choice: "Allocation_Withdraw",
293
+ choiceArgument: {
294
+ extraArgs: {
295
+ context: choiceContextData,
296
+ meta: { values: {} },
297
+ },
298
+ },
299
+ },
300
+ },
301
+ ],
302
+ commandId: randomUUID(),
303
+ userId: userId || getUserId() || config.AUTH0_USER_ID || "temple",
304
+ applicationId: "temple",
305
+ actAs: [sender],
306
+ disclosedContracts: disclosedContracts,
307
+ };
308
+
309
+ dedupeDisclosedContracts(command);
310
+
311
+ const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
312
+
313
+ if (returnCommand) {
314
+ return { command, endpoint };
315
+ }
316
+
317
+ // Auto-submit via wallet adapter if available
318
+ if (getWalletAdapter()) {
319
+ try {
320
+ await payDueGasIfAny();
321
+ return (await submitCommand(command)) as Record<string, unknown>;
322
+ } catch (error: unknown) {
323
+ const msg = `finalizeWithdrawFunds: wallet adapter submission failed: ${(error as Error).message}`;
324
+ console.error(msg);
325
+ return { error: msg };
326
+ }
327
+ }
328
+
329
+ const headers = await buildHeaders();
330
+ try {
331
+ const response = await axios.post(endpoint, command, { headers });
332
+ return response.data;
333
+ } catch (error: unknown) {
334
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
335
+ const errorData = axiosErr?.response?.data;
336
+ const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
337
+ const msg = `finalizeWithdrawFunds: error submitting command: ${errorDetail}`;
338
+ console.error(msg);
339
+ return { error: msg };
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Finalize multiple withdrawals in a single signed transaction.
345
+ *
346
+ * Bundles N Allocation_Withdraw exercises into one ledger submission so the user
347
+ * signs once for the whole batch. All allocations must belong to the same sender
348
+ * and the same asset (caller's responsibility — pass one assetId).
349
+ *
350
+ * Choice-context fetching:
351
+ * - Localhost ledger / Amulet via /api/amulet/disclosures: one shared context for all N.
352
+ * - Scan API (Amulet) / Registry API (utility): one fetch per allocation, then disclosed
353
+ * contracts merged and deduped.
354
+ */
355
+ export async function bulkWithdrawFunds(
356
+ opts: BulkWithdrawFundsOpts,
357
+ returnCommand = false,
358
+ ): Promise<Record<string, unknown>> {
359
+ const { allocationIds, sender: senderOpt, assetId: rawAssetId, disclosures, userId } = opts;
360
+ const assetId = normalizeAssetId(rawAssetId);
361
+ const sender = getAdapterPartyId() ?? senderOpt ?? config.VALIDATOR_USER_PARTY_ID;
362
+
363
+ if (!Array.isArray(allocationIds) || allocationIds.length === 0) {
364
+ const msg = "bulkWithdrawFunds: allocationIds must be a non-empty array";
365
+ console.error(msg);
366
+ return { error: msg };
367
+ }
368
+ if (!sender || !assetId) {
369
+ const msg = "bulkWithdrawFunds: sender and assetId are required";
370
+ console.error(msg);
371
+ return { error: msg };
372
+ }
373
+
374
+ const isAmulet = assetId === "Amulet";
375
+ const allocationCids = allocationIds.map((cid) => normalizeContractId(cid) as string);
376
+
377
+ // Per-allocation choice context (one entry per allocation, in the same order).
378
+ // Shared-context paths fill all N entries with the same object.
379
+ let perAllocContexts: Record<string, unknown>[] = [];
380
+ let mergedDisclosedContracts: DisclosedContract[] = [];
381
+
382
+ if (shouldUseLedgerForMetadata()) {
383
+ // Localhost: resolve once, reuse for all allocations.
384
+ let sharedContext: Record<string, unknown> = {};
385
+ const sharedDisclosed: DisclosedContract[] = [];
386
+
387
+ if (isAmulet) {
388
+ const amuletCtx = await resolveAmuletContext({ investor: sender, holdingIds: [], transferAmount: 0 });
389
+ if (!amuletCtx) {
390
+ const msg = "bulkWithdrawFunds: failed to resolve Amulet context from ledger";
391
+ console.error(msg);
392
+ return { error: msg };
393
+ }
394
+ const ctx = amuletCtx as Record<string, unknown>;
395
+ const contextKeys = ctx.contextKeys as Record<string, string>;
396
+ const amuletRules = ctx.amuletRules as ContractMetadata;
397
+ const openMiningRound = ctx.openMiningRound as ContractMetadata;
398
+ const featuredAppRight = ctx.featuredAppRight as ContractMetadata;
399
+ const externalAmuletRules = ctx.externalAmuletRules as ContractMetadata;
400
+
401
+ sharedContext = {
402
+ values: {
403
+ [contextKeys.amuletRules]: { tag: "AV_ContractId", value: amuletRules.contractCid },
404
+ [contextKeys.openRound]: { tag: "AV_ContractId", value: openMiningRound.contractCid },
405
+ [contextKeys.featuredAppRight]: { tag: "AV_ContractId", value: featuredAppRight.contractCid },
406
+ [contextKeys.expireLock]: { tag: "AV_Bool", value: true },
407
+ },
408
+ };
409
+ sharedDisclosed.push(
410
+ {
411
+ templateId: amuletRules.templateId ?? null,
412
+ contractId: amuletRules.contractCid,
413
+ createdEventBlob: amuletRules.disclosureCid,
414
+ synchronizerId: amuletRules.synchronizerId,
415
+ },
416
+ {
417
+ templateId: openMiningRound.templateId ?? null,
418
+ contractId: openMiningRound.contractCid,
419
+ createdEventBlob: openMiningRound.disclosureCid,
420
+ synchronizerId: openMiningRound.synchronizerId,
421
+ },
422
+ {
423
+ templateId: externalAmuletRules.templateId ?? null,
424
+ contractId: externalAmuletRules.contractCid,
425
+ createdEventBlob: externalAmuletRules.disclosureCid,
426
+ synchronizerId: externalAmuletRules.synchronizerId,
427
+ },
428
+ );
429
+ if (featuredAppRight?.contractCid && featuredAppRight?.disclosureCid) {
430
+ sharedDisclosed.push({
431
+ templateId: featuredAppRight.templateId ?? null,
432
+ contractId: featuredAppRight.contractCid,
433
+ createdEventBlob: featuredAppRight.disclosureCid,
434
+ synchronizerId: featuredAppRight.synchronizerId,
435
+ });
436
+ }
437
+ } else {
438
+ const instrumentDef = resolveInstrumentDefinition(assetId);
439
+ const networkDef = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
440
+ const registrar =
441
+ (networkDef?.registrar as string) || getInstrumentRegistrar(assetId) || config.VALIDATOR_REGISTRAR_PARTY_ID;
442
+ const [allocFactory, instConfig] = await Promise.all([
443
+ resolveUtilityAllocationFactory(registrar, sender),
444
+ resolveUtilityInstrumentConfiguration(assetId, registrar),
445
+ ]);
446
+ if (instConfig) {
447
+ sharedContext = {
448
+ values: {
449
+ [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfiguration]: {
450
+ tag: "AV_ContractId",
451
+ value: instConfig.contractCid,
452
+ },
453
+ [DEFAULT_UTILITY_CONTEXT_KEYS.instrumentConfigurationPrefixed]: {
454
+ tag: "AV_ContractId",
455
+ value: instConfig.contractCid,
456
+ },
457
+ [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentials]: { tag: "AV_List", value: [] },
458
+ [DEFAULT_UTILITY_CONTEXT_KEYS.senderCredentialsPrefixed]: { tag: "AV_List", value: [] },
459
+ [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentials]: { tag: "AV_List", value: [] },
460
+ [DEFAULT_UTILITY_CONTEXT_KEYS.receiverCredentialsPrefixed]: { tag: "AV_List", value: [] },
461
+ [DEFAULT_AMULET_CONTEXT_KEYS.expireLock]: { tag: "AV_Bool", value: true },
462
+ },
463
+ };
464
+ sharedDisclosed.push({
465
+ templateId: null,
466
+ contractId: instConfig.contractCid,
467
+ createdEventBlob: instConfig.disclosureCid,
468
+ synchronizerId: instConfig.synchronizerId,
469
+ });
470
+ }
471
+ if (allocFactory) {
472
+ sharedDisclosed.push({
473
+ templateId: null,
474
+ contractId: allocFactory.contractCid,
475
+ createdEventBlob: allocFactory.disclosureCid,
476
+ synchronizerId: allocFactory.synchronizerId,
477
+ });
478
+ }
479
+ }
480
+
481
+ perAllocContexts = allocationCids.map(() => sharedContext);
482
+ mergedDisclosedContracts = sharedDisclosed;
483
+ } else if (isAmulet && !config.VALIDATOR_SCAN_API_URL) {
484
+ // Amulet via /api/amulet/disclosures: one fetch shared across all allocations.
485
+ const factoryData = (disclosures as Record<string, unknown>)?.disclosures || disclosures || null;
486
+ let resolvedData = factoryData as Record<string, unknown> | null;
487
+
488
+ if (!resolvedData?.choiceContext) {
489
+ const disclosuresResult = (await getDisclosures(sender)) as unknown as Record<string, unknown>;
490
+ resolvedData = disclosuresResult?.disclosures as Record<string, unknown>;
491
+ if (disclosuresResult?.error || !resolvedData?.choiceContext) {
492
+ const detail =
493
+ (disclosuresResult?.message as string) ||
494
+ (disclosuresResult?.error as string) ||
495
+ "missing choiceContext in response";
496
+ const msg = `bulkWithdrawFunds: failed to resolve Amulet disclosures: ${detail}`;
497
+ console.error(msg);
498
+ return { error: msg };
499
+ }
500
+ }
501
+
502
+ const choiceContext = resolvedData.choiceContext as Record<string, unknown>;
503
+ const sharedContext = (choiceContext.choiceContextData as Record<string, unknown>) || {};
504
+ const values = sharedContext.values as Record<string, unknown> | undefined;
505
+ if (values && !values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock]) {
506
+ values[DEFAULT_AMULET_CONTEXT_KEYS.expireLock] = { tag: "AV_Bool", value: true };
507
+ }
508
+ const sharedDisclosed = ((choiceContext.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
509
+ templateId: dc.templateId,
510
+ contractId: dc.contractId,
511
+ createdEventBlob: dc.createdEventBlob,
512
+ synchronizerId: dc.synchronizerId,
513
+ }));
514
+
515
+ perAllocContexts = allocationCids.map(() => sharedContext);
516
+ mergedDisclosedContracts = sharedDisclosed;
517
+ } else {
518
+ // Per-allocation context: Scan API (Amulet) or Registry API (utility).
519
+ let baseUrl: string;
520
+ let contextHeaders: Record<string, string> = {};
521
+
522
+ if (isAmulet) {
523
+ if (!config.VALIDATOR_SCAN_API_URL) {
524
+ const msg = "bulkWithdrawFunds: VALIDATOR_SCAN_API_URL is required for Amulet allocations";
525
+ console.error(msg);
526
+ return { error: msg };
527
+ }
528
+ baseUrl = `${config.VALIDATOR_SCAN_API_URL}/registry`;
529
+ contextHeaders = await buildHeaders();
530
+ } else {
531
+ const instrumentDef = resolveInstrumentDefinition(assetId);
532
+ const networkContracts = instrumentDef?.[config.NETWORK] as Record<string, unknown> | undefined;
533
+ const registryAPI = networkContracts?.registryAPI as string | undefined;
534
+ if (!registryAPI) {
535
+ const msg = `bulkWithdrawFunds: no registryAPI defined for ${assetId} on ${config.NETWORK}`;
536
+ console.error(msg);
537
+ return { error: msg };
538
+ }
539
+ baseUrl = registryAPI;
540
+ }
541
+
542
+ const requestConfig = Object.keys(contextHeaders).length > 0 ? { headers: contextHeaders } : undefined;
543
+ try {
544
+ const contextResults = await Promise.all(
545
+ allocationCids.map(async (cid) => {
546
+ const url = `${baseUrl}/allocations/v1/${encodeURIComponent(cid)}/choice-contexts/withdraw`;
547
+ try {
548
+ const resp = await axios.post(url, { meta: {}, excludeDebugFields: true }, requestConfig);
549
+ return resp.data as Record<string, unknown>;
550
+ } catch (error: unknown) {
551
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
552
+ const detail = axiosErr.response?.data ? JSON.stringify(axiosErr.response.data) : axiosErr.message;
553
+ throw new Error(
554
+ `bulkWithdrawFunds: error fetching withdraw context for ${cid} from ${isAmulet ? "Scan API" : "Registry API"}: ${detail}`,
555
+ );
556
+ }
557
+ }),
558
+ );
559
+
560
+ for (const data of contextResults) {
561
+ perAllocContexts.push((data?.choiceContextData as Record<string, unknown>) || {});
562
+ const disclosed = ((data?.disclosedContracts as DisclosedContract[]) || []).map((dc) => ({
563
+ templateId: dc.templateId,
564
+ contractId: dc.contractId,
565
+ createdEventBlob: dc.createdEventBlob,
566
+ synchronizerId: dc.synchronizerId,
567
+ }));
568
+ mergedDisclosedContracts.push(...disclosed);
569
+ }
570
+ } catch (error: unknown) {
571
+ const msg = (error as Error).message;
572
+ console.error(msg);
573
+ return { error: msg };
574
+ }
575
+ }
576
+
577
+ // Build one ExerciseCommand per allocation; Canton signs the whole submission atomically.
578
+ const exerciseCommands = allocationCids.map((cid, idx) => ({
579
+ ExerciseCommand: {
580
+ templateId: "#splice-api-token-allocation-v1:Splice.Api.Token.AllocationV1:Allocation",
581
+ contractId: cid,
582
+ choice: "Allocation_Withdraw",
583
+ choiceArgument: {
584
+ extraArgs: {
585
+ context: perAllocContexts[idx],
586
+ meta: { values: {} },
587
+ },
588
+ },
589
+ },
590
+ }));
591
+
592
+ const command = {
593
+ commands: exerciseCommands,
594
+ commandId: randomUUID(),
595
+ userId: userId || getUserId() || config.AUTH0_USER_ID || "temple",
596
+ applicationId: "temple",
597
+ actAs: [sender],
598
+ disclosedContracts: mergedDisclosedContracts,
599
+ };
600
+
601
+ dedupeDisclosedContracts(command);
602
+
603
+ const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
604
+
605
+ if (returnCommand) {
606
+ return { command, endpoint };
607
+ }
608
+
609
+ if (getWalletAdapter()) {
610
+ try {
611
+ await payDueGasIfAny();
612
+ return (await submitCommand(command)) as Record<string, unknown>;
613
+ } catch (error: unknown) {
614
+ const msg = `bulkWithdrawFunds: wallet adapter submission failed: ${(error as Error).message}`;
615
+ console.error(msg);
616
+ return { error: msg };
617
+ }
618
+ }
619
+
620
+ const headers = await buildHeaders();
621
+ try {
622
+ const response = await axios.post(endpoint, command, { headers });
623
+ return response.data;
624
+ } catch (error: unknown) {
625
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
626
+ const errorData = axiosErr?.response?.data;
627
+ const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
628
+ const msg = `bulkWithdrawFunds: error submitting command: ${errorDetail}`;
629
+ console.error(msg);
630
+ return { error: msg };
631
+ }
632
+ }
633
+
634
+ /**
635
+ * High-level withdrawal flow:
636
+ * 1. Creates a withdrawal request via the backend API.
637
+ * 2. Polls the request status until it is no longer "pending".
638
+ * 3. Exercises Allocation_Withdraw on the allocation contract to release holdings back to the user.
639
+ */
640
+ export async function withdrawFunds(
641
+ opts: WithdrawFundsOpts,
642
+ returnCommand = false,
643
+ ): Promise<Record<string, unknown>> {
644
+ const { amount, pollIntervalMs = 2000, maxPollAttempts = 30 } = opts || {};
645
+ const asset_id = normalizeAssetId(opts?.asset_id);
646
+
647
+ const sender = getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
648
+ if (!sender) {
649
+ const msg = "withdrawFunds: sender party is required. Connect a wallet adapter or configure VALIDATOR_USER_PARTY_ID.";
650
+ console.error(msg);
651
+ return { error: msg };
652
+ }
653
+
654
+ // Pay any outstanding network gas first so the fee-reserve check below is accurate.
655
+ await payDueGasIfAny();
656
+
657
+ // Ensure the user has enough CC unlocked to pay gas for the eventual Allocation_Withdraw.
658
+ const provider = resolveProvider(null);
659
+ const ccBalance = await getUtxoCount(sender, "Amulet", provider);
660
+ const availableCC = ccBalance.unlockedBalance || 0;
661
+ if (availableCC < CC_FEE_RESERVE) {
662
+ return {
663
+ error: `withdrawFunds: insufficient CC for fees. You have ${availableCC} CC but need at least ${CC_FEE_RESERVE} CC to cover transaction fees.`,
664
+ data: { ccBalance: availableCC, feeReserve: CC_FEE_RESERVE },
665
+ };
666
+ }
667
+
668
+ // 1. Submit the withdrawal request
669
+ const createResult = (await createWithdrawalRequest(asset_id, amount)) as Record<string, unknown>;
670
+ if (createResult?.error) {
671
+ return createResult;
672
+ }
673
+
674
+ const requestId = createResult?.request_id;
675
+ if (requestId == null) {
676
+ const msg = "withdrawFunds: backend did not return a request_id";
677
+ console.error(msg);
678
+ return { error: msg };
679
+ }
680
+
681
+ console.log(`withdrawFunds: withdrawal request ${requestId} submitted, polling for status...`);
682
+
683
+ // 2. Poll until status is "ready" and allocation_cid is available
684
+ let allocationCid: string | null = null;
685
+ for (let attempt = 1; attempt <= maxPollAttempts; attempt++) {
686
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
687
+
688
+ const status = (await getWithdrawalRequestStatus(String(requestId))) as Record<string, unknown>;
689
+ if (status?.error) {
690
+ return status;
691
+ }
692
+
693
+ // Terminal failure states — stop polling
694
+ if (status.status === "failed" || status.status === "rejected") {
695
+ console.error(`withdrawFunds: request ${requestId} ${status.status}`);
696
+ return status;
697
+ }
698
+
699
+ // Ready with an allocation_cid — proceed to withdraw
700
+ if (status.status === "ready" && status.allocation_cid) {
701
+ console.log(`withdrawFunds: request ${requestId} ready — allocation_cid: ${status.allocation_cid}`);
702
+ allocationCid = status.allocation_cid as string;
703
+ break;
704
+ }
705
+
706
+ if (attempt % 5 === 0) {
707
+ console.log(`withdrawFunds: still waiting (status=${status.status}) after ${attempt} poll(s)...`);
708
+ }
709
+ }
710
+
711
+ if (!allocationCid) {
712
+ const msg = `withdrawFunds: request ${requestId} not ready after ${maxPollAttempts} attempts`;
713
+ console.error(msg);
714
+ return { error: msg, request_id: requestId };
715
+ }
716
+
717
+ // 3. Exercise Allocation_Withdraw to release held funds back to the user
718
+ console.log(`withdrawFunds: exercising Allocation_Withdraw on ${allocationCid}...`);
719
+ const assetId = asset_id;
720
+ const withdrawResult = await finalizeWithdrawFunds({ allocationId: allocationCid, sender, assetId }, returnCommand);
721
+
722
+ if (withdrawResult?.error) {
723
+ console.error(`withdrawFunds: Allocation_Withdraw failed for request ${requestId}: ${withdrawResult.error}`);
724
+ return { error: withdrawResult.error as string, request_id: requestId, allocation_cid: allocationCid };
725
+ }
726
+
727
+ console.log(`withdrawFunds: withdrawal complete for request ${requestId}`);
728
+ return { ...withdrawResult, request_id: requestId, allocation_cid: allocationCid };
729
+ }
730
+
731
+ /**
732
+ * Withdraw a Delegation contract (user-initiated).
733
+ *
734
+ * Exercises the WithdrawDelegation choice on the Delegation interface,
735
+ * which archives the delegation and revokes the operator's authority.
736
+ */
737
+ export async function withdrawDelegation(
738
+ delegationId: string | null = null,
739
+ user: string | null = null,
740
+ returnCommand = false,
741
+ ): Promise<Record<string, unknown>> {
742
+ let resolvedUser = user ?? getAdapterPartyId() ?? config.VALIDATOR_USER_PARTY_ID;
743
+ let resolvedDelegationId = delegationId;
744
+
745
+ // If no delegationId passed, fetch it from the API
746
+ if (!resolvedDelegationId) {
747
+ const result = (await getDelegation()) as unknown as Record<string, unknown>;
748
+ const delegation = result?.delegation as Record<string, unknown> | undefined;
749
+ if (result?.error || !delegation) {
750
+ const msg =
751
+ "withdrawDelegation: could not resolve delegation. Pass delegationId directly or ensure the user is onboarded.";
752
+ console.error(msg);
753
+ return { error: msg };
754
+ }
755
+ resolvedDelegationId = delegation.delegation_cid as string;
756
+ }
757
+ if (!resolvedUser) {
758
+ const msg =
759
+ "withdrawDelegation: user party is required. Pass it directly, set WALLET_ADAPTER, or configure VALIDATOR_USER_PARTY_ID.";
760
+ console.error(msg);
761
+ return { error: msg };
762
+ }
763
+
764
+ const command = {
765
+ commands: [
766
+ {
767
+ ExerciseCommand: {
768
+ templateId: `${config.PACKAGE_NAME_SETTLEMENT_INTERFACE}:Temple.Settlement.Interface.Delegation:Delegation`,
769
+ contractId: normalizeContractId(resolvedDelegationId),
770
+ choice: "WithdrawDelegation",
771
+ choiceArgument: {},
772
+ },
773
+ },
774
+ ],
775
+ commandId: randomUUID(),
776
+ userId: getUserId() || config.AUTH0_USER_ID || "temple",
777
+ applicationId: "temple",
778
+ actAs: [resolvedUser],
779
+ };
780
+
781
+ if (returnCommand) {
782
+ return { command, endpoint: `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait` };
783
+ }
784
+
785
+ // Auto-submit via wallet adapter if available
786
+ if (getWalletAdapter()) {
787
+ try {
788
+ await payDueGasIfAny();
789
+ return (await submitCommand(command)) as Record<string, unknown>;
790
+ } catch (error: unknown) {
791
+ const msg = `withdrawDelegation: wallet adapter submission failed: ${(error as Error).message}`;
792
+ console.error(msg);
793
+ return { error: msg };
794
+ }
795
+ }
796
+
797
+ const endpoint = `${config.VALIDATOR_API_URL}/v2/commands/submit-and-wait`;
798
+ const headers = await buildHeaders();
799
+ try {
800
+ const response = await axios.post(endpoint, command, { headers });
801
+ return response.data;
802
+ } catch (error: unknown) {
803
+ const axiosErr = error as { response?: { data?: unknown }; message?: string };
804
+ const errorData = axiosErr?.response?.data;
805
+ const errorDetail = errorData ? JSON.stringify(errorData, null, 2) : axiosErr.message;
806
+ const msg = `withdrawDelegation: error submitting command: ${errorDetail}`;
807
+ console.error(msg);
808
+ return { error: msg };
809
+ }
810
+ }