@hardkas/toolkit 0.11.1-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +21 -0
  3. package/dist/dag/consensus.d.ts +20 -0
  4. package/dist/dag/consensus.d.ts.map +1 -0
  5. package/dist/dag/consensus.js +131 -0
  6. package/dist/dag/consensus.js.map +1 -0
  7. package/dist/dag/index.d.ts +28 -0
  8. package/dist/dag/index.d.ts.map +1 -0
  9. package/dist/dag/index.js +57 -0
  10. package/dist/dag/index.js.map +1 -0
  11. package/dist/dag/store.d.ts +11 -0
  12. package/dist/dag/store.d.ts.map +1 -0
  13. package/dist/dag/store.js +32 -0
  14. package/dist/dag/store.js.map +1 -0
  15. package/dist/dag/topology.d.ts +14 -0
  16. package/dist/dag/topology.d.ts.map +1 -0
  17. package/dist/dag/topology.js +98 -0
  18. package/dist/dag/topology.js.map +1 -0
  19. package/dist/dag/types.d.ts +21 -0
  20. package/dist/dag/types.d.ts.map +1 -0
  21. package/dist/dag/types.js +2 -0
  22. package/dist/dag/types.js.map +1 -0
  23. package/dist/index.d.ts +10 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +10 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/indexer.d.ts +34 -0
  28. package/dist/indexer.d.ts.map +1 -0
  29. package/dist/indexer.js +111 -0
  30. package/dist/indexer.js.map +1 -0
  31. package/dist/jobs.d.ts +18 -0
  32. package/dist/jobs.d.ts.map +1 -0
  33. package/dist/jobs.js +33 -0
  34. package/dist/jobs.js.map +1 -0
  35. package/dist/payment.d.ts +25 -0
  36. package/dist/payment.d.ts.map +1 -0
  37. package/dist/payment.js +66 -0
  38. package/dist/payment.js.map +1 -0
  39. package/dist/silver/index.d.ts +20 -0
  40. package/dist/silver/index.d.ts.map +1 -0
  41. package/dist/silver/index.js +94 -0
  42. package/dist/silver/index.js.map +1 -0
  43. package/dist/silver/templates.d.ts +2 -0
  44. package/dist/silver/templates.d.ts.map +1 -0
  45. package/dist/silver/templates.js +28 -0
  46. package/dist/silver/templates.js.map +1 -0
  47. package/dist/silver/types.d.ts +34 -0
  48. package/dist/silver/types.d.ts.map +1 -0
  49. package/dist/silver/types.js +2 -0
  50. package/dist/silver/types.js.map +1 -0
  51. package/dist/snapshot/backends/FsSnapshotBackend.d.ts +14 -0
  52. package/dist/snapshot/backends/FsSnapshotBackend.d.ts.map +1 -0
  53. package/dist/snapshot/backends/FsSnapshotBackend.js +82 -0
  54. package/dist/snapshot/backends/FsSnapshotBackend.js.map +1 -0
  55. package/dist/snapshot/backends/MemorySnapshotBackend.d.ts +13 -0
  56. package/dist/snapshot/backends/MemorySnapshotBackend.d.ts.map +1 -0
  57. package/dist/snapshot/backends/MemorySnapshotBackend.js +55 -0
  58. package/dist/snapshot/backends/MemorySnapshotBackend.js.map +1 -0
  59. package/dist/snapshot/index.d.ts +19 -0
  60. package/dist/snapshot/index.d.ts.map +1 -0
  61. package/dist/snapshot/index.js +84 -0
  62. package/dist/snapshot/index.js.map +1 -0
  63. package/dist/snapshot/serialization.d.ts +18 -0
  64. package/dist/snapshot/serialization.d.ts.map +1 -0
  65. package/dist/snapshot/serialization.js +31 -0
  66. package/dist/snapshot/serialization.js.map +1 -0
  67. package/dist/snapshot/types.d.ts +44 -0
  68. package/dist/snapshot/types.d.ts.map +1 -0
  69. package/dist/snapshot/types.js +2 -0
  70. package/dist/snapshot/types.js.map +1 -0
  71. package/dist/stores/invoice-store.d.ts +26 -0
  72. package/dist/stores/invoice-store.d.ts.map +1 -0
  73. package/dist/stores/invoice-store.js +34 -0
  74. package/dist/stores/invoice-store.js.map +1 -0
  75. package/dist/stores/utxo-control-store.d.ts +23 -0
  76. package/dist/stores/utxo-control-store.d.ts.map +1 -0
  77. package/dist/stores/utxo-control-store.js +64 -0
  78. package/dist/stores/utxo-control-store.js.map +1 -0
  79. package/dist/utxos.d.ts +128 -0
  80. package/dist/utxos.d.ts.map +1 -0
  81. package/dist/utxos.js +331 -0
  82. package/dist/utxos.js.map +1 -0
  83. package/dist/wallet.d.ts +34 -0
  84. package/dist/wallet.d.ts.map +1 -0
  85. package/dist/wallet.js +72 -0
  86. package/dist/wallet.js.map +1 -0
  87. package/package.json +28 -0
  88. package/src/dag/consensus.ts +150 -0
  89. package/src/dag/index.ts +73 -0
  90. package/src/dag/store.ts +40 -0
  91. package/src/dag/topology.ts +108 -0
  92. package/src/dag/types.ts +20 -0
  93. package/src/index.ts +9 -0
  94. package/src/indexer.ts +128 -0
  95. package/src/jobs.ts +46 -0
  96. package/src/payment.ts +81 -0
  97. package/src/silver/index.ts +102 -0
  98. package/src/silver/templates.ts +27 -0
  99. package/src/silver/types.ts +37 -0
  100. package/src/snapshot/backends/FsSnapshotBackend.ts +100 -0
  101. package/src/snapshot/backends/MemorySnapshotBackend.ts +65 -0
  102. package/src/snapshot/index.ts +102 -0
  103. package/src/snapshot/serialization.ts +35 -0
  104. package/src/snapshot/types.ts +45 -0
  105. package/src/stores/invoice-store.ts +53 -0
  106. package/src/stores/utxo-control-store.ts +78 -0
  107. package/src/utxos.ts +451 -0
  108. package/src/wallet.ts +93 -0
  109. package/test/silver.test.ts +59 -0
  110. package/test/snapshot.test.ts +99 -0
  111. package/test/toolkit.test.ts +109 -0
  112. package/tsconfig.json +9 -0
  113. package/vitest.config.ts +8 -0
package/src/utxos.ts ADDED
@@ -0,0 +1,451 @@
1
+ import { Utxo } from "@hardkas/query";
2
+
3
+ export interface UtxoStatistics {
4
+ totalUtxos: number;
5
+ availableUtxos: number;
6
+ frozenUtxos: number;
7
+ totalValue: bigint;
8
+ availableValue: bigint;
9
+ frozenValue: bigint;
10
+ averageValue: bigint;
11
+ largest: bigint;
12
+ smallest: bigint;
13
+ includeFrozen: boolean;
14
+ }
15
+
16
+ export interface DustAnalysis {
17
+ dustUtxos: Utxo[];
18
+ normalUtxos: Utxo[];
19
+ totalDustValue: bigint;
20
+ dustPercentage: number;
21
+ }
22
+
23
+ export type Recommendation = "consolidate" | "mergeDust" | "splitLarge" | "none";
24
+
25
+ export interface WalletAnalysis {
26
+ utxoCount: number;
27
+ fragmentationScore: number; // 0.0 to 1.0 (e.g. dustCount / totalUtxos)
28
+ dustCount: number;
29
+ totalValue: bigint;
30
+ recommendedActions: Recommendation[];
31
+ }
32
+
33
+ export interface ConsolidationPlan {
34
+ strategy: string;
35
+ inputs: Utxo[];
36
+ outputs: any[]; // Target outputs (usually one consolidated output back to the wallet)
37
+ estimatedFee: bigint;
38
+ estimatedSavings: bigint; // Approximated future fee savings
39
+ warnings: string[];
40
+ }
41
+
42
+ export interface SplitPlan extends ConsolidationPlan {
43
+ strategy: "split";
44
+ }
45
+
46
+ export interface MergePlan extends ConsolidationPlan {
47
+ strategy: "merge";
48
+ }
49
+
50
+ export interface SweepPlan extends ConsolidationPlan {
51
+ strategy: "sweep";
52
+ }
53
+
54
+ import { UtxoControlStore, UtxoControlState } from './stores/utxo-control-store.js';
55
+
56
+ export interface UtxoListOpts { includeFrozen?: boolean | undefined }
57
+
58
+ export class WalletUtxoApi {
59
+ constructor(
60
+ private readonly fetchUtxos: () => Promise<Utxo[]>,
61
+ private readonly address: () => Promise<string>,
62
+ private readonly estimateFee: (inputs: number, outputs: number) => Promise<bigint>,
63
+ private readonly store: UtxoControlStore
64
+ ) {}
65
+
66
+ /**
67
+ * List UTXOs currently available in the wallet.
68
+ * Frozen UTXOs are excluded by default unless includeFrozen: true.
69
+ */
70
+ async list(opts?: UtxoListOpts): Promise<Utxo[]> {
71
+ const all = await this.fetchUtxos();
72
+ const state = this.store.getState();
73
+ if (opts?.includeFrozen) {
74
+ return all;
75
+ }
76
+ return all.filter(u => !state.frozen[u.transactionId] && !state.frozen[`${u.transactionId}:${u.outputIndex}`]);
77
+ }
78
+
79
+ /**
80
+ * Return basic statistics about the UTXO set.
81
+ */
82
+ async statistics(opts?: UtxoListOpts): Promise<UtxoStatistics> {
83
+ const allUtxos = await this.fetchUtxos();
84
+ const state = this.store.getState();
85
+
86
+ let totalUtxos = 0;
87
+ let availableUtxos = 0;
88
+ let frozenUtxos = 0;
89
+
90
+ let totalValue = 0n;
91
+ let availableValue = 0n;
92
+ let frozenValue = 0n;
93
+
94
+ let largest = -1n;
95
+ let smallest = -1n;
96
+
97
+ const includeFrozen = opts?.includeFrozen ?? false;
98
+
99
+ for (const u of allUtxos) {
100
+ const amt = BigInt(u.amountSompi);
101
+ const isFrozen = !!(state.frozen[u.transactionId] || state.frozen[`${u.transactionId}:${u.outputIndex}`]);
102
+
103
+ totalUtxos++;
104
+ totalValue += amt;
105
+
106
+ if (isFrozen) {
107
+ frozenUtxos++;
108
+ frozenValue += amt;
109
+ } else {
110
+ availableUtxos++;
111
+ availableValue += amt;
112
+ }
113
+
114
+ // Largest/smallest only counts what is included
115
+ if (includeFrozen || !isFrozen) {
116
+ if (largest === -1n || amt > largest) largest = amt;
117
+ if (smallest === -1n || amt < smallest) smallest = amt;
118
+ }
119
+ }
120
+
121
+ const countForAvg = includeFrozen ? totalUtxos : availableUtxos;
122
+ const valueForAvg = includeFrozen ? totalValue : availableValue;
123
+
124
+ return {
125
+ totalUtxos,
126
+ availableUtxos,
127
+ frozenUtxos,
128
+ totalValue,
129
+ availableValue,
130
+ frozenValue,
131
+ averageValue: countForAvg === 0 ? 0n : valueForAvg / BigInt(countForAvg),
132
+ largest: largest === -1n ? 0n : largest,
133
+ smallest: smallest === -1n ? 0n : smallest,
134
+ includeFrozen
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Classify UTXOs into dust and normal based on a threshold.
140
+ * Default threshold: 10 KAS (1_000_000_000 sompi).
141
+ */
142
+ async analyzeDust(opts?: { thresholdSompi?: bigint, includeFrozen?: boolean | undefined }): Promise<DustAnalysis> {
143
+ const threshold = opts?.thresholdSompi ?? 1_000_000_000n;
144
+ const utxos = await this.list({ includeFrozen: opts?.includeFrozen });
145
+
146
+ const dustUtxos: Utxo[] = [];
147
+ const normalUtxos: Utxo[] = [];
148
+ let totalDustValue = 0n;
149
+
150
+ for (const u of utxos) {
151
+ const amt = BigInt(u.amountSompi);
152
+ if (amt < threshold) {
153
+ dustUtxos.push(u);
154
+ totalDustValue += amt;
155
+ } else {
156
+ normalUtxos.push(u);
157
+ }
158
+ }
159
+
160
+ return {
161
+ dustUtxos,
162
+ normalUtxos,
163
+ totalDustValue,
164
+ dustPercentage: utxos.length > 0 ? dustUtxos.length / utxos.length : 0
165
+ };
166
+ }
167
+
168
+ /**
169
+ * High-level diagnostic analysis of the wallet's UTXO health.
170
+ */
171
+ async analyze(opts?: UtxoListOpts): Promise<WalletAnalysis> {
172
+ const stats = await this.statistics(opts);
173
+ const dust = await this.analyzeDust(opts);
174
+
175
+ const fragmentationScore = dust.dustPercentage;
176
+ const actions: Recommendation[] = [];
177
+
178
+ // We base actions on available counts
179
+ const activeUtxoCount = opts?.includeFrozen ? stats.totalUtxos : stats.availableUtxos;
180
+
181
+ if (fragmentationScore > 0.5 && activeUtxoCount > 10) {
182
+ actions.push("consolidate");
183
+ } else if (dust.dustUtxos.length > 5) {
184
+ actions.push("mergeDust");
185
+ }
186
+
187
+ // Simplistic threshold for splitLarge: if there are very few UTXOs but high value
188
+ if (activeUtxoCount < 3 && stats.largest > 10_000_000_000n) {
189
+ actions.push("splitLarge");
190
+ }
191
+
192
+ if (actions.length === 0) {
193
+ actions.push("none");
194
+ }
195
+
196
+ return {
197
+ utxoCount: activeUtxoCount,
198
+ fragmentationScore,
199
+ dustCount: dust.dustUtxos.length,
200
+ totalValue: opts?.includeFrozen ? stats.totalValue : stats.availableValue,
201
+ recommendedActions: actions
202
+ };
203
+ }
204
+
205
+ /**
206
+ * Plan a UTXO management operation without executing it.
207
+ */
208
+ async plan(opts: { strategy: "consolidation" | "split" | "sweep", includeFrozen?: boolean | undefined }): Promise<ConsolidationPlan> {
209
+ const utxos = await this.list({ includeFrozen: opts.includeFrozen });
210
+ const myAddress = await this.address();
211
+
212
+ if (opts.strategy === "consolidation") {
213
+ // In a real scenario, the CoinSelector would chunk this to respect mass limits.
214
+ // For Phase 1A, we will just select up to 84 (a safe mass limit approximation for Kaspa).
215
+ const MAX_INPUTS = 84;
216
+
217
+ // Sort smallest first to consolidate dust
218
+ const sorted = [...utxos].sort((a, b) => (BigInt(a.amountSompi) < BigInt(b.amountSompi) ? -1 : 1));
219
+ const inputs = sorted.slice(0, MAX_INPUTS);
220
+
221
+ const estimatedFee = await this.estimateFee(inputs.length, 1);
222
+
223
+ const totalInputValue = inputs.reduce((sum, u) => sum + BigInt(u.amountSompi), 0n);
224
+
225
+ const warnings: string[] = [];
226
+ if (inputs.length < 2) {
227
+ warnings.push("Not enough UTXOs to consolidate.");
228
+ }
229
+ if (totalInputValue <= estimatedFee) {
230
+ warnings.push("Consolidation fee exceeds the value of the selected UTXOs.");
231
+ }
232
+
233
+ // If we spent these 84 UTXOs individually in the future, we would pay for 1 input each time.
234
+ // 84 txs with 1 input vs 1 tx with 84 inputs.
235
+ // This is a naive calculation for "estimated savings".
236
+ const individualFee = await this.estimateFee(1, 2);
237
+ const estimatedSavings = (individualFee * BigInt(inputs.length)) - estimatedFee;
238
+
239
+ return {
240
+ strategy: opts.strategy,
241
+ inputs,
242
+ outputs: [{ address: myAddress, amount: totalInputValue - estimatedFee }],
243
+ estimatedFee,
244
+ estimatedSavings: estimatedSavings > 0n ? estimatedSavings : 0n,
245
+ warnings
246
+ };
247
+ }
248
+
249
+ throw new Error(`Strategy '${opts.strategy}' not fully implemented yet.`);
250
+ }
251
+
252
+ /**
253
+ * Convenience alias for planning a consolidation.
254
+ */
255
+ async consolidate(opts?: { includeFrozen?: boolean | undefined }): Promise<ConsolidationPlan> {
256
+ return this.plan({ strategy: "consolidation", includeFrozen: opts?.includeFrozen });
257
+ }
258
+
259
+ private _assertNotFrozen(utxoId: string, includeFrozen?: boolean) {
260
+ if (!includeFrozen && this.store.getState().frozen[utxoId]) {
261
+ throw new Error(`UTXO ${utxoId} is frozen and cannot be planned for execution`);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Plans the splitting of a specific large UTXO.
267
+ * Supports symmetric splits (intoCount) OR asymmetric splits (outputs).
268
+ */
269
+ async splitPlan(opts: {
270
+ utxoId: string;
271
+ intoCount?: number;
272
+ outputs?: { amountSompi: bigint }[];
273
+ includeFrozen?: boolean | undefined;
274
+ }): Promise<SplitPlan> {
275
+ this._assertNotFrozen(opts.utxoId, opts.includeFrozen);
276
+ const utxos = await this.list({ includeFrozen: opts.includeFrozen });
277
+ const targetUtxo = utxos.find(u => u.transactionId === opts.utxoId || `${u.transactionId}:${u.outputIndex}` === opts.utxoId);
278
+
279
+ if (!targetUtxo) {
280
+ throw new Error(`UTXO ${opts.utxoId} not found in wallet.`);
281
+ }
282
+
283
+ const myAddress = await this.address();
284
+ const warnings: string[] = [];
285
+ const planOutputs: { address: string, amount: bigint }[] = [];
286
+
287
+ const targetAmount = BigInt(targetUtxo.amountSompi);
288
+ let outputCount = 0;
289
+
290
+ if (opts.intoCount !== undefined && opts.outputs === undefined) {
291
+ outputCount = opts.intoCount;
292
+ if (outputCount < 2) throw new Error("splitPlan requires intoCount >= 2.");
293
+
294
+ const estimatedFee = await this.estimateFee(1, outputCount);
295
+ const remainingAmount = targetAmount - estimatedFee;
296
+
297
+ if (remainingAmount <= 0n) {
298
+ throw new Error("Target UTXO does not have enough funds to cover the split and fees.");
299
+ }
300
+
301
+ const chunkAmount = remainingAmount / BigInt(outputCount);
302
+ for (let i = 0; i < outputCount; i++) {
303
+ planOutputs.push({ address: myAddress, amount: chunkAmount });
304
+ }
305
+ } else if (opts.outputs !== undefined && opts.intoCount === undefined) {
306
+ outputCount = opts.outputs.length;
307
+ if (outputCount < 2) throw new Error("splitPlan requires outputs.length >= 2.");
308
+
309
+ const estimatedFee = await this.estimateFee(1, outputCount + 1); // +1 for change
310
+ let sumOutputs = 0n;
311
+
312
+ for (const out of opts.outputs) {
313
+ sumOutputs += out.amountSompi;
314
+ planOutputs.push({ address: myAddress, amount: out.amountSompi });
315
+ }
316
+
317
+ const changeAmount = targetAmount - sumOutputs - estimatedFee;
318
+ if (changeAmount < 0n) {
319
+ throw new Error("Target UTXO does not have enough funds to cover the requested outputs and fees.");
320
+ }
321
+
322
+ if (changeAmount > 0n) {
323
+ // Add change output
324
+ planOutputs.push({ address: myAddress, amount: changeAmount });
325
+ }
326
+ } else {
327
+ throw new Error("splitPlan requires exactly one of 'intoCount' or 'outputs' to be defined.");
328
+ }
329
+
330
+ const estimatedFee = await this.estimateFee(1, planOutputs.length);
331
+
332
+ return {
333
+ strategy: "split",
334
+ inputs: [targetUtxo],
335
+ outputs: planOutputs,
336
+ estimatedFee,
337
+ estimatedSavings: 0n,
338
+ warnings
339
+ };
340
+ }
341
+
342
+ /**
343
+ * Plans the explicit merging of specific UTXOs.
344
+ */
345
+ async mergePlan(opts: {
346
+ utxoIds: string[];
347
+ includeFrozen?: boolean | undefined;
348
+ }): Promise<MergePlan> {
349
+ for (const id of opts.utxoIds) {
350
+ this._assertNotFrozen(id, opts.includeFrozen);
351
+ }
352
+ const utxos = await this.list({ includeFrozen: opts.includeFrozen });
353
+ const inputs: Utxo[] = [];
354
+
355
+ for (const id of opts.utxoIds) {
356
+ const u = utxos.find(utxo => utxo.transactionId === id || `${utxo.transactionId}:${utxo.outputIndex}` === id);
357
+ if (!u) {
358
+ throw new Error(`UTXO ${id} not found in wallet.`);
359
+ }
360
+ inputs.push(u);
361
+ }
362
+
363
+ if (inputs.length < 2) {
364
+ throw new Error("mergePlan requires at least 2 UTXOs to merge.");
365
+ }
366
+
367
+ const estimatedFee = await this.estimateFee(inputs.length, 1);
368
+ const totalInputValue = inputs.reduce((sum, u) => sum + BigInt(u.amountSompi), 0n);
369
+ const myAddress = await this.address();
370
+
371
+ const warnings: string[] = [];
372
+ if (totalInputValue <= estimatedFee) {
373
+ warnings.push("Merge fee exceeds the value of the selected UTXOs.");
374
+ }
375
+
376
+ const individualFee = await this.estimateFee(1, 2);
377
+ const estimatedSavings = (individualFee * BigInt(inputs.length)) - estimatedFee;
378
+
379
+ return {
380
+ strategy: "merge",
381
+ inputs,
382
+ outputs: [{ address: myAddress, amount: totalInputValue > estimatedFee ? totalInputValue - estimatedFee : 0n }],
383
+ estimatedFee,
384
+ estimatedSavings: estimatedSavings > 0n ? estimatedSavings : 0n,
385
+ warnings
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Plans sweeping the entire wallet to a single destination address.
391
+ */
392
+ async sweepPlan(opts: {
393
+ destinationAddress: string;
394
+ includeFrozen?: boolean | undefined;
395
+ }): Promise<SweepPlan> {
396
+ const utxos = await this.list({ includeFrozen: opts.includeFrozen });
397
+
398
+ if (utxos.length === 0) {
399
+ throw new Error("Cannot sweep an empty wallet.");
400
+ }
401
+
402
+ const estimatedFee = await this.estimateFee(utxos.length, 1);
403
+ const totalInputValue = utxos.reduce((sum, u) => sum + BigInt(u.amountSompi), 0n);
404
+ const warnings: string[] = [];
405
+
406
+ if (totalInputValue <= estimatedFee) {
407
+ warnings.push("Sweep fee exceeds the entire wallet balance.");
408
+ }
409
+
410
+ return {
411
+ strategy: "sweep",
412
+ inputs: utxos,
413
+ outputs: [{ address: opts.destinationAddress, amount: totalInputValue > estimatedFee ? totalInputValue - estimatedFee : 0n }],
414
+ estimatedFee,
415
+ estimatedSavings: 0n,
416
+ warnings
417
+ };
418
+ }
419
+
420
+ // --- Coin Control Persistence ---
421
+
422
+ async controlState(): Promise<UtxoControlState> {
423
+ return this.store.getState();
424
+ }
425
+
426
+ async freeze(utxoId: string, reason?: string): Promise<void> {
427
+ this.store.freeze(utxoId, reason);
428
+ }
429
+
430
+ async unfreeze(utxoId: string): Promise<void> {
431
+ this.store.unfreeze(utxoId);
432
+ }
433
+
434
+ public labels = {
435
+ set: async (utxoId: string, label: string): Promise<void> => {
436
+ this.store.setLabel(utxoId, label);
437
+ },
438
+ get: async (utxoId: string): Promise<string | undefined> => {
439
+ return this.store.getLabel(utxoId);
440
+ }
441
+ };
442
+
443
+ public notes = {
444
+ set: async (utxoId: string, note: string): Promise<void> => {
445
+ this.store.setNote(utxoId, note);
446
+ },
447
+ get: async (utxoId: string): Promise<string | undefined> => {
448
+ return this.store.getNote(utxoId);
449
+ }
450
+ };
451
+ }
package/src/wallet.ts ADDED
@@ -0,0 +1,93 @@
1
+ import { WalletManagerImpl, WalletStateStoreJson, AddressManager } from '@hardkas/accounts';
2
+ import { WalletQuery } from '@hardkas/query';
3
+ import { selectCoins, estimateFee, buildPaymentPlan } from '@hardkas/tx-builder';
4
+ import { WalletUtxoApi } from './utxos.js';
5
+ import { UtxoControlStore } from './stores/utxo-control-store.js';
6
+
7
+ export interface WalletToolkitOptions {
8
+ storePath?: string;
9
+ network?: "simnet" | "testnet" | "mainnet";
10
+ }
11
+
12
+ export class WalletToolkit {
13
+ private walletManager: WalletManagerImpl;
14
+ private addressManager: typeof AddressManager;
15
+ private walletQuery: WalletQuery;
16
+
17
+ private _utxosApi: WalletUtxoApi;
18
+
19
+ private constructor(
20
+ public readonly name: string,
21
+ private readonly store: WalletStateStoreJson,
22
+ private readonly options: WalletToolkitOptions
23
+ ) {
24
+ this.walletManager = new WalletManagerImpl();
25
+ this.addressManager = AddressManager;
26
+ this.walletQuery = new WalletQuery({} as any);
27
+
28
+ const utxoControlPath = options.storePath
29
+ ? options.storePath.replace('.json', '-utxo-control.json')
30
+ : 'default-utxo-control.json';
31
+ const utxoStore = new UtxoControlStore(utxoControlPath);
32
+
33
+ this._utxosApi = new WalletUtxoApi(
34
+ async () => (await this.walletQuery.getUtxos([await this.address()])) as any,
35
+ async () => this.address(),
36
+ async (inputs: number, outputs: number) => BigInt(inputs * 1000 + outputs * 1000), // Dummy fee estimation
37
+ utxoStore
38
+ );
39
+ }
40
+
41
+ public static open(name: string, options: WalletToolkitOptions = {}): WalletToolkit {
42
+ const store = new WalletStateStoreJson({ filePath: options.storePath || 'default.json' });
43
+ return new WalletToolkit(name, store, options);
44
+ }
45
+
46
+ public async create(): Promise<void> {
47
+ this.walletManager.create({ walletId: this.name });
48
+ }
49
+
50
+ public async address(): Promise<string> {
51
+ // High level facade: gets receive address
52
+ const seedRef = this.walletManager.getSeedRef(this.name);
53
+ return this.addressManager.derive({
54
+ seedRef,
55
+ accountIndex: 0,
56
+ chain: 'receive',
57
+ addressIndex: 0,
58
+ network: this.options.network || 'simnet'
59
+ }).address;
60
+ }
61
+
62
+ public async balance(): Promise<bigint> {
63
+ const addr = await this.address();
64
+ return (await this.walletQuery.getBalance([addr])) as any;
65
+ }
66
+
67
+ public get utxos(): WalletUtxoApi {
68
+ return this._utxosApi;
69
+ }
70
+
71
+ public async history(): Promise<any[]> {
72
+ const addr = await this.address();
73
+ return (await this.walletQuery.getHistory({ addresses: [addr] })) as any;
74
+ }
75
+
76
+ public async estimateFee(opts: { to: string; amount: bigint }): Promise<any> {
77
+ const u = await this.utxos.list();
78
+ // Since we are mocking the facade, just return a fake fee
79
+ return {
80
+ selectedUtxos: [],
81
+ fee: 1000n,
82
+ totalOut: BigInt(opts.amount) + 1000n
83
+ };
84
+ }
85
+
86
+ public async planSend(opts: { to: string; amount: bigint }): Promise<any> {
87
+ return {};
88
+ }
89
+
90
+ public async sendSimulated(opts: { to: string; amount: bigint }): Promise<string> {
91
+ return `simulated_tx_${Date.now()}`;
92
+ }
93
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SilverToolkit } from '../src/index.js';
3
+
4
+ describe('SilverToolkit', () => {
5
+ it('should list all templates', () => {
6
+ const silver = SilverToolkit.open();
7
+ const templates = silver.templates();
8
+ expect(templates).toContain('htlc');
9
+ expect(templates).toContain('op-true');
10
+ expect(templates).toContain('multisig');
11
+ });
12
+
13
+ it('should extract template parameters', () => {
14
+ const silver = SilverToolkit.open();
15
+ const tpl = silver.template('htlc');
16
+ const params = tpl.parameters();
17
+ expect(params).toEqual(expect.arrayContaining(['secret_hash', 'receiver_pubkey', 'locktime', 'sender_pubkey']));
18
+ });
19
+
20
+ it('should fill template with parameters', () => {
21
+ const silver = SilverToolkit.open();
22
+ const tpl = silver.template('timelock');
23
+ const source = tpl.fill({ locktime: 500000, pubkey: 'abcd123' });
24
+ expect(source).toContain('500000 OP_CHECKLOCKTIMEVERIFY OP_DROP');
25
+ expect(source).toContain('abcd123 OP_CHECKSIG');
26
+ });
27
+
28
+ it('should throw if missing parameters during fill', () => {
29
+ const silver = SilverToolkit.open();
30
+ const tpl = silver.template('timelock');
31
+ expect(() => tpl.fill({ locktime: 500000 })).toThrow(/Missing parameter/);
32
+ });
33
+
34
+ it('should enforce restrictive claims on build', async () => {
35
+ const silver = SilverToolkit.open();
36
+ const build = await silver.build('OP_TRUE');
37
+ expect(build.bytecode).toBeDefined();
38
+ expect(build.claims.realSilverCompiler).toBe(false);
39
+ expect(build.claims.simulatedOnly).toBe(true);
40
+ });
41
+
42
+ it('should enforce restrictive claims on simulate, artifact, and evidence', async () => {
43
+ const silver = SilverToolkit.open();
44
+ const build = await silver.build('OP_TRUE');
45
+
46
+ const simulation = await silver.simulate(build);
47
+ expect(simulation.claims.vmConsensusEquivalence).toBe(false);
48
+
49
+ const artifact = await silver.artifact(build, "test-script");
50
+ expect(artifact.claims.productionSafe).toBe(false);
51
+ expect(artifact.name).toBe("test-script");
52
+
53
+ const evidence = await silver.evidence(build, simulation);
54
+ expect(evidence.claims.mainnetReady).toBe(false);
55
+ expect(evidence.schema).toBe("hardkas.script-evidence.v1");
56
+ expect(evidence.simulationResult).not.toHaveProperty('claims'); // internal property
57
+ expect(evidence.simulationResult.success).toBe(true);
58
+ });
59
+ });
@@ -0,0 +1,99 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { SnapshotToolkit } from '../src/snapshot/index.js';
3
+ import { JobsToolkit } from '../src/jobs.js';
4
+ import fs from 'fs-extra';
5
+ import path from 'path';
6
+
7
+ describe('SnapshotToolkit (Time Travel)', () => {
8
+ let snapshots: SnapshotToolkit;
9
+ let jobs: JobsToolkit;
10
+
11
+ const testDir = path.join(process.cwd(), '.test-snapshots');
12
+
13
+ beforeEach(async () => {
14
+ await fs.ensureDir(testDir);
15
+ snapshots = SnapshotToolkit.open({ backend: 'filesystem', dir: testDir });
16
+ jobs = JobsToolkit.open({ storePath: path.join(testDir, 'jobs.json') });
17
+ snapshots.register('jobs', jobs);
18
+ });
19
+
20
+ afterEach(async () => {
21
+ await fs.remove(testDir);
22
+ });
23
+
24
+ it('should save, mutate, and restore state', async () => {
25
+ // State 1
26
+ await jobs.enqueue('payment', { to: 'alice' });
27
+ const s1 = await snapshots.create('state1');
28
+
29
+ let job1 = await jobs.getJob((await jobs.snapshot() as any)[Object.keys(await jobs.snapshot())[0]].id);
30
+ expect(job1).toBeDefined();
31
+
32
+ // Mutate State 2
33
+ await jobs.enqueue('payment', { to: 'bob' });
34
+
35
+ let stateKeys = Object.keys(await jobs.snapshot());
36
+ expect(stateKeys.length).toBe(2);
37
+
38
+ // Restore State 1
39
+ await snapshots.restore(s1.snapshotId);
40
+
41
+ // Verify restored
42
+ stateKeys = Object.keys(await jobs.snapshot());
43
+ expect(stateKeys.length).toBe(1); // bob's job is gone
44
+
45
+ // Branch from State 1
46
+ const b1 = await snapshots.branch(s1.snapshotId, 'experiment-a');
47
+ expect(b1.name).toBe('experiment-a');
48
+ });
49
+
50
+ it('should handle partial restore failures gracefully', async () => {
51
+ // Enqueue some initial jobs
52
+ await jobs.enqueue('payment', { to: 'alice' });
53
+ const base = await snapshots.create('base');
54
+
55
+ // Register a malicious participant that fails on restore
56
+ const failingParticipant = {
57
+ snapshot: async () => ({ bad: 'state' }),
58
+ restore: async () => { throw new Error('Simulated restore failure'); },
59
+ reload: async () => {}
60
+ };
61
+ snapshots.register('failing', failingParticipant);
62
+ const badSnapshot = await snapshots.create('bad');
63
+
64
+ // Restore should throw and mention the participant
65
+ await expect(snapshots.restore(badSnapshot.snapshotId)).rejects.toThrow(/Failed to restore participant 'failing'/);
66
+ });
67
+
68
+ it('should fail if participant is missing on restore', async () => {
69
+ await jobs.enqueue('payment', { to: 'alice' });
70
+ const base = await snapshots.create('base');
71
+
72
+ // Unregister a participant by creating a new snapshots instance
73
+ const newSnapshots = SnapshotToolkit.open({ backend: 'filesystem', dir: testDir });
74
+ // Didn't register 'jobs' this time
75
+
76
+ await expect(newSnapshots.restore(base.snapshotId)).rejects.toThrow(/not registered/);
77
+ });
78
+
79
+ it('should generate deterministic diffs and valid filesystem manifests', async () => {
80
+ await jobs.enqueue('payment', { to: 'alice' });
81
+ const s1 = await snapshots.create('s1');
82
+
83
+ await jobs.enqueue('payment', { to: 'bob' });
84
+ const s2 = await snapshots.create('s2');
85
+
86
+ const diff = await snapshots.diff(s1.snapshotId, s2.snapshotId);
87
+ expect(diff).toHaveProperty('jobs');
88
+
89
+ // Assert filesystem manifest created properly
90
+ const manifestPath = path.join(testDir, s1.snapshotId, 'manifest.json');
91
+ expect(await fs.pathExists(manifestPath)).toBe(true);
92
+
93
+ const manifest = await fs.readJson(manifestPath);
94
+ expect(manifest.schema).toBe('hardkas.snapshot.v1');
95
+ expect(manifest.backend).toBe('filesystem');
96
+ expect(manifest.participants).toContain('jobs');
97
+ expect(manifest.stateHashes).toHaveProperty('jobs');
98
+ });
99
+ });