@temple-digital-group/temple-canton-js 2.0.3 → 2.0.4-beta.0

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.
@@ -1,3 +1,24 @@
1
+ /**
2
+ * Compute the maximum safe lock-expiration time for an Amulet allocation,
3
+ * given the amulet amount and current network parameters.
4
+ *
5
+ * Mirrors Splice.Expiry.isAmuletExpired + MaxLockExpirationForFreshAmulet.
6
+ *
7
+ * @param amount Amulet amount being locked (e.g. 0.001)
8
+ * @param cfg.holdingFeeRate Per-round holding fee rate (e.g. 0.00002)
9
+ * @param cfg.tickDurationSec Round duration in seconds (e.g. 600)
10
+ * @param cfg.currentRound Current open mining round number
11
+ * @param cfg.currentRoundOpensAt When that round opened
12
+ * @param cfg.safetyTicks Subtract this many ticks as buffer (default 1)
13
+ * @returns Latest safe lock expiry Date, or null if already unsafe.
14
+ */
15
+ export declare function maxLockExpirationForFreshAmulet(amount: number | string, cfg: {
16
+ holdingFeeRate: number | string;
17
+ tickDurationSec: number;
18
+ currentRound: number | bigint;
19
+ currentRoundOpensAt: Date | string | number;
20
+ safetyTicks?: number;
21
+ }): Date | null;
1
22
  export interface DepositFundsOpts {
2
23
  sender: string;
3
24
  receiver?: string;
@@ -5,9 +5,109 @@ import { getUserId } from "../api/tokenStore.js";
5
5
  import { getAdapterPartyId, getWalletAdapter, submitCommand, payDueGasIfAny } from "../../src/canton/walletAdapter.js";
6
6
  import { normalizeAssetId, instrumentCatalog, instrumentIdToSymbol, resolveOnChainInstrumentId } from "../../src/canton/instrumentCatalog.js";
7
7
  import { randomUUID, shouldUseLedgerForMetadata, normalizeContractId, resolveInstrumentDefinition, getInstrumentRegistrar, resolveProvider, dedupeDisclosedContracts, buildHeaders, DEFAULT_UTILITY_CONTEXT_KEYS, } from "./helpers.js";
8
- import { resolveAmuletContext, resolveUtilityInstrumentConfiguration, resolveUtilityAllocationFactory, getAmuletHoldingsForParty, getUtilityHoldingsForParty, getUtxoCount, } from "../../src/canton/index.js";
8
+ import { resolveAmuletContext, resolveUtilityInstrumentConfiguration, resolveUtilityAllocationFactory, getAmuletHoldingsForParty, getUtilityHoldingsForParty, getUtxoCount, getAmuletRules, getOpenMiningRounds, } from "../../src/canton/index.js";
9
9
  // ─── Constants ───────────────────────────────────────────────────────────────
10
10
  const CC_FEE_RESERVE = 10;
11
+ // ─── Amulet Expiry Helpers ───────────────────────────────────────────────────
12
+ /**
13
+ * Compute the maximum safe lock-expiration time for an Amulet allocation,
14
+ * given the amulet amount and current network parameters.
15
+ *
16
+ * Mirrors Splice.Expiry.isAmuletExpired + MaxLockExpirationForFreshAmulet.
17
+ *
18
+ * @param amount Amulet amount being locked (e.g. 0.001)
19
+ * @param cfg.holdingFeeRate Per-round holding fee rate (e.g. 0.00002)
20
+ * @param cfg.tickDurationSec Round duration in seconds (e.g. 600)
21
+ * @param cfg.currentRound Current open mining round number
22
+ * @param cfg.currentRoundOpensAt When that round opened
23
+ * @param cfg.safetyTicks Subtract this many ticks as buffer (default 1)
24
+ * @returns Latest safe lock expiry Date, or null if already unsafe.
25
+ */
26
+ export function maxLockExpirationForFreshAmulet(amount, cfg) {
27
+ const amt = Number(amount);
28
+ const rate = Number(cfg.holdingFeeRate);
29
+ const tickSec = Number(cfg.tickDurationSec);
30
+ const currentRound = Number(cfg.currentRound);
31
+ const opensAt = new Date(cfg.currentRoundOpensAt).getTime();
32
+ const safetyTicks = cfg.safetyTicks ?? 1;
33
+ if (!(amt > 0) || !(rate > 0) || !(tickSec > 0))
34
+ return null;
35
+ // Rounds the amulet can survive holding-fee drain
36
+ const roundsUntilExpired = Math.ceil(amt / rate);
37
+ // DAML: amulet is "definitely expired" once currentRound >= expiresAtRound + 2.
38
+ // Assume freshly minted → createdAtRound = currentRound.
39
+ const expiresAtRound = currentRound + roundsUntilExpired;
40
+ const effectiveExpiringRound = expiresAtRound + 2;
41
+ if (effectiveExpiringRound <= currentRound)
42
+ return null;
43
+ // Time at which `effectiveExpiringRound` opens:
44
+ // opensAt(current) + (target - current - 1) * tickDuration
45
+ const roundsInFuture = Math.max(0, effectiveExpiringRound - currentRound - 1);
46
+ const expirationMs = opensAt + roundsInFuture * tickSec * 1000;
47
+ // Safety buffer: lock must end strictly before amulet would expire
48
+ const safeMs = expirationMs - safetyTicks * tickSec * 1000;
49
+ if (safeMs <= Date.now())
50
+ return null;
51
+ return new Date(safeMs);
52
+ }
53
+ /**
54
+ * Fetch the parameters needed for maxLockExpirationForFreshAmulet from on-chain data.
55
+ * AmuletRules is long-lived and cached internally; OpenMiningRound should be refreshed per round.
56
+ * Returns null if data cannot be fetched.
57
+ */
58
+ async function fetchAmuletExpiryParams(dso) {
59
+ try {
60
+ const [amuletRulesData, miningRoundsData] = await Promise.all([
61
+ getAmuletRules(dso),
62
+ getOpenMiningRounds(dso),
63
+ ]);
64
+ const payload = amuletRulesData?.amulet_rules?.contract;
65
+ const transferConfig = payload?.payload?.transferConfig;
66
+ if (!transferConfig)
67
+ return null;
68
+ const holdingFeeRate = Number(transferConfig?.holdingFee?.rate);
69
+ // tickDuration may be { microseconds: "N" } (DAML RelTime) or a plain seconds number
70
+ const rawTick = transferConfig?.tickDuration;
71
+ let tickDurationSec;
72
+ if (rawTick && typeof rawTick === "object" && "microseconds" in rawTick) {
73
+ tickDurationSec = Number(rawTick.microseconds) / 1000000;
74
+ }
75
+ else {
76
+ tickDurationSec = Number(rawTick);
77
+ }
78
+ if (!holdingFeeRate || !tickDurationSec)
79
+ return null;
80
+ // Find the currently open mining round
81
+ const now = new Date();
82
+ const rounds = miningRoundsData?.open_mining_rounds ?? [];
83
+ let selectedRoundPayload = null;
84
+ for (const entry of rounds) {
85
+ const rPayload = entry.contract?.payload;
86
+ const opensAt = new Date(rPayload?.opensAt);
87
+ const targetClosesAt = new Date(rPayload?.targetClosesAt);
88
+ if (now >= opensAt && now <= targetClosesAt) {
89
+ selectedRoundPayload = rPayload;
90
+ break;
91
+ }
92
+ }
93
+ if (!selectedRoundPayload && rounds.length > 0) {
94
+ selectedRoundPayload = rounds[0].contract?.payload;
95
+ }
96
+ if (!selectedRoundPayload)
97
+ return null;
98
+ const roundField = selectedRoundPayload?.round;
99
+ const currentRound = Number(typeof roundField === "object" && roundField !== null
100
+ ? roundField.number
101
+ : roundField);
102
+ const currentRoundOpensAt = selectedRoundPayload?.opensAt;
103
+ if (!currentRoundOpensAt)
104
+ return null;
105
+ return { holdingFeeRate, tickDurationSec, currentRound, currentRoundOpensAt };
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
11
111
  // ─── Deposit Functions ───────────────────────────────────────────────────────
12
112
  /**
13
113
  * Prepare holdings for a deposit by selecting UTXOs from the wallet provider.
@@ -213,8 +313,34 @@ export async function depositFunds(opts, returnCommand = false) {
213
313
  }
214
314
  // --- 2. Build the choice arguments upfront ---
215
315
  const now = new Date();
216
- const allocateDeadline = allocateBefore || new Date(now.getTime() + 60 * 60 * 1000).toISOString();
217
- const settleDeadline = settleBefore || new Date(now.getTime() + 2 * 60 * 60 * 1000).toISOString();
316
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
317
+ let allocateDeadline;
318
+ let settleDeadline;
319
+ if (isAmulet && (!allocateBefore || !settleBefore)) {
320
+ // Compute the safe deadline from on-chain fee params to avoid
321
+ // "amulet expires before lock" errors on small amounts.
322
+ const expiryParams = await fetchAmuletExpiryParams(config.VALIDATOR_DSO_PARTY_ID);
323
+ const maxExpiry = expiryParams ? maxLockExpirationForFreshAmulet(amount, expiryParams) : null;
324
+ if (maxExpiry) {
325
+ // settleBefore = safe max expiry; allocateBefore = halfway between now and settleBefore
326
+ const midMs = now.getTime() + (maxExpiry.getTime() - now.getTime()) / 2;
327
+ allocateDeadline = allocateBefore || new Date(midMs).toISOString();
328
+ settleDeadline = settleBefore || maxExpiry.toISOString();
329
+ }
330
+ else {
331
+ // Fallback heuristic if on-chain params are unavailable
332
+ const isSmallAmulet = parseFloat(String(amount)) <= 10;
333
+ const defaultWindowMs = isSmallAmulet ? 2 * ONE_DAY_MS : 60 * ONE_DAY_MS;
334
+ allocateDeadline = allocateBefore || new Date(now.getTime() + defaultWindowMs / 2).toISOString();
335
+ settleDeadline = settleBefore || new Date(now.getTime() + defaultWindowMs).toISOString();
336
+ }
337
+ }
338
+ else {
339
+ // Utility tokens or caller-supplied deadlines — use fixed window or provided values
340
+ const defaultWindowMs = 60 * ONE_DAY_MS;
341
+ allocateDeadline = allocateBefore || new Date(now.getTime() + defaultWindowMs / 2).toISOString();
342
+ settleDeadline = settleBefore || new Date(now.getTime() + defaultWindowMs).toISOString();
343
+ }
218
344
  const executor = config.TEMPLE_PARTY_ID;
219
345
  const resolvedTransferLegId = transferLegId || randomUUID();
220
346
  const resolvedSettlementId = settlementId || `SR-${randomUUID()}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@temple-digital-group/temple-canton-js",
3
- "version": "2.0.3",
3
+ "version": "2.0.4-beta.0",
4
4
  "description": "JavaScript library for interacting with Temple Canton blockchain",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -23,12 +23,140 @@ import {
23
23
  getAmuletHoldingsForParty,
24
24
  getUtilityHoldingsForParty,
25
25
  getUtxoCount,
26
+ getAmuletRules,
27
+ getOpenMiningRounds,
26
28
  } from "../../src/canton/index.js";
27
29
 
28
30
  // ─── Constants ───────────────────────────────────────────────────────────────
29
31
 
30
32
  const CC_FEE_RESERVE = 10;
31
33
 
34
+ // ─── Amulet Expiry Helpers ───────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Compute the maximum safe lock-expiration time for an Amulet allocation,
38
+ * given the amulet amount and current network parameters.
39
+ *
40
+ * Mirrors Splice.Expiry.isAmuletExpired + MaxLockExpirationForFreshAmulet.
41
+ *
42
+ * @param amount Amulet amount being locked (e.g. 0.001)
43
+ * @param cfg.holdingFeeRate Per-round holding fee rate (e.g. 0.00002)
44
+ * @param cfg.tickDurationSec Round duration in seconds (e.g. 600)
45
+ * @param cfg.currentRound Current open mining round number
46
+ * @param cfg.currentRoundOpensAt When that round opened
47
+ * @param cfg.safetyTicks Subtract this many ticks as buffer (default 1)
48
+ * @returns Latest safe lock expiry Date, or null if already unsafe.
49
+ */
50
+ export function maxLockExpirationForFreshAmulet(
51
+ amount: number | string,
52
+ cfg: {
53
+ holdingFeeRate: number | string;
54
+ tickDurationSec: number;
55
+ currentRound: number | bigint;
56
+ currentRoundOpensAt: Date | string | number;
57
+ safetyTicks?: number;
58
+ },
59
+ ): Date | null {
60
+ const amt = Number(amount);
61
+ const rate = Number(cfg.holdingFeeRate);
62
+ const tickSec = Number(cfg.tickDurationSec);
63
+ const currentRound = Number(cfg.currentRound);
64
+ const opensAt = new Date(cfg.currentRoundOpensAt).getTime();
65
+ const safetyTicks = cfg.safetyTicks ?? 1;
66
+
67
+ if (!(amt > 0) || !(rate > 0) || !(tickSec > 0)) return null;
68
+
69
+ // Rounds the amulet can survive holding-fee drain
70
+ const roundsUntilExpired = Math.ceil(amt / rate);
71
+
72
+ // DAML: amulet is "definitely expired" once currentRound >= expiresAtRound + 2.
73
+ // Assume freshly minted → createdAtRound = currentRound.
74
+ const expiresAtRound = currentRound + roundsUntilExpired;
75
+ const effectiveExpiringRound = expiresAtRound + 2;
76
+
77
+ if (effectiveExpiringRound <= currentRound) return null;
78
+
79
+ // Time at which `effectiveExpiringRound` opens:
80
+ // opensAt(current) + (target - current - 1) * tickDuration
81
+ const roundsInFuture = Math.max(0, effectiveExpiringRound - currentRound - 1);
82
+ const expirationMs = opensAt + roundsInFuture * tickSec * 1000;
83
+
84
+ // Safety buffer: lock must end strictly before amulet would expire
85
+ const safeMs = expirationMs - safetyTicks * tickSec * 1000;
86
+
87
+ if (safeMs <= Date.now()) return null;
88
+ return new Date(safeMs);
89
+ }
90
+
91
+ interface AmuletExpiryParams {
92
+ holdingFeeRate: number;
93
+ tickDurationSec: number;
94
+ currentRound: number;
95
+ currentRoundOpensAt: string;
96
+ }
97
+
98
+ /**
99
+ * Fetch the parameters needed for maxLockExpirationForFreshAmulet from on-chain data.
100
+ * AmuletRules is long-lived and cached internally; OpenMiningRound should be refreshed per round.
101
+ * Returns null if data cannot be fetched.
102
+ */
103
+ async function fetchAmuletExpiryParams(dso: string): Promise<AmuletExpiryParams | null> {
104
+ try {
105
+ const [amuletRulesData, miningRoundsData] = await Promise.all([
106
+ getAmuletRules(dso) as Promise<Record<string, unknown>>,
107
+ getOpenMiningRounds(dso) as Promise<Record<string, unknown>>,
108
+ ]);
109
+
110
+ const payload = (amuletRulesData?.amulet_rules as Record<string, unknown>)?.contract as Record<string, unknown>;
111
+ const transferConfig = (payload?.payload as Record<string, unknown>)?.transferConfig as Record<string, unknown>;
112
+ if (!transferConfig) return null;
113
+
114
+ const holdingFeeRate = Number((transferConfig?.holdingFee as Record<string, unknown>)?.rate);
115
+
116
+ // tickDuration may be { microseconds: "N" } (DAML RelTime) or a plain seconds number
117
+ const rawTick = transferConfig?.tickDuration;
118
+ let tickDurationSec: number;
119
+ if (rawTick && typeof rawTick === "object" && "microseconds" in (rawTick as object)) {
120
+ tickDurationSec = Number((rawTick as Record<string, unknown>).microseconds) / 1_000_000;
121
+ } else {
122
+ tickDurationSec = Number(rawTick);
123
+ }
124
+
125
+ if (!holdingFeeRate || !tickDurationSec) return null;
126
+
127
+ // Find the currently open mining round
128
+ const now = new Date();
129
+ const rounds = (miningRoundsData?.open_mining_rounds as Record<string, unknown>[]) ?? [];
130
+ let selectedRoundPayload: Record<string, unknown> | null = null;
131
+ for (const entry of rounds) {
132
+ const rPayload = (entry.contract as Record<string, unknown>)?.payload as Record<string, unknown>;
133
+ const opensAt = new Date(rPayload?.opensAt as string);
134
+ const targetClosesAt = new Date(rPayload?.targetClosesAt as string);
135
+ if (now >= opensAt && now <= targetClosesAt) {
136
+ selectedRoundPayload = rPayload;
137
+ break;
138
+ }
139
+ }
140
+ if (!selectedRoundPayload && rounds.length > 0) {
141
+ selectedRoundPayload = (rounds[0].contract as Record<string, unknown>)?.payload as Record<string, unknown>;
142
+ }
143
+ if (!selectedRoundPayload) return null;
144
+
145
+ const roundField = selectedRoundPayload?.round;
146
+ const currentRound = Number(
147
+ typeof roundField === "object" && roundField !== null
148
+ ? (roundField as Record<string, unknown>).number
149
+ : roundField,
150
+ );
151
+ const currentRoundOpensAt = selectedRoundPayload?.opensAt as string;
152
+ if (!currentRoundOpensAt) return null;
153
+
154
+ return { holdingFeeRate, tickDurationSec, currentRound, currentRoundOpensAt };
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+
32
160
  // ─── Types ───────────────────────────────────────────────────────────────────
33
161
 
34
162
  export interface DepositFundsOpts {
@@ -296,8 +424,33 @@ export async function depositFunds(
296
424
 
297
425
  // --- 2. Build the choice arguments upfront ---
298
426
  const now = new Date();
299
- const allocateDeadline = allocateBefore || new Date(now.getTime() + 60 * 60 * 1000).toISOString();
300
- const settleDeadline = settleBefore || new Date(now.getTime() + 2 * 60 * 60 * 1000).toISOString();
427
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
428
+ let allocateDeadline: string;
429
+ let settleDeadline: string;
430
+
431
+ if (isAmulet && (!allocateBefore || !settleBefore)) {
432
+ // Compute the safe deadline from on-chain fee params to avoid
433
+ // "amulet expires before lock" errors on small amounts.
434
+ const expiryParams = await fetchAmuletExpiryParams(config.VALIDATOR_DSO_PARTY_ID);
435
+ const maxExpiry = expiryParams ? maxLockExpirationForFreshAmulet(amount, expiryParams) : null;
436
+ if (maxExpiry) {
437
+ // settleBefore = safe max expiry; allocateBefore = halfway between now and settleBefore
438
+ const midMs = now.getTime() + (maxExpiry.getTime() - now.getTime()) / 2;
439
+ allocateDeadline = allocateBefore || new Date(midMs).toISOString();
440
+ settleDeadline = settleBefore || maxExpiry.toISOString();
441
+ } else {
442
+ // Fallback heuristic if on-chain params are unavailable
443
+ const isSmallAmulet = parseFloat(String(amount)) <= 10;
444
+ const defaultWindowMs = isSmallAmulet ? 2 * ONE_DAY_MS : 60 * ONE_DAY_MS;
445
+ allocateDeadline = allocateBefore || new Date(now.getTime() + defaultWindowMs / 2).toISOString();
446
+ settleDeadline = settleBefore || new Date(now.getTime() + defaultWindowMs).toISOString();
447
+ }
448
+ } else {
449
+ // Utility tokens or caller-supplied deadlines — use fixed window or provided values
450
+ const defaultWindowMs = 60 * ONE_DAY_MS;
451
+ allocateDeadline = allocateBefore || new Date(now.getTime() + defaultWindowMs / 2).toISOString();
452
+ settleDeadline = settleBefore || new Date(now.getTime() + defaultWindowMs).toISOString();
453
+ }
301
454
  const executor = config.TEMPLE_PARTY_ID;
302
455
  const resolvedTransferLegId = transferLegId || randomUUID();
303
456
  const resolvedSettlementId = settlementId || `SR-${randomUUID()}`;
@@ -39,3 +39,7 @@ export function getUtxoCount(
39
39
  largestUnlocked: number;
40
40
  unlockedBalance: number;
41
41
  }>;
42
+
43
+ export function getAmuletRules(dso: string, returnCommand?: boolean): Promise<Record<string, unknown> | null>;
44
+
45
+ export function getOpenMiningRounds(dso: string, returnCommand?: boolean): Promise<Record<string, unknown> | null>;