@gvnrdao/dh-lit-actions 0.0.14 → 0.0.17

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.
@@ -0,0 +1,2154 @@
1
+ // LIT Actions runtime provides: Lit, ethers, fetch
2
+ var _LIT_ACTION_ = (() => {
3
+ // src/constants/chunks/liquidation.ts
4
+ var ACTIVE_LOAN_LIQUIDATION_THRESHOLD_BPS = 13e3;
5
+ var EXPIRED_LOAN_MIN_LIQUIDATION_THRESHOLD_BPS = 11e3;
6
+ var EXPIRED_LOAN_MAX_LIQUIDATION_THRESHOLD_BPS = 2e4;
7
+ var GRACE_PERIOD_DAYS = 30;
8
+
9
+ // src/constants/chunks/decimals.ts
10
+ var SATOSHIS_PER_BITCOIN = 100000000n;
11
+ var PRICE_ORACLE_DECIMALS = 100000000n;
12
+ var UCD_TOKEN_DECIMALS = 1000000000000000000n;
13
+
14
+ // src/constants/chunks/price-oracle.ts
15
+ var PRICE_ORACLE_OUTLIER_DEVIATION_THRESHOLD = 0.02;
16
+ var PRICE_ORACLE_DISPERSION_THRESHOLD = 0.05;
17
+
18
+ // src/constants/chunks/bitcoin.ts
19
+ var BITCOIN_DEFAULT_MIN_CONFIRMATIONS = 6;
20
+
21
+ // src/constants/chunks/quantum-time.ts
22
+ var QUANTUM_WINDOW_SECONDS = 100;
23
+ var DEAD_ZONE_SECONDS = 16;
24
+ var SAFE_EXECUTION_WINDOW_SECONDS = QUANTUM_WINDOW_SECONDS - DEAD_ZONE_SECONDS;
25
+
26
+ // deployments/deployment.sepolia.json
27
+ var deployment_sepolia_default = {
28
+ network: "sepolia",
29
+ chainId: 11155111,
30
+ timestamp: "2025-10-24T23:22:01.396Z",
31
+ deployer: "0x2767441E044aCd9bbC21a759fB0517494875092d",
32
+ contracts: {
33
+ UpgradeValidator: "0x4246830c4bF03a6f4460ecEB9e6470562a5Cd0bB",
34
+ UCDToken: "0x86060Fa5B3E01E003b8aFd9C1295F1F444a7dC60",
35
+ UCDController: "0xEE24c338e394610b503768Ba7D8A8c26efdD3Aeb",
36
+ PriceFeedConsumer: "0xA285198196B8f4f658DC81bf4e99A5DC6c191694",
37
+ BTCProofValidator: "0x79469590b41AF8b21276914da7f18FF136B59afB",
38
+ UCDMintingRewards: "0x914740bA9b454d1B3C116A9F55C0929487b7E052",
39
+ PositionManagerCoreModule: "0x0f7E84538026Efdd118168b27f6da00035c75697",
40
+ TermManagerModule: "0x73f0e9151bbEa3cB8b21628E2aAd54C3f6b3968A",
41
+ LoanOperationsManagerModule: "0x71128A4e728ca4B6976E05aC77E50Ad7fe98Ae37",
42
+ CollateralManagerModule: "0xAb9205f4b559F945Ae45c0857fc5c30d699509E1",
43
+ LiquidationManagerModule: "0xe5A72334ED5e0F962be02229b36685d5Bb331cCF",
44
+ CircuitBreakerModule: "0x55eE4Bb05008b73AebcAfa5FbC745348E645d8c6",
45
+ CommunityManagerModule: "0x4E4481B0B3B08804C1d32A19Af66130426D82e89",
46
+ AdminModule: "0xF96F14605f870A0cC2589A5d2b1D77E379762Af2",
47
+ PositionManagerViews: "0x7bc05DBC27BBdab374E3FEBB4Efaa6CDE082fc27",
48
+ PositionManager: "0xAC8d7b714865D4F304b80cd037b517796D56E4Ce",
49
+ MockUSDC: "0x43670cD40c3c4a7acBA5B06B84b8Dd97D7E08f82",
50
+ MockUSDT: "0xF8b7fB38b17a310960E47805Be9adba0b0e0394D",
51
+ SimplePSMV2: "0x8C2D7C833b40eD23Bf1EABAf8B9105C80FD686A2",
52
+ OperationAuthorizationRegistry: "0x400B85A6A901fdbF08f048cD5f3e37B8DD6c0D66"
53
+ },
54
+ upgraded: {
55
+ LoanOperationsManagerModule: {
56
+ previousImplementation: "0xFa3E83992d964E46d82Bb1F92844a8aC909130B2",
57
+ newImplementation: "0xFa3E83992d964E46d82Bb1F92844a8aC909130B2",
58
+ upgradedAt: "2025-10-24T23:22:01.396Z",
59
+ upgradedBy: "0x2767441E044aCd9bbC21a759fB0517494875092d"
60
+ }
61
+ }
62
+ };
63
+
64
+ // deployments/deployment.localhost.json
65
+ var deployment_localhost_default = {
66
+ network: "localhost",
67
+ chainId: 1337,
68
+ timestamp: "2025-10-19T17:53:35.461Z",
69
+ deployer: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
70
+ contracts: {
71
+ UpgradeValidator: "0xa513E6E4b8f2a923D98304ec87F64353C4D5C853",
72
+ UCDToken: "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512",
73
+ UCDController: "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9",
74
+ PriceFeedConsumer: "0x8A791620dd6260079BF849Dc5567aDC3F2FdC318",
75
+ BTCProofValidator: "0xB7f8BC63BbcaD18155201308C8f3540b07f84F5e",
76
+ UCDMintingRewards: "0x0DCd1Bf9A1b36cE34237eEaFef220932846BCD82",
77
+ PositionManagerCoreModule: "0x0B306BF915C4d645ff596e518fAf3F9669b97016",
78
+ TermManagerModule: "0x9A9f2CCfdE556A7E9Ff0848998Aa4a0CFD8863AE",
79
+ LoanOperationsManagerModule: "0x3Aa5ebB10DC797CAC828524e59A333d0A371443c",
80
+ CollateralManagerModule: "0x59b670e9fA9D0A427751Af201D676719a970857b",
81
+ LiquidationManagerModule: "0x322813Fd9A801c5507c9de605d63CEA4f2CE6c44",
82
+ CircuitBreakerModule: "0x4A679253410272dd5232B3Ff7cF5dbB88f295319",
83
+ CommunityManagerModule: "0x09635F643e140090A9A8Dcd712eD6285858ceBef",
84
+ AdminModule: "0x67d269191c92Caf3cD7723F116c85e6E9bf55933",
85
+ PositionManagerViews: "0xE6E340D132b5f46d1e472DebcD681B2aBc16e57E",
86
+ PositionManager: "0x84eA74d481Ee0A5332c457a4d796187F6Ba67fEB"
87
+ }
88
+ };
89
+
90
+ // src/config/network-config.ts
91
+ var NETWORK_DEPLOYMENTS = {
92
+ sepolia: deployment_sepolia_default,
93
+ localhost: deployment_localhost_default
94
+ };
95
+ function getNetworkConfig(network) {
96
+ const normalizedNetwork = network.toLowerCase();
97
+ if (!NETWORK_DEPLOYMENTS[normalizedNetwork]) {
98
+ console.warn(
99
+ `Warning: Network "${network}" not found, defaulting to sepolia`
100
+ );
101
+ return NETWORK_DEPLOYMENTS.sepolia;
102
+ }
103
+ return NETWORK_DEPLOYMENTS[normalizedNetwork];
104
+ }
105
+
106
+ // src/constants/chunks/loan-status.ts
107
+ function loanStatusToString(status) {
108
+ switch (status) {
109
+ case 0 /* PENDING_DEPOSIT */:
110
+ return "PENDING_DEPOSIT";
111
+ case 1 /* PENDING_MINT */:
112
+ return "PENDING_MINT";
113
+ case 2 /* ACTIVE */:
114
+ return "ACTIVE";
115
+ case 3 /* EXPIRED */:
116
+ return "EXPIRED";
117
+ case 4 /* LIQUIDATABLE */:
118
+ return "LIQUIDATABLE";
119
+ case 5 /* LIQUIDATED */:
120
+ return "LIQUIDATED";
121
+ case 6 /* REPAID */:
122
+ return "REPAID";
123
+ case 7 /* CLOSED */:
124
+ return "CLOSED";
125
+ default:
126
+ return `UNKNOWN(${status})`;
127
+ }
128
+ }
129
+ function getValidMintingStatuses() {
130
+ return [
131
+ 0 /* PENDING_DEPOSIT */,
132
+ 1 /* PENDING_MINT */,
133
+ 2 /* ACTIVE */
134
+ ];
135
+ }
136
+
137
+ // src/constants/chunks/bitcoin-network-config.ts
138
+ var EVMChain = /* @__PURE__ */ ((EVMChain2) => {
139
+ EVMChain2["SEPOLIA"] = "sepolia";
140
+ EVMChain2["ETHEREUM"] = "ethereum";
141
+ return EVMChain2;
142
+ })(EVMChain || {});
143
+ var CHAIN_TO_BITCOIN_NETWORK = {
144
+ ["sepolia" /* SEPOLIA */]: "testnet" /* TESTNET */,
145
+ ["ethereum" /* ETHEREUM */]: "mainnet" /* MAINNET */
146
+ };
147
+ var APPROVED_BITCOIN_PROVIDERS = {
148
+ ["testnet" /* TESTNET */]: [
149
+ {
150
+ url: "https://diamond-hands-btc-faucet-6b39a1072059.herokuapp.com/api",
151
+ network: "testnet" /* TESTNET */,
152
+ minConfirmations: 1,
153
+ name: "Diamond Hands Faucet"
154
+ }
155
+ ],
156
+ ["mainnet" /* MAINNET */]: [
157
+ {
158
+ url: "https://blockstream.info/api",
159
+ network: "mainnet" /* MAINNET */,
160
+ minConfirmations: 6,
161
+ name: "Blockstream Mainnet"
162
+ },
163
+ {
164
+ url: "https://mempool.space/api",
165
+ network: "mainnet" /* MAINNET */,
166
+ minConfirmations: 6,
167
+ name: "Mempool.space Mainnet"
168
+ }
169
+ ]
170
+ };
171
+ function validateChain(chain) {
172
+ const normalized = chain.toLowerCase().trim();
173
+ if (normalized === "sepolia") {
174
+ return "sepolia" /* SEPOLIA */;
175
+ }
176
+ if (normalized === "ethereum" || normalized === "mainnet") {
177
+ return "ethereum" /* ETHEREUM */;
178
+ }
179
+ throw new Error(
180
+ `Unsupported EVM chain: "${chain}". Supported chains: ${Object.values(EVMChain).join(", ")}`
181
+ );
182
+ }
183
+ function getBitcoinNetworkForChain(chain) {
184
+ return CHAIN_TO_BITCOIN_NETWORK[chain];
185
+ }
186
+ function validateBitcoinProvider(providerUrl, bitcoinNetwork) {
187
+ const providers = APPROVED_BITCOIN_PROVIDERS[bitcoinNetwork];
188
+ const config = providers.find((p) => p.url === providerUrl);
189
+ if (!config) {
190
+ const approvedUrls = providers.map((p) => `${p.name} (${p.url})`).join(", ");
191
+ throw new Error(
192
+ `Bitcoin provider not approved for ${bitcoinNetwork}. Provided URL: ${providerUrl}. Approved providers: ${approvedUrls}`
193
+ );
194
+ }
195
+ return config;
196
+ }
197
+ function getBitcoinProviderConfig(chain, providerUrl) {
198
+ const validatedChain = validateChain(chain);
199
+ const bitcoinNetwork = getBitcoinNetworkForChain(validatedChain);
200
+ const providerConfig = validateBitcoinProvider(providerUrl, bitcoinNetwork);
201
+ if (providerConfig.network !== bitcoinNetwork) {
202
+ throw new Error(
203
+ `Bitcoin provider network mismatch. Provider ${providerConfig.name} is for ${providerConfig.network}, but chain ${chain} requires ${bitcoinNetwork}`
204
+ );
205
+ }
206
+ return providerConfig;
207
+ }
208
+
209
+ // src/modules/quantum-time.module.ts
210
+ function getQuantumStart(timestamp) {
211
+ return Math.floor(timestamp / QUANTUM_WINDOW_SECONDS) * QUANTUM_WINDOW_SECONDS;
212
+ }
213
+ function getCurrentQuantumTimestamp() {
214
+ const now = Math.floor(Date.now() / 1e3);
215
+ return getQuantumStart(now);
216
+ }
217
+ function isInDeadZone(now) {
218
+ const currentTime = now ?? Math.floor(Date.now() / 1e3);
219
+ const timeInQuantum = currentTime % QUANTUM_WINDOW_SECONDS;
220
+ return timeInQuantum >= 92 || timeInQuantum < 8;
221
+ }
222
+ async function waitForSafeQuantumMoment() {
223
+ const now = Math.floor(Date.now() / 1e3);
224
+ const timeInQuantum = now % QUANTUM_WINDOW_SECONDS;
225
+ if (!isInDeadZone(now)) {
226
+ return;
227
+ }
228
+ let waitSeconds;
229
+ if (timeInQuantum >= 92) {
230
+ waitSeconds = 100 - timeInQuantum + 8;
231
+ } else {
232
+ waitSeconds = 8 - timeInQuantum;
233
+ }
234
+ console.log(
235
+ `[Quantum] Waiting ${waitSeconds}s for safe moment (current time in quantum: ${timeInQuantum}s)`
236
+ );
237
+ await new Promise((resolve) => setTimeout(resolve, waitSeconds * 1e3));
238
+ console.log(`[Quantum] Safe quantum ready: ${getCurrentQuantumTimestamp()}`);
239
+ }
240
+ function validateQuantumTimestamp(timestampOnSignature, now) {
241
+ const currentTime = now ?? Math.floor(Date.now() / 1e3);
242
+ const currentQuantum = getQuantumStart(currentTime);
243
+ const signatureQuantum = getQuantumStart(timestampOnSignature);
244
+ if (signatureQuantum !== currentQuantum) {
245
+ throw new Error(
246
+ `[Quantum] Invalid timestamp: signature from quantum ${signatureQuantum}, current quantum is ${currentQuantum}`
247
+ );
248
+ }
249
+ }
250
+
251
+ // src/modules/authorization.module.ts
252
+ var AuthorizationModule = class {
253
+ /**
254
+ * Verify owner signature for position action
255
+ *
256
+ * Validates that:
257
+ * 1. Waits for safe quantum moment (if in dead zone)
258
+ * 2. Timestamp is within valid quantum window
259
+ * 3. Message includes: LIT action name, caller address, position, timestamp, params
260
+ * 4. Recovered signer matches claimed caller address
261
+ * 5. Caller address matches expected owner
262
+ *
263
+ * @param litActionName - Name of the LIT action (e.g., "ucd-mint-validator")
264
+ * @param auth - Authorization parameters
265
+ * @param expectedOwner - Expected owner address from contract
266
+ * @param actionParams - Action-specific parameters to include in message
267
+ * @returns True if authorized
268
+ */
269
+ static async verifyOwnerSignature(litActionName, auth, expectedOwner, actionParams) {
270
+ await waitForSafeQuantumMoment();
271
+ try {
272
+ validateQuantumTimestamp(auth.timestamp);
273
+ } catch (error) {
274
+ console.error(
275
+ "[Authorization] Quantum timestamp validation failed:",
276
+ error
277
+ );
278
+ return false;
279
+ }
280
+ const message = this.buildAuthorizationMessage(
281
+ litActionName,
282
+ auth.callerAddress,
283
+ auth.positionId,
284
+ auth.timestamp,
285
+ actionParams
286
+ );
287
+ let recoveredAddress;
288
+ try {
289
+ recoveredAddress = await this.recoverSigner(message, auth.signature);
290
+ } catch (error) {
291
+ console.error("[Authorization] Signature recovery failed:", error);
292
+ return false;
293
+ }
294
+ if (recoveredAddress.toLowerCase() !== auth.callerAddress.toLowerCase()) {
295
+ console.error(
296
+ `[Authorization] Caller address mismatch:`,
297
+ `
298
+ Claimed: ${auth.callerAddress}`,
299
+ `
300
+ Recovered: ${recoveredAddress}`
301
+ );
302
+ return false;
303
+ }
304
+ const isAuthorized = auth.callerAddress.toLowerCase() === expectedOwner.toLowerCase();
305
+ if (!isAuthorized) {
306
+ console.error(
307
+ `[Authorization] Not authorized:`,
308
+ `
309
+ Caller: ${auth.callerAddress}`,
310
+ `
311
+ Owner: ${expectedOwner}`
312
+ );
313
+ }
314
+ return isAuthorized;
315
+ }
316
+ /**
317
+ * Build authorization message for signing
318
+ *
319
+ * Creates deterministic message from parameters for signature verification.
320
+ *
321
+ * Message structure:
322
+ * keccak256(litActionName, callerAddress, positionId, timestamp, actionParams...)
323
+ *
324
+ * @param litActionName - Name of the LIT action
325
+ * @param callerAddress - Ethereum address of caller
326
+ * @param positionId - Position identifier
327
+ * @param timestamp - Quantum timestamp
328
+ * @param actionParams - Action-specific parameters
329
+ * @returns Message hash for signing
330
+ */
331
+ static buildAuthorizationMessage(litActionName, callerAddress, positionId, timestamp, actionParams) {
332
+ const sortedKeys = Object.keys(actionParams).sort();
333
+ const values = sortedKeys.map((key) => actionParams[key]);
334
+ const types = [
335
+ "string",
336
+ // litActionName
337
+ "address",
338
+ // callerAddress
339
+ "bytes32",
340
+ // positionId
341
+ "uint256",
342
+ // timestamp
343
+ ...sortedKeys.map(() => "bytes32")
344
+ // action params
345
+ ];
346
+ const allValues = [
347
+ litActionName,
348
+ callerAddress,
349
+ positionId,
350
+ timestamp,
351
+ ...values
352
+ ];
353
+ const messageHash = ethers.utils.solidityKeccak256(types, allValues);
354
+ return messageHash;
355
+ }
356
+ /**
357
+ * Recover signer address from signature
358
+ *
359
+ * Uses EIP-191 standard (Ethereum Signed Message)
360
+ *
361
+ * @param messageHash - Keccak256 hash of the message
362
+ * @param signature - Signature to verify
363
+ * @returns Recovered signer address
364
+ */
365
+ static async recoverSigner(messageHash, signature) {
366
+ try {
367
+ const messageHashBytes = ethers.utils.arrayify(messageHash);
368
+ const prefixedHash = ethers.utils.hashMessage(messageHashBytes);
369
+ const recoveredAddress = ethers.utils.recoverAddress(
370
+ prefixedHash,
371
+ signature
372
+ );
373
+ return recoveredAddress;
374
+ } catch (error) {
375
+ console.error("[Authorization] Signature recovery failed:", error);
376
+ throw new Error(
377
+ `Failed to recover signer: ${error instanceof Error ? error.message : String(error)}`
378
+ );
379
+ }
380
+ }
381
+ /**
382
+ * Verify position owner authorization for mintUCD action
383
+ *
384
+ * @param auth - Authorization parameters
385
+ * @param positionOwner - Position owner address from contract
386
+ * @param amount - UCD amount to mint (in wei)
387
+ * @returns True if authorized
388
+ */
389
+ static async verifyMintAuthorization(auth, positionOwner, amount) {
390
+ return this.verifyOwnerSignature(
391
+ "ucd-mint-validator",
392
+ auth,
393
+ positionOwner,
394
+ {
395
+ action: ethers.utils.keccak256(ethers.utils.toUtf8Bytes("mintUCD")),
396
+ amount: ethers.utils.hexZeroPad(ethers.utils.hexlify(amount), 32)
397
+ }
398
+ );
399
+ }
400
+ /**
401
+ * Verify position owner authorization for withdrawal action
402
+ *
403
+ * @param auth - Authorization parameters
404
+ * @param positionOwner - Position owner address from contract
405
+ * @param btcAddress - Bitcoin address to withdraw to
406
+ * @param satoshis - Amount in satoshis
407
+ * @returns True if authorized
408
+ */
409
+ static async verifyWithdrawalAuthorization(auth, positionOwner, btcAddress, satoshis) {
410
+ return this.verifyOwnerSignature(
411
+ "btc-withdrawal-validator",
412
+ auth,
413
+ positionOwner,
414
+ {
415
+ action: ethers.utils.keccak256(ethers.utils.toUtf8Bytes("withdraw")),
416
+ btcAddress: ethers.utils.keccak256(
417
+ ethers.utils.toUtf8Bytes(btcAddress)
418
+ ),
419
+ satoshis: ethers.utils.hexZeroPad(ethers.utils.hexlify(satoshis), 32)
420
+ }
421
+ );
422
+ }
423
+ /**
424
+ * Verify position owner authorization for repayment action
425
+ *
426
+ * @param auth - Authorization parameters
427
+ * @param positionOwner - Position owner address from contract
428
+ * @param amount - UCD amount to repay (in wei)
429
+ * @returns True if authorized
430
+ */
431
+ static async verifyRepaymentAuthorization(auth, positionOwner, amount) {
432
+ return this.verifyOwnerSignature(
433
+ "ucd-repayment-validator",
434
+ auth,
435
+ positionOwner,
436
+ {
437
+ action: ethers.utils.keccak256(ethers.utils.toUtf8Bytes("repay")),
438
+ amount: ethers.utils.hexZeroPad(ethers.utils.hexlify(amount), 32)
439
+ }
440
+ );
441
+ }
442
+ };
443
+
444
+ // src/modules/bitcoin-data-provider.module.ts
445
+ var DEFAULT_MIN_CONFIRMATIONS = BITCOIN_DEFAULT_MIN_CONFIRMATIONS;
446
+ var BitcoinDataProvider = class {
447
+ constructor(config) {
448
+ this.config = config;
449
+ this.timeout = config.timeout || 1e4;
450
+ }
451
+ /**
452
+ * Fetch all UTXOs for a Bitcoin address
453
+ *
454
+ * @param address - Bitcoin address (legacy, segwit, or taproot)
455
+ * @returns Array of UTXOs
456
+ */
457
+ async getUTXOs(address) {
458
+ if (this.config.rpcHelper) {
459
+ return await this.fetchUTXOsFromRPC(address);
460
+ }
461
+ try {
462
+ return await this.fetchUTXOsFromProvider(
463
+ this.config.providerUrl,
464
+ address
465
+ );
466
+ } catch (error) {
467
+ console.warn(`[Bitcoin Data] Primary provider failed: ${error.message}`);
468
+ if (this.config.fallbackProviders && this.config.fallbackProviders.length > 0) {
469
+ const sortedFallbacks = [...this.config.fallbackProviders].sort(
470
+ (a, b) => a.priority - b.priority
471
+ );
472
+ for (const fallback of sortedFallbacks) {
473
+ try {
474
+ console.log(
475
+ `[Bitcoin Data] Trying fallback: ${fallback.name} (${fallback.url})`
476
+ );
477
+ return await this.fetchUTXOsFromProvider(fallback.url, address);
478
+ } catch (fallbackError) {
479
+ console.warn(
480
+ `[Bitcoin Data] Fallback ${fallback.name} failed: ${fallbackError.message}`
481
+ );
482
+ continue;
483
+ }
484
+ }
485
+ }
486
+ throw new Error(`Failed to fetch UTXOs: ${error.message}`);
487
+ }
488
+ }
489
+ /**
490
+ * Fetch UTXOs using Bitcoin RPC (for regtest/testing)
491
+ */
492
+ async fetchUTXOsFromRPC(address) {
493
+ if (!this.config.rpcHelper) {
494
+ throw new Error("RPC helper not configured");
495
+ }
496
+ const wallet = this.config.rpcWallet || "";
497
+ const unspentOutputs = await this.config.rpcHelper.listUnspent(
498
+ wallet,
499
+ address
500
+ );
501
+ const utxos = unspentOutputs.map((utxo) => ({
502
+ txid: utxo.txid,
503
+ vout: utxo.vout,
504
+ satoshis: BigInt(Math.round(utxo.amount * 1e8)),
505
+ // BTC to satoshis as bigint
506
+ confirmations: utxo.confirmations
507
+ }));
508
+ return utxos;
509
+ }
510
+ /**
511
+ * Detect provider format from URL
512
+ *
513
+ * Determines which API format a provider uses based on its URL.
514
+ *
515
+ * @param providerUrl Provider base URL
516
+ * @returns Provider format type
517
+ */
518
+ detectProviderFormat(providerUrl) {
519
+ if (providerUrl.includes("diamond-hands-btc-faucet")) {
520
+ return "diamond-hands-faucet" /* DIAMOND_HANDS_FAUCET */;
521
+ }
522
+ return "blockstream" /* BLOCKSTREAM */;
523
+ }
524
+ /**
525
+ * Parse Diamond Hands Faucet UTXO response
526
+ *
527
+ * Expected format:
528
+ * {
529
+ * "success": true,
530
+ * "address": "...",
531
+ * "utxos": [{txid, vout, amount, confirmations, scriptPubKey}],
532
+ * "totalBalance": 33000000,
533
+ * "utxoCount": 6
534
+ * }
535
+ *
536
+ * @param data Raw response from faucet API
537
+ * @returns Array of normalized UTXOs
538
+ */
539
+ parseDiamondHandsFaucetUTXOs(data) {
540
+ if (!data.success) {
541
+ throw new Error(
542
+ `Diamond Hands Faucet error: ${data.error || data.message || "Unknown error"}`
543
+ );
544
+ }
545
+ if (!Array.isArray(data.utxos)) {
546
+ throw new Error(
547
+ "Invalid Diamond Hands Faucet response: utxos must be an array"
548
+ );
549
+ }
550
+ const utxos = data.utxos.map((utxo) => ({
551
+ txid: utxo.txid,
552
+ vout: utxo.vout,
553
+ satoshis: BigInt(utxo.amount),
554
+ // Faucet uses "amount" (satoshis)
555
+ confirmations: utxo.confirmations || 0
556
+ }));
557
+ return utxos;
558
+ }
559
+ /**
560
+ * Parse Blockstream/Mempool.space UTXO response
561
+ *
562
+ * Expected format: Array of {txid, vout, value, confirmations}
563
+ *
564
+ * @param data Raw response from provider API
565
+ * @returns Array of normalized UTXOs
566
+ */
567
+ parseBlockstreamUTXOs(data) {
568
+ if (!Array.isArray(data)) {
569
+ throw new Error(
570
+ "Invalid Blockstream response format: expected an array"
571
+ );
572
+ }
573
+ const utxos = data.map((utxo) => ({
574
+ txid: utxo.txid,
575
+ vout: utxo.vout !== void 0 ? utxo.vout : utxo.n,
576
+ satoshis: BigInt(utxo.value !== void 0 ? utxo.value : utxo.satoshis),
577
+ confirmations: utxo.confirmations || 0
578
+ }));
579
+ return utxos;
580
+ }
581
+ /**
582
+ * Internal method to fetch UTXOs from a specific provider
583
+ *
584
+ * Supports multiple provider API formats:
585
+ * - Blockstream/Mempool.space: /api/address/{address}/utxos
586
+ * - Diamond Hands Faucet: /api/faucet/utxos/{address}
587
+ */
588
+ async fetchUTXOsFromProvider(providerUrl, address) {
589
+ const format = this.detectProviderFormat(providerUrl);
590
+ let endpoint;
591
+ if (format === "diamond-hands-faucet" /* DIAMOND_HANDS_FAUCET */) {
592
+ endpoint = `${providerUrl}/api/faucet/utxos/${address}`;
593
+ } else {
594
+ endpoint = `${providerUrl}/api/address/${address}/utxos`;
595
+ }
596
+ const controller = new AbortController();
597
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
598
+ try {
599
+ const response = await fetch(endpoint, { signal: controller.signal });
600
+ clearTimeout(timeoutId);
601
+ if (!response.ok) {
602
+ throw new Error(
603
+ `Bitcoin provider error: ${response.status} ${response.statusText}`
604
+ );
605
+ }
606
+ const data = await response.json();
607
+ if (format === "diamond-hands-faucet" /* DIAMOND_HANDS_FAUCET */) {
608
+ return this.parseDiamondHandsFaucetUTXOs(data);
609
+ } else {
610
+ return this.parseBlockstreamUTXOs(data);
611
+ }
612
+ } catch (error) {
613
+ clearTimeout(timeoutId);
614
+ if (error.name === "AbortError") {
615
+ throw new Error(`Request timeout after ${this.timeout}ms`);
616
+ }
617
+ throw error;
618
+ }
619
+ }
620
+ /**
621
+ * Get complete UTXO set with balance summary
622
+ *
623
+ * This is the SINGLE method that vault-balance module should call.
624
+ * Returns everything needed to know about Bitcoin network state for an address.
625
+ *
626
+ * @param address - Bitcoin address
627
+ * @param minConfirmations - Minimum confirmations for "confirmed" status (default: 6)
628
+ * @returns Complete UTXO set with balance breakdown
629
+ */
630
+ async getUTXOSet(address, minConfirmations = DEFAULT_MIN_CONFIRMATIONS) {
631
+ const utxos = await this.getUTXOs(address);
632
+ const totalBalance = utxos.reduce((sum, utxo) => sum + utxo.satoshis, 0n);
633
+ const confirmedBalance = utxos.filter((utxo) => utxo.confirmations >= minConfirmations).reduce((sum, utxo) => sum + utxo.satoshis, 0n);
634
+ const unconfirmedBalance = totalBalance - confirmedBalance;
635
+ return {
636
+ utxos,
637
+ totalBalance,
638
+ totalUTXOs: utxos.length,
639
+ confirmedBalance,
640
+ unconfirmedBalance
641
+ };
642
+ }
643
+ /**
644
+ * Get transaction with confirmation count
645
+ *
646
+ * Checks if transaction exists on Bitcoin network and returns confirmation count.
647
+ * Used to validate authorized spends have been broadcasted.
648
+ *
649
+ * @param txid - Transaction ID to check
650
+ * @returns Transaction with confirmation count, or null if not found
651
+ */
652
+ async getTransaction(txid) {
653
+ if (this.config.rpcHelper) {
654
+ try {
655
+ const wallet = this.config.rpcWallet || "";
656
+ const tx = await this.config.rpcHelper.getTransaction(wallet, txid);
657
+ return {
658
+ txid: tx.txid,
659
+ confirmations: tx.confirmations || 0
660
+ };
661
+ } catch (error) {
662
+ if (error.message?.includes("Invalid or non-wallet transaction") || error.code === -5) {
663
+ return null;
664
+ }
665
+ throw error;
666
+ }
667
+ }
668
+ const controller = new AbortController();
669
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
670
+ try {
671
+ const response = await fetch(
672
+ `${this.config.providerUrl}/api/tx/${txid}`,
673
+ {
674
+ signal: controller.signal
675
+ }
676
+ );
677
+ clearTimeout(timeoutId);
678
+ if (!response.ok) {
679
+ if (response.status === 404) {
680
+ return null;
681
+ }
682
+ throw new Error(
683
+ `Bitcoin provider error: ${response.status} ${response.statusText}`
684
+ );
685
+ }
686
+ const data = await response.json();
687
+ if (data.status) {
688
+ return {
689
+ txid: data.txid,
690
+ confirmations: data.status.confirmed ? data.status.block_height ? 1 : 0 : 0
691
+ };
692
+ }
693
+ return null;
694
+ } catch (error) {
695
+ clearTimeout(timeoutId);
696
+ if (error.name === "AbortError") {
697
+ throw new Error(`Request timeout after ${this.timeout}ms`);
698
+ }
699
+ if (error.message?.includes("404")) {
700
+ return null;
701
+ }
702
+ throw error;
703
+ }
704
+ }
705
+ // FUTURE: Multi-provider consensus methods will be added here
706
+ // async getUTXOsWithConsensus(address: string): Promise<UTXO[]> {
707
+ // // Fetch from all providers
708
+ // // Return UTXOs that appear in 2+ sources
709
+ // // Use median value if amounts differ
710
+ // }
711
+ };
712
+
713
+ // src/modules/price-oracle.module.ts
714
+ var PriceOracleModule = class {
715
+ constructor(sources) {
716
+ this.sources = sources || this.getDefaultSources();
717
+ this.sources.sort((a, b) => a.priority - b.priority);
718
+ }
719
+ getDefaultSources() {
720
+ return [
721
+ {
722
+ name: "CoinGecko",
723
+ fetchPrice: async () => {
724
+ const url = new URL("https://api.coingecko.com/api/v3/simple/price");
725
+ url.searchParams.set("ids", "bitcoin");
726
+ url.searchParams.set("vs_currencies", "usd");
727
+ const response = await fetch(url.toString());
728
+ if (!response.ok) {
729
+ throw new Error(`CoinGecko API error: ${response.status} ${response.statusText}`);
730
+ }
731
+ const data = await response.json();
732
+ const price = data.bitcoin.usd;
733
+ const allPrices = await Lit.Actions.broadcastAndCollect({
734
+ name: "coinGeckoPrice",
735
+ value: price.toString()
736
+ });
737
+ const prices = allPrices.map((p) => parseFloat(p));
738
+ prices.sort((a, b) => a - b);
739
+ return prices[Math.floor(prices.length / 2)];
740
+ },
741
+ priority: 1
742
+ },
743
+ {
744
+ name: "Binance",
745
+ fetchPrice: async () => {
746
+ const url = new URL("https://api.binance.com/api/v3/ticker/price");
747
+ url.searchParams.set("symbol", "BTCUSDT");
748
+ const response = await fetch(url.toString());
749
+ if (!response.ok) {
750
+ throw new Error(`Binance API error: ${response.status} ${response.statusText}`);
751
+ }
752
+ const data = await response.json();
753
+ const price = parseFloat(data.price);
754
+ const allPrices = await Lit.Actions.broadcastAndCollect({
755
+ name: "binancePrice",
756
+ value: price.toString()
757
+ });
758
+ const prices = allPrices.map((p) => parseFloat(p));
759
+ prices.sort((a, b) => a - b);
760
+ return prices[Math.floor(prices.length / 2)];
761
+ },
762
+ priority: 2
763
+ },
764
+ {
765
+ name: "Coinbase",
766
+ fetchPrice: async () => {
767
+ const response = await fetch("https://api.coinbase.com/v2/prices/BTC-USD/spot");
768
+ if (!response.ok) {
769
+ throw new Error(`Coinbase API error: ${response.status} ${response.statusText}`);
770
+ }
771
+ const data = await response.json();
772
+ const price = parseFloat(data.data.amount);
773
+ const allPrices = await Lit.Actions.broadcastAndCollect({
774
+ name: "coinbasePrice",
775
+ value: price.toString()
776
+ });
777
+ const prices = allPrices.map((p) => parseFloat(p));
778
+ prices.sort((a, b) => a - b);
779
+ return prices[Math.floor(prices.length / 2)];
780
+ },
781
+ priority: 3
782
+ }
783
+ ];
784
+ }
785
+ /**
786
+ * Get BTC price in USD with 8 decimals
787
+ * Fetches from real price sources in priority order
788
+ */
789
+ async getBTCPrice() {
790
+ console.log(`[Price Oracle] Fetching BTC price from external sources...`);
791
+ for (const source of this.sources) {
792
+ try {
793
+ console.log(`[Price Oracle] Trying ${source.name}...`);
794
+ const priceUSD = await source.fetchPrice();
795
+ console.log(
796
+ `[Price Oracle] \u2705 ${source.name}: $${priceUSD.toLocaleString()}`
797
+ );
798
+ const priceWith8Decimals = BigInt(Math.floor(priceUSD * 1e8));
799
+ console.log(
800
+ `[Price Oracle] Price with 8 decimals: ${priceWith8Decimals}`
801
+ );
802
+ return priceWith8Decimals;
803
+ } catch (error) {
804
+ console.warn(
805
+ `[Price Oracle] \u26A0\uFE0F ${source.name} failed: ${error.message}`
806
+ );
807
+ continue;
808
+ }
809
+ }
810
+ throw new Error("All price sources failed");
811
+ }
812
+ /**
813
+ * Consensus across multiple sources with outlier detection
814
+ * Returns median price from valid sources after filtering outliers
815
+ *
816
+ * Master's Wisdom: Two-Path Validation
817
+ * 1. If ONE source is outlier (>2% from median) → FILTER it, continue with remaining
818
+ * 2. If ALL sources too dispersed (>5% spread) → REJECT all, throw error
819
+ *
820
+ * Logic Flow:
821
+ * 1. Calculate initial median from all sources
822
+ * 2. Filter individual outliers (>2% from median)
823
+ * 3. If outliers found: remove them, require minimum 2 sources remaining
824
+ * 4. Recalculate median from valid sources only
825
+ * 5. Check if remaining sources are too dispersed (max/min ratio > 1.05)
826
+ * 6. Return final median or throw appropriate error
827
+ */
828
+ async getBTCPriceConsensus() {
829
+ console.log(`[Price Oracle] Fetching BTC price with consensus...`);
830
+ const results = await Promise.allSettled(
831
+ this.sources.map(async (source) => {
832
+ const price = await source.fetchPrice();
833
+ return { source: source.name, price };
834
+ })
835
+ );
836
+ const successful = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
837
+ if (successful.length === 0) {
838
+ throw new Error("No price sources returned data");
839
+ }
840
+ console.log(
841
+ `[Price Oracle] Got prices from ${successful.length}/${this.sources.length} sources:`
842
+ );
843
+ successful.forEach((s) => {
844
+ console.log(` ${s.source}: $${s.price.toLocaleString()}`);
845
+ });
846
+ const prices = successful.map((s) => s.price);
847
+ prices.sort((a, b) => a - b);
848
+ const initialMedian = prices[Math.floor(prices.length / 2)];
849
+ const minPrice = prices[0];
850
+ const maxPrice = prices[prices.length - 1];
851
+ const dispersionRatio = maxPrice / minPrice;
852
+ const validPrices = successful.filter((s) => {
853
+ const deviation = Math.abs(s.price - initialMedian) / initialMedian;
854
+ return deviation <= PRICE_ORACLE_OUTLIER_DEVIATION_THRESHOLD;
855
+ });
856
+ if (dispersionRatio > 1 + PRICE_ORACLE_DISPERSION_THRESHOLD && validPrices.length === successful.length) {
857
+ throw new Error(
858
+ `Price consensus failed: sources too dispersed (${((dispersionRatio - 1) * 100).toFixed(1)}% spread)`
859
+ );
860
+ }
861
+ if (validPrices.length < successful.length) {
862
+ const outliers = successful.filter(
863
+ (s) => !validPrices.find((v) => v.source === s.source)
864
+ );
865
+ console.log(`[Price Oracle] \u26A0\uFE0F Detected ${outliers.length} outlier(s):`);
866
+ outliers.forEach((o) => {
867
+ const deviation = Math.abs(o.price - initialMedian) / initialMedian;
868
+ console.log(
869
+ ` ${o.source}: $${o.price.toLocaleString()} (${(deviation * 100).toFixed(1)}% deviation)`
870
+ );
871
+ });
872
+ if (validPrices.length < 2) {
873
+ throw new Error(
874
+ "Price consensus failed: insufficient valid sources after outlier removal"
875
+ );
876
+ }
877
+ console.log(
878
+ `[Price Oracle] \u2705 Outliers filtered, continuing with ${validPrices.length} valid sources`
879
+ );
880
+ const validPricesOnly = validPrices.map((v) => v.price);
881
+ validPricesOnly.sort((a, b) => a - b);
882
+ const finalMedian = validPricesOnly[Math.floor(validPricesOnly.length / 2)];
883
+ console.log(
884
+ `[Price Oracle] \u2705 Consensus price (median): $${finalMedian.toLocaleString()}`
885
+ );
886
+ return BigInt(Math.floor(finalMedian * 1e8));
887
+ }
888
+ console.log(
889
+ `[Price Oracle] \u2705 Consensus price (median): $${initialMedian.toLocaleString()}`
890
+ );
891
+ return BigInt(Math.floor(initialMedian * 1e8));
892
+ }
893
+ };
894
+
895
+ // src/modules/vault-balance.module.ts
896
+ var VaultBalanceModule = class {
897
+ constructor(config) {
898
+ this.config = config;
899
+ this.bitcoinProvider = config.bitcoinProvider;
900
+ }
901
+ /**
902
+ * Calculate trusted balance for a position
903
+ *
904
+ * This is the main entry point for determining available balance.
905
+ *
906
+ * CRITICAL: Validates that all authorized spends have been properly broadcasted
907
+ * to Bitcoin network with sufficient confirmations. Throws errors if:
908
+ * 1. Authorized transaction not found on network (not broadcasted)
909
+ * 2. Authorized transaction has insufficient confirmations (network updating)
910
+ *
911
+ * @param positionId - The position ID to check
912
+ * @param vaultAddress - Bitcoin address of the vault
913
+ * @returns Complete breakdown of vault balance
914
+ * @throws Error if authorized spends are in invalid state
915
+ */
916
+ async calculateTrustedBalance(positionId, vaultAddress) {
917
+ const minConfirmations = this.config.minConfirmations || 6;
918
+ const utxoSet = await this.bitcoinProvider.getUTXOSet(
919
+ vaultAddress,
920
+ minConfirmations
921
+ );
922
+ const authorizedSpends = await this.getAuthorizedSpendsFromContract(positionId);
923
+ for (const spend of authorizedSpends) {
924
+ const utxoStillExists = utxoSet.utxos.some(
925
+ (utxo) => utxo.txid === spend.txid && utxo.vout === spend.vout
926
+ );
927
+ if (utxoStillExists) {
928
+ throw new Error(
929
+ `Authorized UTXO ${spend.txid}:${spend.vout} not yet spent. Transaction was authorized in smart contract but not signed and broadcasted to Bitcoin network. Complete the transaction by signing and broadcasting it.`
930
+ );
931
+ }
932
+ }
933
+ const authorizedBalance = authorizedSpends.reduce(
934
+ (sum, spend) => sum + spend.satoshis,
935
+ 0n
936
+ );
937
+ const availableUTXOs = utxoSet.utxos.filter(
938
+ (utxo) => !this.isUTXOAuthorized(utxo, authorizedSpends)
939
+ );
940
+ const availableBalance = availableUTXOs.reduce(
941
+ (sum, utxo) => sum + utxo.satoshis,
942
+ 0n
943
+ );
944
+ return {
945
+ // Total from Bitcoin
946
+ totalUTXOs: utxoSet.utxos,
947
+ totalBalance: utxoSet.totalBalance,
948
+ // Authorized from Contract
949
+ authorizedUTXOs: authorizedSpends,
950
+ authorizedBalance,
951
+ // Available = Total - Authorized (THIS IS THE TRUSTED BALANCE)
952
+ availableUTXOs,
953
+ availableBalance,
954
+ // Metadata
955
+ vaultAddress,
956
+ positionId,
957
+ timestamp: Date.now()
958
+ };
959
+ }
960
+ /**
961
+ * Get just the trusted balance amount
962
+ *
963
+ * Convenience method when you only need the number.
964
+ *
965
+ * @param positionId - The position ID to check
966
+ * @param vaultAddress - Bitcoin address of the vault
967
+ * @returns Available balance in satoshis
968
+ */
969
+ async getTrustedBalance(positionId, vaultAddress) {
970
+ const result = await this.calculateTrustedBalance(positionId, vaultAddress);
971
+ return result.availableBalance;
972
+ }
973
+ /**
974
+ * Get available UTXOs that can be used for new authorizations
975
+ *
976
+ * This is useful when you need to select specific UTXOs for authorization.
977
+ *
978
+ * @param positionId - The position ID to check
979
+ * @param vaultAddress - Bitcoin address of the vault
980
+ * @returns Array of available UTXOs
981
+ */
982
+ async getAvailableUTXOs(positionId, vaultAddress) {
983
+ const result = await this.calculateTrustedBalance(positionId, vaultAddress);
984
+ return result.availableUTXOs;
985
+ }
986
+ /**
987
+ * Check if a specific UTXO is available for authorization
988
+ *
989
+ * @param positionId - The position ID to check
990
+ * @param vaultAddress - Bitcoin address of the vault
991
+ * @param txid - Transaction ID of the UTXO
992
+ * @param vout - Output index of the UTXO
993
+ * @returns True if UTXO is available, false otherwise
994
+ */
995
+ async isUTXOAvailable(positionId, vaultAddress, txid, vout) {
996
+ const availableUTXOs = await this.getAvailableUTXOs(
997
+ positionId,
998
+ vaultAddress
999
+ );
1000
+ return availableUTXOs.some(
1001
+ (utxo) => utxo.txid === txid && utxo.vout === vout
1002
+ );
1003
+ }
1004
+ /**
1005
+ * Check if a UTXO is authorized (appears in authorized spends)
1006
+ *
1007
+ * @param utxo - The UTXO to check
1008
+ * @param authorizedSpends - List of authorized spends from contract
1009
+ * @returns True if UTXO is authorized, false otherwise
1010
+ */
1011
+ isUTXOAuthorized(utxo, authorizedSpends) {
1012
+ return authorizedSpends.some(
1013
+ (spend) => spend.txid === utxo.txid && spend.vout === utxo.vout
1014
+ );
1015
+ }
1016
+ /**
1017
+ * Query smart contract for authorized spends
1018
+ *
1019
+ * Calls the contract's getAuthorizedSpends(positionId) view function
1020
+ * to retrieve all authorized Bitcoin spends for this position.
1021
+ *
1022
+ * Contract struct AuthorizedSpend:
1023
+ * - txid: string
1024
+ * - vout: uint32
1025
+ * - satoshis: uint256
1026
+ * - authorizedAt: uint256 (timestamp)
1027
+ *
1028
+ * @param positionId - The position ID to query
1029
+ * @returns Array of authorized spends for this position
1030
+ */
1031
+ async getAuthorizedSpendsFromContract(positionId) {
1032
+ const positionIdBytes32 = positionId.startsWith("0x") ? positionId : `0x${positionId.padStart(64, "0")}`;
1033
+ const getAuthorizedSpendsABI = [
1034
+ {
1035
+ inputs: [
1036
+ { internalType: "bytes32", name: "positionId", type: "bytes32" }
1037
+ ],
1038
+ name: "getAuthorizedSpends",
1039
+ outputs: [
1040
+ {
1041
+ components: [
1042
+ { internalType: "string", name: "txid", type: "string" },
1043
+ { internalType: "uint32", name: "vout", type: "uint32" },
1044
+ { internalType: "uint256", name: "satoshis", type: "uint256" },
1045
+ {
1046
+ internalType: "string",
1047
+ name: "targetAddress",
1048
+ type: "string"
1049
+ },
1050
+ {
1051
+ internalType: "uint256",
1052
+ name: "targetAmount",
1053
+ type: "uint256"
1054
+ },
1055
+ {
1056
+ internalType: "string",
1057
+ name: "changeAddress",
1058
+ type: "string"
1059
+ },
1060
+ {
1061
+ internalType: "uint256",
1062
+ name: "authorizedAt",
1063
+ type: "uint256"
1064
+ }
1065
+ ],
1066
+ internalType: "struct LoanOperationsManager.AuthorizedSpend[]",
1067
+ name: "",
1068
+ type: "tuple[]"
1069
+ }
1070
+ ],
1071
+ stateMutability: "view",
1072
+ type: "function"
1073
+ }
1074
+ ];
1075
+ let rpcUrl;
1076
+ if (this.config.rpcUrl) {
1077
+ rpcUrl = this.config.rpcUrl;
1078
+ } else {
1079
+ rpcUrl = await Lit.Actions.getRpcUrl({ chain: this.config.chain });
1080
+ }
1081
+ const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
1082
+ const contract = new ethers.Contract(
1083
+ this.config.contractAddress,
1084
+ getAuthorizedSpendsABI,
1085
+ provider
1086
+ );
1087
+ const result = await contract.getAuthorizedSpends(positionIdBytes32);
1088
+ return result.map((spend) => ({
1089
+ txid: spend.txid,
1090
+ vout: Number(spend.vout),
1091
+ satoshis: BigInt(spend.satoshis.toString()),
1092
+ positionId,
1093
+ targetAddress: spend.targetAddress,
1094
+ targetAmount: BigInt(spend.targetAmount.toString()),
1095
+ changeAddress: spend.changeAddress,
1096
+ timestamp: Number(spend.authorizedAt)
1097
+ }));
1098
+ }
1099
+ };
1100
+ function createVaultBalanceModule(config) {
1101
+ return new VaultBalanceModule(config);
1102
+ }
1103
+
1104
+ // src/modules/business-rules-math.module.ts
1105
+ function calculateTermStartFromExpiry(expiryTimestamp, termLengthMonths) {
1106
+ if (expiryTimestamp === 0) {
1107
+ return 0;
1108
+ }
1109
+ const SECONDS_PER_DAY = 86400;
1110
+ const DAYS_PER_MONTH = 30;
1111
+ const termDurationSeconds = termLengthMonths * DAYS_PER_MONTH * SECONDS_PER_DAY;
1112
+ return expiryTimestamp - termDurationSeconds;
1113
+ }
1114
+ function calculateTermStatus(termStartTimestamp, selectedTermMonths) {
1115
+ if (termStartTimestamp === 0) {
1116
+ return {
1117
+ termDurationDays: 0,
1118
+ termLengthDays: selectedTermMonths * 30,
1119
+ isExpired: false,
1120
+ daysUntilExpiry: selectedTermMonths * 30,
1121
+ daysIntoGracePeriod: 0
1122
+ };
1123
+ }
1124
+ const now = Math.floor(Date.now() / 1e3);
1125
+ const termLengthDays = selectedTermMonths * 30;
1126
+ const termDurationSeconds = now - termStartTimestamp;
1127
+ const termDurationDays = Math.floor(termDurationSeconds / 86400);
1128
+ const isExpired = termDurationDays > termLengthDays;
1129
+ const daysUntilExpiry = Math.max(0, termLengthDays - termDurationDays);
1130
+ const daysIntoGracePeriod = Math.max(0, termDurationDays - termLengthDays);
1131
+ return {
1132
+ termDurationDays,
1133
+ termLengthDays,
1134
+ isExpired,
1135
+ daysUntilExpiry,
1136
+ daysIntoGracePeriod
1137
+ };
1138
+ }
1139
+ function calculateLiquidationThreshold(isExpired, daysIntoGracePeriod) {
1140
+ if (!isExpired) {
1141
+ return ACTIVE_LOAN_LIQUIDATION_THRESHOLD_BPS;
1142
+ }
1143
+ const day = Math.min(daysIntoGracePeriod, GRACE_PERIOD_DAYS);
1144
+ const daySquared = day * day;
1145
+ const escalation = daySquared * (EXPIRED_LOAN_MAX_LIQUIDATION_THRESHOLD_BPS - EXPIRED_LOAN_MIN_LIQUIDATION_THRESHOLD_BPS) / (GRACE_PERIOD_DAYS * GRACE_PERIOD_DAYS);
1146
+ const threshold = EXPIRED_LOAN_MIN_LIQUIDATION_THRESHOLD_BPS + escalation;
1147
+ return threshold;
1148
+ }
1149
+ function calculateCollateralMetrics(availableBTCSats, btcPriceUsd, ucdDebt) {
1150
+ if (ucdDebt === 0n) {
1151
+ return {
1152
+ collateralValueUsd: 0n,
1153
+ collateralRatioBps: Number.MAX_SAFE_INTEGER
1154
+ };
1155
+ }
1156
+ const collateralValueUsd = availableBTCSats * btcPriceUsd / (SATOSHIS_PER_BITCOIN * PRICE_ORACLE_DECIMALS);
1157
+ const ratioBigInt = collateralValueUsd * UCD_TOKEN_DECIMALS * 10000n / ucdDebt;
1158
+ const collateralRatioBps = Number(ratioBigInt);
1159
+ return {
1160
+ collateralValueUsd,
1161
+ collateralRatioBps
1162
+ };
1163
+ }
1164
+ function hasSufficientCollateralForDebt(collateralValueUsd, currentDebt, additionalDebt, requiredRatioBps) {
1165
+ const newTotalDebt = currentDebt + additionalDebt;
1166
+ if (newTotalDebt === 0n) {
1167
+ return true;
1168
+ }
1169
+ const newCollateralRatioBps = Number(
1170
+ collateralValueUsd * UCD_TOKEN_DECIMALS * 10000n / newTotalDebt
1171
+ );
1172
+ return newCollateralRatioBps >= requiredRatioBps;
1173
+ }
1174
+ function calculateFeeBps(amount, feeBps) {
1175
+ if (amount < 0n) {
1176
+ throw new Error("Amount cannot be negative");
1177
+ }
1178
+ if (feeBps < 0 || feeBps > 1e4) {
1179
+ throw new Error(
1180
+ "Fee basis points must be between 0 and 10000 (0% to 100%)"
1181
+ );
1182
+ }
1183
+ const fee = amount * BigInt(feeBps) / 10000n;
1184
+ return fee;
1185
+ }
1186
+ function calculateBtcValueUsd(btcSats, btcPriceUsd8Decimals) {
1187
+ if (btcSats < 0n) {
1188
+ throw new Error("BTC amount cannot be negative");
1189
+ }
1190
+ if (btcPriceUsd8Decimals <= 0n) {
1191
+ throw new Error("BTC price must be positive");
1192
+ }
1193
+ const valueUsd = btcSats * btcPriceUsd8Decimals / SATOSHIS_PER_BITCOIN;
1194
+ return valueUsd;
1195
+ }
1196
+ function calculateCollateralRatioBps(collateralValueUsd8Decimals, debtUsd18Decimals) {
1197
+ if (collateralValueUsd8Decimals < 0n) {
1198
+ throw new Error("Collateral value cannot be negative");
1199
+ }
1200
+ if (debtUsd18Decimals <= 0n) {
1201
+ throw new Error("Debt must be positive to calculate ratio");
1202
+ }
1203
+ const scaledCollateral = collateralValueUsd8Decimals * 10000000000n;
1204
+ const ratioBps = scaledCollateral * 10000n / debtUsd18Decimals;
1205
+ const ratioBpsNumber = Number(ratioBps);
1206
+ if (ratioBpsNumber < 0 || ratioBpsNumber > 1e8) {
1207
+ throw new Error(
1208
+ `Collateral ratio out of bounds: ${ratioBpsNumber} bps (${ratioBpsNumber / 100}%)`
1209
+ );
1210
+ }
1211
+ return ratioBpsNumber;
1212
+ }
1213
+ function isCollateralSufficient(collateralValueUsd8Decimals, debtUsd18Decimals, minimumRatioBps) {
1214
+ if (minimumRatioBps < 0 || minimumRatioBps > 1e6) {
1215
+ throw new Error(
1216
+ "Minimum ratio must be between 0 and 1000000 bps (0% to 10000%)"
1217
+ );
1218
+ }
1219
+ const actualRatioBps = calculateCollateralRatioBps(
1220
+ collateralValueUsd8Decimals,
1221
+ debtUsd18Decimals
1222
+ );
1223
+ return actualRatioBps >= minimumRatioBps;
1224
+ }
1225
+ function bpsToPercent(basisPoints) {
1226
+ return basisPoints / 100;
1227
+ }
1228
+
1229
+ // src/modules/protocol-parameters.module.ts
1230
+ var ProtocolParametersModule = class {
1231
+ constructor(config) {
1232
+ this.termManagerAddress = config.termManagerAddress;
1233
+ this.loanOpsManagerAddress = config.loanOpsManagerAddress;
1234
+ this.chain = config.chain;
1235
+ }
1236
+ /**
1237
+ * Get liquidation threshold from LoanOperationsManager
1238
+ *
1239
+ * @returns Liquidation threshold in basis points
1240
+ */
1241
+ async getLiquidationThreshold() {
1242
+ const abi = [
1243
+ {
1244
+ inputs: [],
1245
+ name: "liquidationThreshold",
1246
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
1247
+ stateMutability: "view",
1248
+ type: "function"
1249
+ }
1250
+ ];
1251
+ try {
1252
+ const result = await Lit.Actions.call({
1253
+ chain: this.chain,
1254
+ contractAddress: this.loanOpsManagerAddress,
1255
+ abi,
1256
+ methodName: "liquidationThreshold",
1257
+ params: []
1258
+ });
1259
+ return Number(result);
1260
+ } catch (error) {
1261
+ console.error(
1262
+ "[ProtocolParameters] Error fetching liquidation threshold:",
1263
+ error.message
1264
+ );
1265
+ throw new Error(
1266
+ `Failed to fetch liquidation threshold: ${error.message}`
1267
+ );
1268
+ }
1269
+ }
1270
+ /**
1271
+ * Get term-specific fees from TermManager
1272
+ *
1273
+ * @param termMonths Loan term in months
1274
+ * @returns Term fees (origination and extension) in basis points
1275
+ */
1276
+ async getTermFees(termMonths) {
1277
+ const abi = [
1278
+ {
1279
+ inputs: [
1280
+ { internalType: "uint256", name: "_termMonths", type: "uint256" }
1281
+ ],
1282
+ name: "getTermFees",
1283
+ outputs: [
1284
+ { internalType: "uint88", name: "originationFee", type: "uint88" },
1285
+ { internalType: "uint88", name: "extensionFee", type: "uint88" }
1286
+ ],
1287
+ stateMutability: "view",
1288
+ type: "function"
1289
+ }
1290
+ ];
1291
+ try {
1292
+ const result = await Lit.Actions.call({
1293
+ chain: this.chain,
1294
+ contractAddress: this.termManagerAddress,
1295
+ abi,
1296
+ methodName: "getTermFees",
1297
+ params: [termMonths]
1298
+ });
1299
+ return {
1300
+ originationFeeBps: Number(result[0]),
1301
+ extensionFeeBps: Number(result[1])
1302
+ };
1303
+ } catch (error) {
1304
+ console.error(
1305
+ "[ProtocolParameters] Error fetching term fees:",
1306
+ error.message
1307
+ );
1308
+ throw new Error(`Failed to fetch term fees: ${error.message}`);
1309
+ }
1310
+ }
1311
+ };
1312
+ function createProtocolParametersModule(config) {
1313
+ return new ProtocolParametersModule(config);
1314
+ }
1315
+
1316
+ // src/modules/vault-snapshot.ts
1317
+ var VaultSnapshotModule = class {
1318
+ constructor(config) {
1319
+ this.config = config;
1320
+ }
1321
+ /**
1322
+ * Get complete vault snapshot
1323
+ *
1324
+ * This is the main entry point. Returns everything you need to know
1325
+ * about a vault's health in a single call.
1326
+ *
1327
+ * @param positionId - Position to snapshot
1328
+ * @returns Complete VaultSnapshot with all metrics
1329
+ */
1330
+ async getVaultSnapshot(positionId) {
1331
+ const positionState = await this.queryPositionState(positionId);
1332
+ const balanceResult = await this.config.vaultBalance.calculateTrustedBalance(
1333
+ positionId,
1334
+ positionState.vaultAddress
1335
+ );
1336
+ const btcPriceUsd = await this.config.priceOracle.getBTCPrice();
1337
+ const protocolParams = createProtocolParametersModule({
1338
+ termManagerAddress: this.config.termManagerAddress,
1339
+ loanOpsManagerAddress: this.config.loanOpsManagerAddress,
1340
+ chain: this.config.chain
1341
+ });
1342
+ const [liquidationThresholdBps, termFees] = await Promise.all([
1343
+ protocolParams.getLiquidationThreshold(),
1344
+ protocolParams.getTermFees(positionState.selectedTerm)
1345
+ ]);
1346
+ const termStatus = calculateTermStatus(
1347
+ positionState.termStartTimestamp,
1348
+ positionState.selectedTerm
1349
+ );
1350
+ const currentLiquidationThreshold = calculateLiquidationThreshold(
1351
+ termStatus.isExpired,
1352
+ termStatus.daysIntoGracePeriod
1353
+ );
1354
+ const collateralMetrics = calculateCollateralMetrics(
1355
+ balanceResult.availableBalance,
1356
+ btcPriceUsd,
1357
+ positionState.ucdDebt
1358
+ );
1359
+ const isLiquidatable = collateralMetrics.collateralRatioBps < currentLiquidationThreshold;
1360
+ const marginToLiquidationBps = collateralMetrics.collateralRatioBps - currentLiquidationThreshold;
1361
+ return {
1362
+ // Raw state
1363
+ positionId: positionState.positionId,
1364
+ pkpId: positionState.pkpId,
1365
+ borrower: positionState.borrower,
1366
+ vaultAddress: positionState.vaultAddress,
1367
+ ucdDebt: positionState.ucdDebt,
1368
+ termStartTimestamp: positionState.termStartTimestamp,
1369
+ selectedTerm: positionState.selectedTerm,
1370
+ isActive: positionState.isActive,
1371
+ status: positionState.status,
1372
+ // Loan status enum value (0-7)
1373
+ // Bitcoin state
1374
+ totalBTCSats: balanceResult.totalBalance,
1375
+ totalUTXOs: balanceResult.totalUTXOs,
1376
+ authorizedSpendsSats: balanceResult.authorizedBalance,
1377
+ availableBTCSats: balanceResult.availableBalance,
1378
+ availableUTXOs: balanceResult.availableUTXOs,
1379
+ // Price data
1380
+ btcPriceUsd,
1381
+ // Calculated metrics
1382
+ collateralValueUsd: collateralMetrics.collateralValueUsd,
1383
+ collateralRatioBps: collateralMetrics.collateralRatioBps,
1384
+ // Term status
1385
+ termDurationDays: termStatus.termDurationDays,
1386
+ termLengthDays: termStatus.termLengthDays,
1387
+ isExpired: termStatus.isExpired,
1388
+ daysUntilExpiry: termStatus.daysUntilExpiry,
1389
+ daysIntoGracePeriod: termStatus.daysIntoGracePeriod,
1390
+ // Liquidation risk
1391
+ currentLiquidationThreshold,
1392
+ isLiquidatable,
1393
+ marginToLiquidationBps,
1394
+ // Protocol parameters (from smart contracts)
1395
+ liquidationThresholdBps,
1396
+ originationFeeBps: termFees.originationFeeBps,
1397
+ extensionFeeBps: termFees.extensionFeeBps,
1398
+ // Metadata
1399
+ timestamp: Date.now()
1400
+ };
1401
+ }
1402
+ /**
1403
+ * Query position state from smart contract
1404
+ *
1405
+ * Gets the simple state that contract stores:
1406
+ * - positionId, pkpId, borrower, vaultAddress
1407
+ * - ucdDebt
1408
+ * - termStartTimestamp
1409
+ * - selectedTerm
1410
+ * - isActive
1411
+ * - status
1412
+ */
1413
+ async queryPositionState(positionId) {
1414
+ const positionIdBytes32 = positionId.startsWith("0x") ? positionId : `0x${positionId.padStart(64, "0")}`;
1415
+ const getPositionDetailsABI = [
1416
+ {
1417
+ inputs: [
1418
+ { internalType: "bytes32", name: "positionId", type: "bytes32" }
1419
+ ],
1420
+ name: "getPositionDetails",
1421
+ outputs: [
1422
+ {
1423
+ components: [
1424
+ { internalType: "bytes32", name: "positionId", type: "bytes32" },
1425
+ { internalType: "bytes32", name: "pkpId", type: "bytes32" },
1426
+ { internalType: "uint256", name: "ucdDebt", type: "uint256" },
1427
+ {
1428
+ internalType: "string",
1429
+ name: "vaultAddress",
1430
+ type: "string"
1431
+ },
1432
+ { internalType: "address", name: "borrower", type: "address" },
1433
+ { internalType: "uint40", name: "createdAt", type: "uint40" },
1434
+ { internalType: "uint40", name: "lastUpdated", type: "uint40" },
1435
+ {
1436
+ internalType: "uint16",
1437
+ name: "selectedTerm",
1438
+ type: "uint16"
1439
+ },
1440
+ { internalType: "uint40", name: "expiryAt", type: "uint40" },
1441
+ { internalType: "bool", name: "isActive", type: "bool" },
1442
+ {
1443
+ internalType: "enum LoanStatusLib.LoanStatus",
1444
+ name: "status",
1445
+ type: "uint8"
1446
+ }
1447
+ ],
1448
+ internalType: "struct IPositionManagerCore.Position",
1449
+ name: "",
1450
+ type: "tuple"
1451
+ }
1452
+ ],
1453
+ stateMutability: "view",
1454
+ type: "function"
1455
+ }
1456
+ ];
1457
+ let rpcUrl;
1458
+ if (this.config.rpcUrl) {
1459
+ rpcUrl = this.config.rpcUrl;
1460
+ } else {
1461
+ rpcUrl = await Lit.Actions.getRpcUrl({ chain: this.config.chain });
1462
+ }
1463
+ const provider = new ethers.providers.JsonRpcProvider(rpcUrl);
1464
+ const contract = new ethers.Contract(
1465
+ this.config.contractAddress,
1466
+ getPositionDetailsABI,
1467
+ provider
1468
+ );
1469
+ const position = await contract.getPositionDetails(positionIdBytes32);
1470
+ const expiryAt = Number(position.expiryAt);
1471
+ const selectedTermMonths = Number(position.selectedTerm);
1472
+ const termStartTimestamp = calculateTermStartFromExpiry(
1473
+ expiryAt,
1474
+ selectedTermMonths
1475
+ );
1476
+ return {
1477
+ positionId: position.positionId,
1478
+ pkpId: position.pkpId,
1479
+ borrower: position.borrower,
1480
+ vaultAddress: position.vaultAddress,
1481
+ ucdDebt: BigInt(position.ucdDebt.toString()),
1482
+ termStartTimestamp,
1483
+ selectedTerm: selectedTermMonths,
1484
+ isActive: position.isActive,
1485
+ status: Number(position.status)
1486
+ // Extract loan status enum value (0-7)
1487
+ };
1488
+ }
1489
+ /**
1490
+ * Quick check if position is liquidatable
1491
+ *
1492
+ * Convenience method when you only need to know if position can be liquidated.
1493
+ *
1494
+ * @param positionId - Position to check
1495
+ * @returns True if position is liquidatable
1496
+ */
1497
+ async isLiquidatable(positionId) {
1498
+ const snapshot = await this.getVaultSnapshot(positionId);
1499
+ return snapshot.isLiquidatable;
1500
+ }
1501
+ /**
1502
+ * Get collateral ratio for position
1503
+ *
1504
+ * Convenience method when you only need the collateral ratio.
1505
+ *
1506
+ * @param positionId - Position to check
1507
+ * @returns Collateral ratio in basis points
1508
+ */
1509
+ async getCollateralRatio(positionId) {
1510
+ const snapshot = await this.getVaultSnapshot(positionId);
1511
+ return snapshot.collateralRatioBps;
1512
+ }
1513
+ /**
1514
+ * Get available BTC balance
1515
+ *
1516
+ * Convenience method when you only need available balance.
1517
+ *
1518
+ * @param positionId - Position to check
1519
+ * @returns Available BTC in satoshis
1520
+ */
1521
+ async getAvailableBalance(positionId) {
1522
+ const snapshot = await this.getVaultSnapshot(positionId);
1523
+ return snapshot.availableBTCSats;
1524
+ }
1525
+ /**
1526
+ * Check if position has sufficient collateral for new debt
1527
+ *
1528
+ * Used by minting authorization to validate new mints.
1529
+ *
1530
+ * @param positionId - Position to check
1531
+ * @param additionalDebt - Additional UCD debt to add
1532
+ * @param requiredRatioBps - Required collateral ratio (e.g., 13000 for 130%)
1533
+ * @returns True if sufficient collateral exists
1534
+ */
1535
+ async hasSufficientCollateral(positionId, additionalDebt, requiredRatioBps) {
1536
+ const snapshot = await this.getVaultSnapshot(positionId);
1537
+ return hasSufficientCollateralForDebt(
1538
+ snapshot.collateralValueUsd,
1539
+ snapshot.ucdDebt,
1540
+ additionalDebt,
1541
+ requiredRatioBps
1542
+ );
1543
+ }
1544
+ };
1545
+ function createVaultSnapshotModule(config) {
1546
+ return new VaultSnapshotModule(config);
1547
+ }
1548
+
1549
+ // src/modules/system-health.module.ts
1550
+ var SystemHealthModule = class {
1551
+ /**
1552
+ * Check if contract is paused
1553
+ * @param chain Chain identifier
1554
+ * @param contractAddress Contract address to check
1555
+ * @returns True if contract is paused
1556
+ */
1557
+ static async checkPaused(chain, contractAddress) {
1558
+ const pausedAbi = [{
1559
+ inputs: [],
1560
+ name: "paused",
1561
+ outputs: [{ internalType: "bool", name: "", type: "bool" }],
1562
+ stateMutability: "view",
1563
+ type: "function"
1564
+ }];
1565
+ try {
1566
+ const result = await Lit.Actions.call({
1567
+ chain,
1568
+ contractAddress,
1569
+ abi: pausedAbi,
1570
+ methodName: "paused",
1571
+ params: []
1572
+ });
1573
+ return Boolean(result);
1574
+ } catch (error) {
1575
+ console.error(`Failed to check pause state for ${contractAddress}:`, error);
1576
+ return false;
1577
+ }
1578
+ }
1579
+ /**
1580
+ * Check oracle emergency mode
1581
+ * @param chain Chain identifier
1582
+ * @param contractAddress Price feed contract address
1583
+ * @returns True if oracle is in emergency mode
1584
+ */
1585
+ static async checkOracleEmergency(chain, contractAddress) {
1586
+ const emergencyModeAbi = [{
1587
+ inputs: [],
1588
+ name: "emergencyMode",
1589
+ outputs: [{ internalType: "bool", name: "", type: "bool" }],
1590
+ stateMutability: "view",
1591
+ type: "function"
1592
+ }];
1593
+ try {
1594
+ const result = await Lit.Actions.call({
1595
+ chain,
1596
+ contractAddress,
1597
+ abi: emergencyModeAbi,
1598
+ methodName: "emergencyMode",
1599
+ params: []
1600
+ });
1601
+ return Boolean(result);
1602
+ } catch (error) {
1603
+ console.error(`Failed to check oracle emergency mode for ${contractAddress}:`, error);
1604
+ return false;
1605
+ }
1606
+ }
1607
+ /**
1608
+ * Check circuit breaker status
1609
+ * @param chain Chain identifier
1610
+ * @param contractAddress Circuit breaker contract address
1611
+ * @returns True if circuit breaker is triggered
1612
+ */
1613
+ static async checkCircuitBreaker(chain, contractAddress) {
1614
+ const circuitBreakerAbi = [{
1615
+ inputs: [],
1616
+ name: "isTriggered",
1617
+ outputs: [{ internalType: "bool", name: "", type: "bool" }],
1618
+ stateMutability: "view",
1619
+ type: "function"
1620
+ }];
1621
+ try {
1622
+ const result = await Lit.Actions.call({
1623
+ chain,
1624
+ contractAddress,
1625
+ abi: circuitBreakerAbi,
1626
+ methodName: "isTriggered",
1627
+ params: []
1628
+ });
1629
+ return Boolean(result);
1630
+ } catch (error) {
1631
+ console.error(`Failed to check circuit breaker for ${contractAddress}:`, error);
1632
+ return false;
1633
+ }
1634
+ }
1635
+ /**
1636
+ * Check UCD token transfers paused
1637
+ * @param chain Chain identifier
1638
+ * @param contractAddress UCD token contract address
1639
+ * @returns True if transfers are paused
1640
+ */
1641
+ static async checkTransfersPaused(chain, contractAddress) {
1642
+ const transfersPausedAbi = [{
1643
+ inputs: [],
1644
+ name: "transfersPaused",
1645
+ outputs: [{ internalType: "bool", name: "", type: "bool" }],
1646
+ stateMutability: "view",
1647
+ type: "function"
1648
+ }];
1649
+ try {
1650
+ const result = await Lit.Actions.call({
1651
+ chain,
1652
+ contractAddress,
1653
+ abi: transfersPausedAbi,
1654
+ methodName: "transfersPaused",
1655
+ params: []
1656
+ });
1657
+ return Boolean(result);
1658
+ } catch (error) {
1659
+ console.error(`Failed to check transfers paused for ${contractAddress}:`, error);
1660
+ return false;
1661
+ }
1662
+ }
1663
+ };
1664
+
1665
+ // src/ucd-mint-validator.ts
1666
+ var go = async () => {
1667
+ const MIN_BTC_PRICE_USD = 1000000000000n;
1668
+ const MAX_BTC_PRICE_USD = 100000000000000n;
1669
+ const MAX_TIMESTAMP_AGE_MS = 5 * 60 * 1e3;
1670
+ const ABI_REMAINING_MINT_CAPACITY = [
1671
+ {
1672
+ inputs: [],
1673
+ name: "getRemainingMintCapacity",
1674
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
1675
+ stateMutability: "view",
1676
+ type: "function"
1677
+ }
1678
+ ];
1679
+ const ABI_MAX_SUPPLY = [
1680
+ {
1681
+ inputs: [],
1682
+ name: "maxSupply",
1683
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
1684
+ stateMutability: "view",
1685
+ type: "function"
1686
+ }
1687
+ ];
1688
+ const ABI_CURRENT_SUPPLY = [
1689
+ {
1690
+ inputs: [],
1691
+ name: "getCurrentSupply",
1692
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
1693
+ stateMutability: "view",
1694
+ type: "function"
1695
+ }
1696
+ ];
1697
+ const ABI_MINIMUM_LOAN_VALUE_WEI = [
1698
+ {
1699
+ inputs: [],
1700
+ name: "minimumLoanValueWei",
1701
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
1702
+ stateMutability: "view",
1703
+ type: "function"
1704
+ }
1705
+ ];
1706
+ const ABI_MAXIMUM_LOAN_VALUE_UCD = [
1707
+ {
1708
+ inputs: [],
1709
+ name: "maximumLoanValueUcd",
1710
+ outputs: [{ internalType: "uint256", name: "", type: "uint256" }],
1711
+ stateMutability: "view",
1712
+ type: "function"
1713
+ }
1714
+ ];
1715
+ console.log("[Step 0] Validating configuration...");
1716
+ const chain = globalThis.chain;
1717
+ const bitcoinProviderUrl = globalThis.bitcoinProviderUrl;
1718
+ if (!chain) {
1719
+ throw new Error(
1720
+ 'Missing required parameter: "chain". Must be "sepolia" or "ethereum"'
1721
+ );
1722
+ }
1723
+ if (!bitcoinProviderUrl) {
1724
+ throw new Error(
1725
+ 'Missing required parameter: "bitcoinProviderUrl". Must be an approved Bitcoin RPC provider URL'
1726
+ );
1727
+ }
1728
+ const validatedChain = validateChain(chain);
1729
+ const bitcoinNetwork = getBitcoinNetworkForChain(validatedChain);
1730
+ const bitcoinProviderConfig = getBitcoinProviderConfig(
1731
+ chain,
1732
+ bitcoinProviderUrl
1733
+ );
1734
+ console.log(` \u2705 Chain: ${chain} (${validatedChain})`);
1735
+ console.log(` \u2705 Bitcoin Network: ${bitcoinNetwork}`);
1736
+ console.log(
1737
+ ` \u2705 Bitcoin Provider: ${bitcoinProviderConfig.name} (${bitcoinProviderConfig.url})`
1738
+ );
1739
+ console.log(
1740
+ ` \u2705 Min Confirmations: ${bitcoinProviderConfig.minConfirmations}`
1741
+ );
1742
+ const networkConfig = getNetworkConfig(chain);
1743
+ const POSITION_MANAGER_ADDRESS = networkConfig.contracts.PositionManager;
1744
+ const LOAN_OPS_MANAGER_ADDRESS = networkConfig.contracts.LoanOperationsManagerModule;
1745
+ const TERM_MANAGER_ADDRESS = networkConfig.contracts.TermManagerModule;
1746
+ const UCD_CONTROLLER_ADDRESS = networkConfig.contracts.UCDController;
1747
+ console.log(` \u2705 Contract addresses loaded for ${chain}`);
1748
+ console.log("[Step 0.5] Critical system health checks...");
1749
+ const CIRCUIT_BREAKER_ADDRESS = networkConfig.contracts.CircuitBreakerModule;
1750
+ const PRICE_FEED_ADDRESS = networkConfig.contracts.PriceFeedConsumer;
1751
+ const UCD_TOKEN_ADDRESS = networkConfig.contracts.UCDToken;
1752
+ const ucdControllerPaused = await SystemHealthModule.checkPaused(
1753
+ chain,
1754
+ UCD_CONTROLLER_ADDRESS
1755
+ );
1756
+ if (ucdControllerPaused) {
1757
+ throw new Error(
1758
+ "UCD Controller is paused - minting operations are currently disabled. Contact protocol administrators for more information."
1759
+ );
1760
+ }
1761
+ console.log(" \u2705 UCD Controller is active");
1762
+ const circuitBreakerTriggered = await SystemHealthModule.checkCircuitBreaker(
1763
+ chain,
1764
+ CIRCUIT_BREAKER_ADDRESS
1765
+ );
1766
+ if (circuitBreakerTriggered) {
1767
+ throw new Error(
1768
+ "Circuit breaker has been triggered - protocol operations are halted. This is an emergency safety measure. Contact protocol administrators."
1769
+ );
1770
+ }
1771
+ console.log(" \u2705 Circuit breaker is not triggered");
1772
+ const oracleEmergency = await SystemHealthModule.checkOracleEmergency(
1773
+ chain,
1774
+ PRICE_FEED_ADDRESS
1775
+ );
1776
+ if (oracleEmergency) {
1777
+ throw new Error(
1778
+ "Price oracle is in emergency mode - price feeds are unreliable. Minting operations are temporarily disabled for safety."
1779
+ );
1780
+ }
1781
+ console.log(" \u2705 Price oracle is operational");
1782
+ const transfersPaused = await SystemHealthModule.checkTransfersPaused(
1783
+ chain,
1784
+ UCD_TOKEN_ADDRESS
1785
+ );
1786
+ if (transfersPaused) {
1787
+ console.log(" \u26A0\uFE0F WARNING: UCD token transfers are currently paused");
1788
+ console.log(" \u26A0\uFE0F Minting will succeed but tokens cannot be transferred until unpaused");
1789
+ console.log(" \u26A0\uFE0F Tokens will remain in your position and can be used for debt repayment");
1790
+ }
1791
+ console.log(" \u2705 System health checks complete");
1792
+ const auth = globalThis.auth;
1793
+ if (!auth || typeof auth !== "object") {
1794
+ throw new Error('Missing or invalid "auth" parameter - must be an object');
1795
+ }
1796
+ if (!auth.positionId) {
1797
+ throw new Error("auth.positionId is required");
1798
+ }
1799
+ if (!auth.callerAddress) {
1800
+ throw new Error("auth.callerAddress is required");
1801
+ }
1802
+ if (typeof auth.timestamp !== "number") {
1803
+ throw new Error("auth.timestamp must be a number");
1804
+ }
1805
+ if (!auth.signature) {
1806
+ throw new Error("auth.signature is required");
1807
+ }
1808
+ const currentTime = Date.now();
1809
+ const timestampAge = currentTime - auth.timestamp;
1810
+ if (timestampAge < 0) {
1811
+ throw new Error(
1812
+ `auth.timestamp is in the future: ${auth.timestamp} vs current ${currentTime}. This may indicate clock synchronization issues.`
1813
+ );
1814
+ }
1815
+ if (timestampAge > MAX_TIMESTAMP_AGE_MS) {
1816
+ throw new Error(
1817
+ `auth.timestamp is too old: ${Math.round(timestampAge / 1e3)}s ago (max ${Math.round(MAX_TIMESTAMP_AGE_MS / 1e3)}s). Please refresh your authorization and try again.`
1818
+ );
1819
+ }
1820
+ console.log(` \u2705 Timestamp validation passed (${Math.round(timestampAge / 1e3)}s old)`);
1821
+ if (globalThis.amount === void 0 || globalThis.amount === null) {
1822
+ throw new Error('Missing required parameter: "amount"');
1823
+ }
1824
+ const amount = BigInt(globalThis.amount);
1825
+ if (amount <= 0n) {
1826
+ throw new Error(`Amount must be positive, got: ${amount.toString()} wei`);
1827
+ }
1828
+ console.log(" Fetching protocol mint limits...");
1829
+ const minLoanValueResult = await Lit.Actions.call({
1830
+ chain,
1831
+ contractAddress: LOAN_OPS_MANAGER_ADDRESS,
1832
+ abi: ABI_MINIMUM_LOAN_VALUE_WEI,
1833
+ methodName: "minimumLoanValueWei",
1834
+ params: []
1835
+ });
1836
+ const MIN_MINT_AMOUNT = BigInt(minLoanValueResult.toString());
1837
+ const maxLoanValueUcdResult = await Lit.Actions.call({
1838
+ chain,
1839
+ contractAddress: LOAN_OPS_MANAGER_ADDRESS,
1840
+ abi: ABI_MAXIMUM_LOAN_VALUE_UCD,
1841
+ methodName: "maximumLoanValueUcd",
1842
+ params: []
1843
+ });
1844
+ const maxLoanValueUcd = BigInt(maxLoanValueUcdResult.toString());
1845
+ const MAX_MINT_AMOUNT = maxLoanValueUcd * 1000000000000000000n;
1846
+ console.log(` \u2705 Protocol limits loaded from LoanOperationsManager`);
1847
+ console.log(` - Minimum: ${MIN_MINT_AMOUNT.toString()} wei`);
1848
+ console.log(` - Maximum: ${MAX_MINT_AMOUNT.toString()} wei`);
1849
+ if (amount < MIN_MINT_AMOUNT) {
1850
+ throw new Error(
1851
+ `Amount too small: ${amount.toString()} wei. Minimum: ${MIN_MINT_AMOUNT.toString()} wei`
1852
+ );
1853
+ }
1854
+ if (amount > MAX_MINT_AMOUNT) {
1855
+ throw new Error(
1856
+ `Amount too large: ${amount.toString()} wei. Maximum: ${MAX_MINT_AMOUNT.toString()} wei`
1857
+ );
1858
+ }
1859
+ const publicKey = globalThis.publicKey;
1860
+ if (!publicKey) {
1861
+ throw new Error('Missing required parameter: "publicKey"');
1862
+ }
1863
+ if (typeof publicKey !== "string" || publicKey.length === 0) {
1864
+ throw new Error("Invalid publicKey: must be non-empty string");
1865
+ }
1866
+ let currentStep = "0.5";
1867
+ try {
1868
+ console.log("[UCD Mint Validator] Started");
1869
+ console.log(` Position: ${auth.positionId}`);
1870
+ console.log(` Amount: ${amount.toString()} wei`);
1871
+ const bitcoinProvider = new BitcoinDataProvider({
1872
+ providerUrl: bitcoinProviderConfig.url
1873
+ });
1874
+ const priceOracle = new PriceOracleModule();
1875
+ const vaultBalance = createVaultBalanceModule({
1876
+ contractAddress: LOAN_OPS_MANAGER_ADDRESS,
1877
+ chain,
1878
+ bitcoinProvider,
1879
+ minConfirmations: bitcoinProviderConfig.minConfirmations
1880
+ });
1881
+ currentStep = "1";
1882
+ console.log("[Step 1] Getting vault snapshot...");
1883
+ const vaultSnapshot = createVaultSnapshotModule({
1884
+ contractAddress: POSITION_MANAGER_ADDRESS,
1885
+ termManagerAddress: TERM_MANAGER_ADDRESS,
1886
+ loanOpsManagerAddress: LOAN_OPS_MANAGER_ADDRESS,
1887
+ chain,
1888
+ vaultBalance,
1889
+ priceOracle
1890
+ });
1891
+ const snapshot = await vaultSnapshot.getVaultSnapshot(auth.positionId);
1892
+ if (snapshot.btcPriceUsd < MIN_BTC_PRICE_USD || snapshot.btcPriceUsd > MAX_BTC_PRICE_USD) {
1893
+ throw new Error(
1894
+ `Bitcoin price ${snapshot.btcPriceUsd} is outside acceptable range (${MIN_BTC_PRICE_USD} - ${MAX_BTC_PRICE_USD}). This may indicate oracle manipulation or stale price data. Please try again later.`
1895
+ );
1896
+ }
1897
+ console.log(` \u2705 BTC price validation passed: $${(Number(snapshot.btcPriceUsd) / 1e8).toFixed(2)}`);
1898
+ currentStep = "2";
1899
+ console.log("[Step 2] Authenticating caller...");
1900
+ const isAuthorized = await AuthorizationModule.verifyMintAuthorization(
1901
+ auth,
1902
+ snapshot.borrower,
1903
+ // Expected owner from contract
1904
+ amount
1905
+ );
1906
+ if (!isAuthorized) {
1907
+ throw new Error("Caller not authorized");
1908
+ }
1909
+ console.log(" \u2705 Caller authorized");
1910
+ currentStep = "3";
1911
+ console.log("[Step 3] Validating position state...");
1912
+ const validMintingStates = getValidMintingStatuses();
1913
+ if (!validMintingStates.includes(snapshot.status)) {
1914
+ throw new Error(
1915
+ `Invalid position status for minting: ${loanStatusToString(
1916
+ snapshot.status
1917
+ )}. Must be PENDING_DEPOSIT, PENDING_MINT, or ACTIVE. Current status prevents minting operations.`
1918
+ );
1919
+ }
1920
+ console.log(
1921
+ ` \u2705 Position status valid for minting: ${loanStatusToString(
1922
+ snapshot.status
1923
+ )}`
1924
+ );
1925
+ if (snapshot.isExpired) {
1926
+ throw new Error(
1927
+ `Loan term expired ${snapshot.daysIntoGracePeriod} days ago - minting rejected. Must repay or extend term first.`
1928
+ );
1929
+ }
1930
+ if (snapshot.isLiquidatable) {
1931
+ throw new Error(
1932
+ `Position is liquidatable (${bpsToPercent(
1933
+ snapshot.collateralRatioBps
1934
+ )}% < ${bpsToPercent(
1935
+ snapshot.currentLiquidationThreshold
1936
+ )}%) - minting rejected. Add collateral first.`
1937
+ );
1938
+ }
1939
+ console.log(" \u2705 Position is healthy");
1940
+ console.log(` - Term: ${snapshot.daysUntilExpiry} days remaining`);
1941
+ console.log(
1942
+ ` - Current ratio: ${bpsToPercent(snapshot.collateralRatioBps)}%`
1943
+ );
1944
+ console.log(
1945
+ ` - Liquidation threshold: ${bpsToPercent(
1946
+ snapshot.currentLiquidationThreshold
1947
+ )}%`
1948
+ );
1949
+ currentStep = "4";
1950
+ console.log("[Step 4] Calculating fees...");
1951
+ const mintFee = calculateFeeBps(amount, snapshot.originationFeeBps);
1952
+ console.log(
1953
+ ` Mint fee: ${mintFee.toString()} wei (${bpsToPercent(snapshot.originationFeeBps)}%)`
1954
+ );
1955
+ currentStep = "4a";
1956
+ console.log("[Step 4a] Validating daily mint limit...");
1957
+ const totalMintAmount = amount + mintFee;
1958
+ const remainingCapacity = await Lit.Actions.call({
1959
+ chain,
1960
+ contractAddress: UCD_CONTROLLER_ADDRESS,
1961
+ abi: ABI_REMAINING_MINT_CAPACITY,
1962
+ methodName: "getRemainingMintCapacity",
1963
+ params: []
1964
+ });
1965
+ const remainingCapacityBigInt = BigInt(remainingCapacity.toString());
1966
+ if (totalMintAmount > remainingCapacityBigInt) {
1967
+ throw new Error(
1968
+ `Daily mint limit would be exceeded: attempting to mint ${totalMintAmount.toString()} wei (${amount.toString()} + ${mintFee.toString()} fee), but only ${remainingCapacityBigInt.toString()} wei remaining in daily capacity`
1969
+ );
1970
+ }
1971
+ console.log(" \u2705 Daily mint limit check passed");
1972
+ console.log(
1973
+ ` - Mint amount (with fee): ${totalMintAmount.toString()} wei`
1974
+ );
1975
+ console.log(
1976
+ ` - Remaining capacity: ${remainingCapacityBigInt.toString()} wei`
1977
+ );
1978
+ currentStep = "4b";
1979
+ console.log("[Step 4b] Validating max supply limit...");
1980
+ const maxSupplyResult = await Lit.Actions.call({
1981
+ chain,
1982
+ contractAddress: UCD_CONTROLLER_ADDRESS,
1983
+ abi: ABI_MAX_SUPPLY,
1984
+ methodName: "maxSupply",
1985
+ params: []
1986
+ });
1987
+ const maxSupply = BigInt(maxSupplyResult.toString());
1988
+ if (maxSupply > 0n) {
1989
+ const currentSupplyResult = await Lit.Actions.call({
1990
+ chain,
1991
+ contractAddress: UCD_CONTROLLER_ADDRESS,
1992
+ abi: ABI_CURRENT_SUPPLY,
1993
+ methodName: "getCurrentSupply",
1994
+ params: []
1995
+ });
1996
+ const currentSupply = BigInt(currentSupplyResult.toString());
1997
+ const projectedSupply = currentSupply + totalMintAmount;
1998
+ if (projectedSupply > maxSupply) {
1999
+ throw new Error(
2000
+ `Max supply would be exceeded: minting ${totalMintAmount.toString()} wei (${amount.toString()} + ${mintFee.toString()} fee) would result in total supply of ${projectedSupply.toString()} wei, exceeding max supply of ${maxSupply.toString()} wei`
2001
+ );
2002
+ }
2003
+ console.log(" \u2705 Max supply check passed");
2004
+ console.log(` - Current supply: ${currentSupply.toString()} wei`);
2005
+ console.log(` - After mint: ${projectedSupply.toString()} wei`);
2006
+ console.log(` - Max supply: ${maxSupply.toString()} wei`);
2007
+ console.log(
2008
+ ` - Remaining capacity: ${(maxSupply - currentSupply).toString()} wei`
2009
+ );
2010
+ } else {
2011
+ console.log(" \u2705 Max supply unlimited (not enforced)");
2012
+ }
2013
+ currentStep = "5";
2014
+ console.log("[Step 5] Calculating new state...");
2015
+ const newDebt = snapshot.ucdDebt + amount + mintFee;
2016
+ const newCollateral = snapshot.availableBTCSats;
2017
+ console.log(` Current debt: ${snapshot.ucdDebt.toString()} wei`);
2018
+ console.log(` New debt: ${newDebt.toString()} wei`);
2019
+ console.log(` Available collateral: ${newCollateral.toString()} sats`);
2020
+ currentStep = "6";
2021
+ console.log("[Step 6] Validating collateral ratio after mint...");
2022
+ const collateralValueUsd8Dec = calculateBtcValueUsd(
2023
+ newCollateral,
2024
+ snapshot.btcPriceUsd
2025
+ );
2026
+ const newCollateralRatioBps = calculateCollateralRatioBps(
2027
+ collateralValueUsd8Dec,
2028
+ newDebt
2029
+ );
2030
+ console.log(
2031
+ ` New collateral ratio: ${bpsToPercent(newCollateralRatioBps)}%`
2032
+ );
2033
+ console.log(
2034
+ ` Minimum required: ${bpsToPercent(snapshot.liquidationThresholdBps)}%`
2035
+ );
2036
+ if (!isCollateralSufficient(
2037
+ collateralValueUsd8Dec,
2038
+ newDebt,
2039
+ snapshot.liquidationThresholdBps
2040
+ )) {
2041
+ throw new Error(
2042
+ `Insufficient collateral: ${bpsToPercent(
2043
+ newCollateralRatioBps
2044
+ )}% < ${bpsToPercent(snapshot.liquidationThresholdBps)}%`
2045
+ );
2046
+ }
2047
+ console.log(" \u2705 Sufficient collateral");
2048
+ currentStep = "7";
2049
+ console.log("[Step 7] Building authorization message...");
2050
+ const authorizedSpendsHash = ethers.utils.keccak256(
2051
+ // @ts-ignore - ethers available in LIT runtime
2052
+ ethers.utils.defaultAbiCoder.encode(
2053
+ ["uint256"],
2054
+ // Simplified: just use total authorized amount
2055
+ [snapshot.authorizedSpendsSats]
2056
+ )
2057
+ );
2058
+ const messageParams = {
2059
+ authorizedSpendsHash,
2060
+ btcPrice: snapshot.btcPriceUsd.toString(),
2061
+ mintAmount: amount.toString(),
2062
+ mintFee: mintFee.toString(),
2063
+ newCollateral: newCollateral.toString(),
2064
+ newDebt: newDebt.toString()
2065
+ };
2066
+ const message = ethers.utils.keccak256(
2067
+ // @ts-ignore - ethers available in LIT runtime
2068
+ ethers.utils.defaultAbiCoder.encode(
2069
+ [
2070
+ "bytes32",
2071
+ "bytes32",
2072
+ "bytes32",
2073
+ "bytes32",
2074
+ "bytes32",
2075
+ "bytes32",
2076
+ "uint256"
2077
+ ],
2078
+ [
2079
+ auth.positionId,
2080
+ // @ts-ignore - ethers available in LIT runtime
2081
+ ethers.utils.hexZeroPad(
2082
+ ethers.utils.hexlify(BigInt(messageParams.authorizedSpendsHash)),
2083
+ 32
2084
+ ),
2085
+ // @ts-ignore - ethers available in LIT runtime
2086
+ ethers.utils.hexZeroPad(
2087
+ ethers.utils.hexlify(BigInt(messageParams.btcPrice)),
2088
+ 32
2089
+ ),
2090
+ // @ts-ignore - ethers available in LIT runtime
2091
+ ethers.utils.hexZeroPad(
2092
+ ethers.utils.hexlify(BigInt(messageParams.mintAmount)),
2093
+ 32
2094
+ ),
2095
+ // @ts-ignore - ethers available in LIT runtime
2096
+ ethers.utils.hexZeroPad(
2097
+ ethers.utils.hexlify(BigInt(messageParams.mintFee)),
2098
+ 32
2099
+ ),
2100
+ // @ts-ignore - ethers available in LIT runtime
2101
+ ethers.utils.hexZeroPad(
2102
+ ethers.utils.hexlify(BigInt(messageParams.newCollateral)),
2103
+ 32
2104
+ ),
2105
+ // @ts-ignore - ethers available in LIT runtime
2106
+ ethers.utils.hexZeroPad(
2107
+ ethers.utils.hexlify(BigInt(messageParams.newDebt)),
2108
+ 32
2109
+ ),
2110
+ auth.timestamp
2111
+ ]
2112
+ )
2113
+ );
2114
+ currentStep = "8";
2115
+ console.log("[Step 8] Signing authorization...");
2116
+ const signature = await Lit.Actions.signEcdsa({
2117
+ // @ts-ignore - ethers available in LIT runtime
2118
+ toSign: ethers.utils.arrayify(message),
2119
+ publicKey,
2120
+ sigName: "ucdMintAuth"
2121
+ });
2122
+ console.log("[UCD Mint Validator] \u2705 Complete");
2123
+ Lit.Actions.setResponse({
2124
+ response: JSON.stringify({
2125
+ approved: true,
2126
+ positionId: auth.positionId,
2127
+ mintAmount: amount.toString(),
2128
+ mintFee: mintFee.toString(),
2129
+ newDebt: newDebt.toString(),
2130
+ newCollateral: newCollateral.toString(),
2131
+ newCollateralRatioBps,
2132
+ btcPrice: snapshot.btcPriceUsd.toString(),
2133
+ authorizedSpendsHash,
2134
+ signature,
2135
+ timestamp: auth.timestamp,
2136
+ validatorPkp: publicKey
2137
+ })
2138
+ });
2139
+ } catch (error) {
2140
+ console.error("[UCD Mint Validator] \u274C Failed:", error.message);
2141
+ console.error(`[UCD Mint Validator] Failed at step: ${currentStep}`);
2142
+ Lit.Actions.setResponse({
2143
+ response: JSON.stringify({
2144
+ approved: false,
2145
+ reason: error.message || error.toString(),
2146
+ failedStep: currentStep,
2147
+ positionId: auth?.positionId,
2148
+ timestamp: Date.now()
2149
+ })
2150
+ });
2151
+ }
2152
+ };
2153
+ go();
2154
+ })();