@arkade-os/sdk 0.4.17 → 0.4.19
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.
- package/README.md +16 -6
- package/dist/cjs/contracts/arkcontract.js +0 -2
- package/dist/cjs/contracts/contractManager.js +111 -215
- package/dist/cjs/contracts/contractWatcher.js +86 -115
- package/dist/cjs/providers/ark.js +36 -33
- package/dist/cjs/repositories/indexedDB/manager.js +6 -3
- package/dist/cjs/repositories/indexedDB/schema.js +47 -2
- package/dist/cjs/repositories/indexedDB/walletRepository.js +21 -2
- package/dist/cjs/repositories/realm/contractRepository.js +0 -4
- package/dist/cjs/repositories/realm/index.js +3 -1
- package/dist/cjs/repositories/realm/schemas.js +50 -1
- package/dist/cjs/repositories/realm/walletRepository.js +8 -4
- package/dist/cjs/repositories/scriptFromAddress.js +16 -0
- package/dist/cjs/repositories/sqlite/contractRepository.js +2 -6
- package/dist/cjs/repositories/sqlite/walletRepository.js +121 -33
- package/dist/cjs/utils/syncCursors.js +48 -56
- package/dist/cjs/wallet/expo/background.js +0 -13
- package/dist/cjs/wallet/expo/wallet.js +1 -6
- package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +16 -7
- package/dist/cjs/wallet/serviceWorker/wallet.js +19 -0
- package/dist/cjs/wallet/utils.js +41 -10
- package/dist/cjs/wallet/vtxo-manager.js +222 -40
- package/dist/cjs/wallet/wallet.js +149 -211
- package/dist/cjs/worker/expo/processors/contractPollProcessor.js +9 -13
- package/dist/cjs/worker/expo/taskRunner.js +2 -11
- package/dist/esm/contracts/arkcontract.js +0 -2
- package/dist/esm/contracts/contractManager.js +113 -217
- package/dist/esm/contracts/contractWatcher.js +86 -115
- package/dist/esm/providers/ark.js +36 -33
- package/dist/esm/repositories/indexedDB/manager.js +6 -3
- package/dist/esm/repositories/indexedDB/schema.js +46 -2
- package/dist/esm/repositories/indexedDB/walletRepository.js +21 -2
- package/dist/esm/repositories/realm/contractRepository.js +0 -4
- package/dist/esm/repositories/realm/index.js +1 -1
- package/dist/esm/repositories/realm/schemas.js +48 -0
- package/dist/esm/repositories/realm/walletRepository.js +8 -4
- package/dist/esm/repositories/scriptFromAddress.js +13 -0
- package/dist/esm/repositories/sqlite/contractRepository.js +2 -6
- package/dist/esm/repositories/sqlite/walletRepository.js +121 -33
- package/dist/esm/utils/syncCursors.js +47 -53
- package/dist/esm/wallet/expo/background.js +0 -13
- package/dist/esm/wallet/expo/wallet.js +2 -7
- package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +17 -8
- package/dist/esm/wallet/serviceWorker/wallet.js +19 -0
- package/dist/esm/wallet/utils.js +41 -9
- package/dist/esm/wallet/vtxo-manager.js +222 -40
- package/dist/esm/wallet/wallet.js +152 -214
- package/dist/esm/worker/expo/processors/contractPollProcessor.js +9 -13
- package/dist/esm/worker/expo/taskRunner.js +3 -12
- package/dist/types/contracts/arkcontract.d.ts +0 -2
- package/dist/types/contracts/contractManager.d.ts +38 -9
- package/dist/types/contracts/contractWatcher.d.ts +22 -21
- package/dist/types/contracts/types.d.ts +0 -7
- package/dist/types/repositories/indexedDB/manager.d.ts +5 -2
- package/dist/types/repositories/indexedDB/schema.d.ts +3 -2
- package/dist/types/repositories/realm/index.d.ts +1 -1
- package/dist/types/repositories/realm/schemas.d.ts +41 -0
- package/dist/types/repositories/scriptFromAddress.d.ts +9 -0
- package/dist/types/repositories/serialization.d.ts +1 -1
- package/dist/types/repositories/sqlite/walletRepository.d.ts +22 -0
- package/dist/types/repositories/walletRepository.d.ts +10 -2
- package/dist/types/utils/syncCursors.d.ts +25 -23
- package/dist/types/wallet/index.d.ts +1 -1
- package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +15 -3
- package/dist/types/wallet/utils.d.ts +20 -4
- package/dist/types/wallet/vtxo-manager.d.ts +29 -6
- package/dist/types/wallet/wallet.d.ts +8 -17
- package/dist/types/worker/expo/processors/contractPollProcessor.d.ts +9 -4
- package/dist/types/worker/expo/taskRunner.d.ts +6 -3
- package/package.json +1 -1
|
@@ -68,6 +68,12 @@ class ReadonlyWallet {
|
|
|
68
68
|
this.walletRepository = walletRepository;
|
|
69
69
|
this.contractRepository = contractRepository;
|
|
70
70
|
this.delegatorProvider = delegatorProvider;
|
|
71
|
+
// Outpoints ("txid:vout") committed to an in-flight settle/send. Filtered
|
|
72
|
+
// from getVtxos() so concurrent callers (UI, VtxoManager auto-renewal,
|
|
73
|
+
// another send/settle racing the _txLock) can't reselect coins that are
|
|
74
|
+
// already on their way out. The set is in-memory only: a process crash
|
|
75
|
+
// clears it, and a stale entry only hides a VTXO (never spends one).
|
|
76
|
+
this._pendingSpendOutpoints = new Set();
|
|
71
77
|
// Guard: detect identity/server network mismatch for descriptor-based identities.
|
|
72
78
|
// This duplicates the check in setupWalletConfig() so that subclasses
|
|
73
79
|
// bypassing the factory still get the safety net.
|
|
@@ -304,8 +310,13 @@ class ReadonlyWallet {
|
|
|
304
310
|
async getVtxos(filter) {
|
|
305
311
|
const f = filter ?? { withRecoverable: true, withUnrolled: false };
|
|
306
312
|
const contractManager = await this.getContractManager();
|
|
307
|
-
const
|
|
308
|
-
return
|
|
313
|
+
const vtxos = await contractManager.getContractsWithVtxos();
|
|
314
|
+
return vtxos
|
|
315
|
+
.flatMap((_) => _.vtxos)
|
|
316
|
+
.filter((vtxo) => {
|
|
317
|
+
if (this._pendingSpendOutpoints.has(`${vtxo.txid}:${vtxo.vout}`)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
309
320
|
if ((0, _1.isSpendable)(vtxo)) {
|
|
310
321
|
if (!f.withRecoverable &&
|
|
311
322
|
((0, _1.isRecoverable)(vtxo) || (0, _1.isExpired)(vtxo))) {
|
|
@@ -314,17 +325,15 @@ class ReadonlyWallet {
|
|
|
314
325
|
return true;
|
|
315
326
|
}
|
|
316
327
|
return !!(f.withUnrolled && vtxo.isUnrolled);
|
|
317
|
-
})
|
|
328
|
+
});
|
|
318
329
|
}
|
|
319
330
|
/**
|
|
320
331
|
* Return wallet transaction history derived from Arkade state and boarding transactions.
|
|
321
332
|
*/
|
|
322
333
|
async getTransactionHistory() {
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
const allVtxos =
|
|
326
|
-
? await this.walletRepository.getVtxos(address)
|
|
327
|
-
: fetchedExtended;
|
|
334
|
+
const contractManager = await this.getContractManager();
|
|
335
|
+
const response = await contractManager.getContractsWithVtxos();
|
|
336
|
+
const allVtxos = response.flatMap((_) => _.vtxos);
|
|
328
337
|
const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
|
|
329
338
|
const getTxCreatedAt = (txid) => this.indexerProvider
|
|
330
339
|
.getVtxos({ outpoints: [{ txid, vout: 0 }] })
|
|
@@ -332,166 +341,11 @@ class ReadonlyWallet {
|
|
|
332
341
|
return (0, transactionHistory_1.buildTransactionHistory)(allVtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
|
|
333
342
|
}
|
|
334
343
|
/**
|
|
335
|
-
*
|
|
336
|
-
* cursor, or do a full bootstrap when no cursor exists. Upserts
|
|
337
|
-
* the result into the cache and advances the sync cursors.
|
|
338
|
-
*
|
|
339
|
-
* Concurrent calls are deduplicated: if a sync is already in flight,
|
|
340
|
-
* subsequent callers receive the same promise instead of triggering
|
|
341
|
-
* a second network round-trip.
|
|
342
|
-
*/
|
|
343
|
-
syncVtxos() {
|
|
344
|
-
if (this._syncVtxosInflight)
|
|
345
|
-
return this._syncVtxosInflight;
|
|
346
|
-
const p = this.doSyncVtxos().finally(() => {
|
|
347
|
-
this._syncVtxosInflight = undefined;
|
|
348
|
-
});
|
|
349
|
-
this._syncVtxosInflight = p;
|
|
350
|
-
return p;
|
|
351
|
-
}
|
|
352
|
-
async doSyncVtxos() {
|
|
353
|
-
const address = await this.getAddress();
|
|
354
|
-
// Batch cursor read with script map to avoid extra async hops
|
|
355
|
-
// before the fetch (background operations may run between hops).
|
|
356
|
-
const [scriptMap, cursors] = await Promise.all([
|
|
357
|
-
this.getScriptMap(),
|
|
358
|
-
(0, syncCursors_1.getAllSyncCursors)(this.walletRepository),
|
|
359
|
-
]);
|
|
360
|
-
const allScripts = [...scriptMap.keys()];
|
|
361
|
-
// Partition scripts into bootstrap (no cursor) and delta (has cursor).
|
|
362
|
-
const bootstrapScripts = [];
|
|
363
|
-
const deltaScripts = [];
|
|
364
|
-
for (const s of allScripts) {
|
|
365
|
-
if (cursors[s] === undefined) {
|
|
366
|
-
bootstrapScripts.push(s);
|
|
367
|
-
}
|
|
368
|
-
else {
|
|
369
|
-
deltaScripts.push(s);
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
const requestStartedAt = Date.now();
|
|
373
|
-
const allVtxos = [];
|
|
374
|
-
const extendWithScript = (vtxo) => {
|
|
375
|
-
const vtxoScript = vtxo.script
|
|
376
|
-
? scriptMap.get(vtxo.script)
|
|
377
|
-
: undefined;
|
|
378
|
-
if (!vtxoScript)
|
|
379
|
-
return undefined;
|
|
380
|
-
return {
|
|
381
|
-
...vtxo,
|
|
382
|
-
forfeitTapLeafScript: vtxoScript.forfeit(),
|
|
383
|
-
intentTapLeafScript: vtxoScript.forfeit(),
|
|
384
|
-
tapTree: vtxoScript.encode(),
|
|
385
|
-
};
|
|
386
|
-
};
|
|
387
|
-
// Full fetch for scripts with no cursor.
|
|
388
|
-
if (bootstrapScripts.length > 0) {
|
|
389
|
-
const response = await this.indexerProvider.getVtxos({
|
|
390
|
-
scripts: bootstrapScripts,
|
|
391
|
-
});
|
|
392
|
-
allVtxos.push(...response.vtxos);
|
|
393
|
-
}
|
|
394
|
-
// Delta fetch for scripts with an existing cursor.
|
|
395
|
-
let hasDelta = false;
|
|
396
|
-
if (deltaScripts.length > 0) {
|
|
397
|
-
const minCursor = Math.min(...deltaScripts.map((s) => cursors[s]));
|
|
398
|
-
const window = (0, syncCursors_1.computeSyncWindow)(minCursor);
|
|
399
|
-
if (window) {
|
|
400
|
-
hasDelta = true;
|
|
401
|
-
const response = await this.indexerProvider.getVtxos({
|
|
402
|
-
scripts: deltaScripts,
|
|
403
|
-
after: window.after,
|
|
404
|
-
});
|
|
405
|
-
allVtxos.push(...response.vtxos);
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
// Extend every fetched virtual output and upsert into the cache.
|
|
409
|
-
const fetchedExtended = [];
|
|
410
|
-
for (const vtxo of allVtxos) {
|
|
411
|
-
const extended = extendWithScript(vtxo);
|
|
412
|
-
if (extended)
|
|
413
|
-
fetchedExtended.push(extended);
|
|
414
|
-
}
|
|
415
|
-
// Save virtual outputs first, then advance cursors only on success.
|
|
416
|
-
const cutoff = (0, syncCursors_1.cursorCutoff)(requestStartedAt);
|
|
417
|
-
await this.walletRepository.saveVtxos(address, fetchedExtended);
|
|
418
|
-
await (0, syncCursors_1.advanceSyncCursors)(this.walletRepository, Object.fromEntries(allScripts.map((s) => [s, cutoff])));
|
|
419
|
-
// Delta-sync reconciliation: full re-fetch for delta scripts.
|
|
420
|
-
//
|
|
421
|
-
// The delta fetch (above) only returns virtual outputs changed after the
|
|
422
|
-
// cursor, so it can miss preconfirmed virtual outputs that were consumed
|
|
423
|
-
// by a round between syncs. Rather than layering targeted
|
|
424
|
-
// queries (pendingOnly, spendableOnly) with pagination guards
|
|
425
|
-
// and set algebra, we perform a single unfiltered re-fetch for
|
|
426
|
-
// delta scripts. This is slightly more data over the wire but
|
|
427
|
-
// gives us complete, authoritative state in one call and keeps
|
|
428
|
-
// the reconciliation logic simple.
|
|
429
|
-
//
|
|
430
|
-
// Any cached non-spent virtual output that is absent from the full
|
|
431
|
-
// result set is marked spent; any virtual output whose state changed
|
|
432
|
-
// (e.g. preconfirmed → settled) is updated in place.
|
|
433
|
-
if (hasDelta) {
|
|
434
|
-
const { vtxos: fullVtxos, page: fullPage } = await this.indexerProvider.getVtxos({
|
|
435
|
-
scripts: deltaScripts,
|
|
436
|
-
});
|
|
437
|
-
// Reconciliation is best-effort: if the response is
|
|
438
|
-
// paginated we don't have a complete picture, so we skip
|
|
439
|
-
// rather than act on partial data. Wallets with enough
|
|
440
|
-
// virtual outputs to exceed a single page rely solely on the
|
|
441
|
-
// cursor-based delta mechanism for state updates.
|
|
442
|
-
const fullSetComplete = !fullPage || fullPage.total <= 1;
|
|
443
|
-
if (fullSetComplete) {
|
|
444
|
-
const fullOutpoints = new Map(fullVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
|
|
445
|
-
const deltaScriptSet = new Set(deltaScripts);
|
|
446
|
-
const cachedVtxos = await this.walletRepository.getVtxos(address);
|
|
447
|
-
const reconciledExtended = [];
|
|
448
|
-
for (const cached of cachedVtxos) {
|
|
449
|
-
if (!cached.script ||
|
|
450
|
-
!deltaScriptSet.has(cached.script) ||
|
|
451
|
-
cached.isSpent) {
|
|
452
|
-
continue;
|
|
453
|
-
}
|
|
454
|
-
const outpoint = `${cached.txid}:${cached.vout}`;
|
|
455
|
-
const fresh = fullOutpoints.get(outpoint);
|
|
456
|
-
if (!fresh) {
|
|
457
|
-
// Server no longer knows about this virtual output —
|
|
458
|
-
// it was spent between syncs.
|
|
459
|
-
reconciledExtended.push({
|
|
460
|
-
...cached,
|
|
461
|
-
isSpent: true,
|
|
462
|
-
});
|
|
463
|
-
continue;
|
|
464
|
-
}
|
|
465
|
-
const extended = extendWithScript(fresh);
|
|
466
|
-
if (extended &&
|
|
467
|
-
extended.virtualStatus.state !==
|
|
468
|
-
cached.virtualStatus.state) {
|
|
469
|
-
// State transitioned (e.g. preconfirmed →
|
|
470
|
-
// settled) — update the cached entry.
|
|
471
|
-
reconciledExtended.push(extended);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (reconciledExtended.length > 0) {
|
|
475
|
-
console.warn(`[ark-sdk] delta sync: reconciled ${reconciledExtended.length} stale VTXO(s) via full re-fetch`);
|
|
476
|
-
await this.walletRepository.saveVtxos(address, reconciledExtended);
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
else {
|
|
480
|
-
console.warn("[ark-sdk] delta sync: skipping reconciliation — full re-fetch was paginated");
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return {
|
|
484
|
-
isDelta: hasDelta || bootstrapScripts.length === 0,
|
|
485
|
-
fetchedExtended,
|
|
486
|
-
address,
|
|
487
|
-
};
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* Clear all virtual output sync cursors, forcing a full re-bootstrap on next sync.
|
|
344
|
+
* Clear the global VTXO sync cursor, forcing a full re-bootstrap on next sync.
|
|
491
345
|
* Useful for recovery after indexer reprocessing or debugging.
|
|
492
346
|
*/
|
|
493
|
-
async
|
|
494
|
-
await (0, syncCursors_1.
|
|
347
|
+
async clearSyncCursor() {
|
|
348
|
+
await (0, syncCursors_1.clearSyncCursor)(this.walletRepository);
|
|
495
349
|
}
|
|
496
350
|
/**
|
|
497
351
|
* Build a transaction history view for the wallet's boarding address.
|
|
@@ -534,6 +388,7 @@ class ReadonlyWallet {
|
|
|
534
388
|
createdAt: tx.status.confirmed
|
|
535
389
|
? new Date(tx.status.block_time * 1000)
|
|
536
390
|
: new Date(0),
|
|
391
|
+
script: base_1.hex.encode(this.boardingTapscript.pkScript),
|
|
537
392
|
});
|
|
538
393
|
}
|
|
539
394
|
}
|
|
@@ -623,22 +478,41 @@ class ReadonlyWallet {
|
|
|
623
478
|
await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
|
|
624
479
|
};
|
|
625
480
|
// Handle subscription updates asynchronously without blocking.
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
//
|
|
481
|
+
// Subscription covers all wallet scripts (default + delegate) plus
|
|
482
|
+
// any additional registered contracts. Virtual outputs carry a
|
|
483
|
+
// `script` field from the indexer which the contract manager
|
|
484
|
+
// resolves to the owning contract so the extension uses the
|
|
485
|
+
// correct forfeit/intent tapscripts.
|
|
631
486
|
(async () => {
|
|
632
487
|
try {
|
|
488
|
+
const cm = await this.getContractManager();
|
|
633
489
|
for await (const update of subscription) {
|
|
634
|
-
if (update.newVtxos?.length
|
|
635
|
-
update.spentVtxos?.length
|
|
490
|
+
if (update.newVtxos?.length === 0 &&
|
|
491
|
+
update.spentVtxos?.length === 0) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
// Isolate per-update annotation failures (e.g. a VTXO
|
|
495
|
+
// arriving for a contract we haven't registered yet).
|
|
496
|
+
// Without this a single bad update would kill the
|
|
497
|
+
// for-await loop and silently drop every subsequent
|
|
498
|
+
// subscription event for the session.
|
|
499
|
+
try {
|
|
500
|
+
// Default to `[]` so a one-sided update (e.g.
|
|
501
|
+
// only `newVtxos`) doesn't pass `undefined` into
|
|
502
|
+
// annotateVtxos and throw on `.length`.
|
|
503
|
+
const [newVtxos, spentVtxos] = await Promise.all([
|
|
504
|
+
cm.annotateVtxos(update.newVtxos ?? []),
|
|
505
|
+
cm.annotateVtxos(update.spentVtxos ?? []),
|
|
506
|
+
]);
|
|
636
507
|
eventCallback({
|
|
637
508
|
type: "vtxo",
|
|
638
|
-
newVtxos
|
|
639
|
-
spentVtxos
|
|
509
|
+
newVtxos,
|
|
510
|
+
spentVtxos,
|
|
640
511
|
});
|
|
641
512
|
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
console.warn("Dropping subscription update after annotation failed; next sync will reconcile:", error);
|
|
515
|
+
}
|
|
642
516
|
}
|
|
643
517
|
}
|
|
644
518
|
catch (error) {
|
|
@@ -895,6 +769,20 @@ exports.ReadonlyWallet = ReadonlyWallet;
|
|
|
895
769
|
* ```
|
|
896
770
|
*/
|
|
897
771
|
class Wallet extends ReadonlyWallet {
|
|
772
|
+
_addPendingSpends(inputs) {
|
|
773
|
+
for (const input of inputs) {
|
|
774
|
+
if ("virtualStatus" in input) {
|
|
775
|
+
this._pendingSpendOutpoints.add(`${input.txid}:${input.vout}`);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
_removePendingSpends(inputs) {
|
|
780
|
+
for (const input of inputs) {
|
|
781
|
+
if ("virtualStatus" in input) {
|
|
782
|
+
this._pendingSpendOutpoints.delete(`${input.txid}:${input.vout}`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
898
786
|
_withTxLock(fn) {
|
|
899
787
|
let release;
|
|
900
788
|
const lock = new Promise((r) => (release = r));
|
|
@@ -909,11 +797,10 @@ class Wallet extends ReadonlyWallet {
|
|
|
909
797
|
}
|
|
910
798
|
});
|
|
911
799
|
}
|
|
912
|
-
constructor(identity, network,
|
|
800
|
+
constructor(identity, network, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, dustAmount, walletRepository, contractRepository,
|
|
913
801
|
/** @deprecated Use settlementConfig */
|
|
914
802
|
renewalConfig, delegatorProvider, watcherConfig, settlementConfig) {
|
|
915
803
|
super(identity, network, onchainProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, dustAmount, walletRepository, contractRepository, delegatorProvider, watcherConfig);
|
|
916
|
-
this.networkName = networkName;
|
|
917
804
|
this.arkProvider = arkProvider;
|
|
918
805
|
this.serverUnrollScript = serverUnrollScript;
|
|
919
806
|
this.forfeitOutputScript = forfeitOutputScript;
|
|
@@ -1033,7 +920,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1033
920
|
const forfeitPubkey = base_1.hex.decode(setup.info.forfeitPubkey).slice(1);
|
|
1034
921
|
const forfeitAddress = (0, btc_signer_1.Address)(setup.network).decode(setup.info.forfeitAddress);
|
|
1035
922
|
const forfeitOutputScript = btc_signer_1.OutScript.encode(forfeitAddress);
|
|
1036
|
-
const wallet = new Wallet(config.identity, setup.network, setup.
|
|
923
|
+
const wallet = new Wallet(config.identity, setup.network, setup.onchainProvider, setup.arkProvider, setup.indexerProvider, setup.serverPubKey, setup.offchainTapscript, setup.boardingTapscript, serverUnrollScript, forfeitOutputScript, forfeitPubkey, setup.dustAmount, setup.walletRepository, setup.contractRepository, config.renewalConfig, config.delegatorProvider, config.watcherConfig, config.settlementConfig);
|
|
1037
924
|
await wallet.getVtxoManager();
|
|
1038
925
|
return wallet;
|
|
1039
926
|
}
|
|
@@ -1111,9 +998,15 @@ class Wallet extends ReadonlyWallet {
|
|
|
1111
998
|
amount: BigInt(selected.changeAmount),
|
|
1112
999
|
});
|
|
1113
1000
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1001
|
+
this._addPendingSpends(selected.inputs);
|
|
1002
|
+
try {
|
|
1003
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selected.inputs, outputs);
|
|
1004
|
+
await this.updateDbAfterOffchainTx(selected.inputs, arkTxid, signedCheckpointTxs, params.amount, selected.changeAmount, selected.changeAmount > 0n ? outputs.length - 1 : 0);
|
|
1005
|
+
return arkTxid;
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
this._removePendingSpends(selected.inputs);
|
|
1009
|
+
}
|
|
1117
1010
|
});
|
|
1118
1011
|
}
|
|
1119
1012
|
return this.send({
|
|
@@ -1159,7 +1052,8 @@ class Wallet extends ReadonlyWallet {
|
|
|
1159
1052
|
const tip = await this.onchainProvider.getChainTip();
|
|
1160
1053
|
chainTipHeight = tip.height;
|
|
1161
1054
|
}
|
|
1162
|
-
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) =>
|
|
1055
|
+
const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => utxo.status.confirmed &&
|
|
1056
|
+
!(0, arkTransaction_1.hasBoardingTxExpired)(utxo, boardingTimelock, chainTipHeight));
|
|
1163
1057
|
const filteredBoardingUtxos = [];
|
|
1164
1058
|
for (const utxo of boardingUtxos) {
|
|
1165
1059
|
const inputFee = estimator.evalOnchainInput({
|
|
@@ -1294,11 +1188,29 @@ class Wallet extends ReadonlyWallet {
|
|
|
1294
1188
|
...params.inputs.map((input) => `${input.txid}:${input.vout}`),
|
|
1295
1189
|
];
|
|
1296
1190
|
const abortController = new AbortController();
|
|
1191
|
+
let stream;
|
|
1192
|
+
// Optimistically hide these inputs from concurrent getVtxos() callers
|
|
1193
|
+
// while the settlement is in flight. Set before safeRegisterIntent so
|
|
1194
|
+
// there's no window between intent registration and coin-visibility.
|
|
1195
|
+
this._addPendingSpends(params.inputs);
|
|
1297
1196
|
try {
|
|
1298
|
-
|
|
1299
|
-
|
|
1197
|
+
stream = this.arkProvider.getEventStream(abortController.signal, topics);
|
|
1198
|
+
// Prime the iterator so the provider opens the SSE subscription
|
|
1199
|
+
// before safeRegisterIntent can trigger server-side batch events.
|
|
1200
|
+
const firstNext = stream.next();
|
|
1201
|
+
// If settle exits before Batch.join consumes the primed result,
|
|
1202
|
+
// keep the orphaned promise from surfacing as an unhandled rejection.
|
|
1203
|
+
void firstNext.catch(() => { });
|
|
1204
|
+
const primedStream = (async function* () {
|
|
1205
|
+
const first = await firstNext;
|
|
1206
|
+
if (!first.done) {
|
|
1207
|
+
yield first.value;
|
|
1208
|
+
}
|
|
1209
|
+
yield* stream;
|
|
1210
|
+
})();
|
|
1211
|
+
const intentId = await this.safeRegisterIntent(intent, params.inputs);
|
|
1300
1212
|
const handler = this.createBatchHandler(intentId, params.inputs, recipients, session);
|
|
1301
|
-
const commitmentTxid = await batch_1.Batch.join(
|
|
1213
|
+
const commitmentTxid = await batch_1.Batch.join(primedStream, handler, {
|
|
1302
1214
|
abortController,
|
|
1303
1215
|
skipVtxoTreeSigning: !hasOffchainOutputs,
|
|
1304
1216
|
eventCallback: eventCallback
|
|
@@ -1309,28 +1221,41 @@ class Wallet extends ReadonlyWallet {
|
|
|
1309
1221
|
return commitmentTxid;
|
|
1310
1222
|
}
|
|
1311
1223
|
catch (error) {
|
|
1312
|
-
// delete the intent to not be stuck in the queue
|
|
1313
|
-
|
|
1224
|
+
// delete the intent to not be stuck in the queue. If deletion fails
|
|
1225
|
+
// the intent stays on the server and the next settle will hit
|
|
1226
|
+
// "duplicated input" in safeRegisterIntent — surface the failure
|
|
1227
|
+
// rather than silently swallowing it.
|
|
1228
|
+
const inputIds = params.inputs
|
|
1229
|
+
.map((i) => `${i.txid}:${i.vout}`)
|
|
1230
|
+
.join(",");
|
|
1231
|
+
await this.arkProvider.deleteIntent(deleteIntent).catch((e) => {
|
|
1232
|
+
console.warn(`Failed to delete intent after settle failure for inputs [${inputIds}]; intent may linger on server and cause 'duplicated input' on next settle`, e);
|
|
1233
|
+
});
|
|
1314
1234
|
throw error;
|
|
1315
1235
|
}
|
|
1316
1236
|
finally {
|
|
1317
|
-
//
|
|
1237
|
+
// Clear state first so a synchronous handler firing from abort()
|
|
1238
|
+
// never observes a stale pending-spend set.
|
|
1239
|
+
this._removePendingSpends(params.inputs);
|
|
1240
|
+
// close the stream — abort() fires the in-body handler if the
|
|
1241
|
+
// generator has started iterating; return() also releases the
|
|
1242
|
+
// eager resource if the body is still suspended or never ran
|
|
1243
|
+
// (e.g. safeRegisterIntent threw before Batch.join was called).
|
|
1318
1244
|
abortController.abort();
|
|
1245
|
+
await stream?.return?.().catch(() => { });
|
|
1319
1246
|
}
|
|
1320
1247
|
}
|
|
1321
1248
|
async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
|
|
1322
1249
|
// the signed forfeits transactions to submit
|
|
1323
1250
|
const signedForfeits = [];
|
|
1324
|
-
const
|
|
1251
|
+
const isVtxo = (input) => "virtualStatus" in input;
|
|
1325
1252
|
let settlementPsbt = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(event.commitmentTx));
|
|
1326
1253
|
let hasBoardingUtxos = false;
|
|
1327
1254
|
let connectorIndex = 0;
|
|
1328
1255
|
const connectorsLeaves = connectorsGraph?.leaves() || [];
|
|
1329
1256
|
for (const input of inputs) {
|
|
1330
|
-
// check if the input is an offchain "virtual" coin
|
|
1331
|
-
const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
|
|
1332
1257
|
// boarding input, we need to sign the settlement tx
|
|
1333
|
-
if (!
|
|
1258
|
+
if (!isVtxo(input)) {
|
|
1334
1259
|
for (let i = 0; i < settlementPsbt.inputsLength; i++) {
|
|
1335
1260
|
const settlementInput = settlementPsbt.getInput(i);
|
|
1336
1261
|
if (!settlementInput.txid ||
|
|
@@ -1354,7 +1279,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1354
1279
|
}
|
|
1355
1280
|
continue;
|
|
1356
1281
|
}
|
|
1357
|
-
if ((0, _1.isRecoverable)(
|
|
1282
|
+
if ((0, _1.isRecoverable)(input) || (0, _1.isSubdust)(input, this.dustAmount)) {
|
|
1358
1283
|
// recoverable or subdust coin, we don't need to create a forfeit tx
|
|
1359
1284
|
continue;
|
|
1360
1285
|
}
|
|
@@ -1381,7 +1306,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1381
1306
|
txid: input.txid,
|
|
1382
1307
|
index: input.vout,
|
|
1383
1308
|
witnessUtxo: {
|
|
1384
|
-
amount: BigInt(
|
|
1309
|
+
amount: BigInt(input.value),
|
|
1385
1310
|
script: base_2.VtxoScript.decode(input.tapTree).pkScript,
|
|
1386
1311
|
},
|
|
1387
1312
|
sighashType: btc_signer_1.SigHash.DEFAULT,
|
|
@@ -1500,7 +1425,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1500
1425
|
},
|
|
1501
1426
|
};
|
|
1502
1427
|
}
|
|
1503
|
-
async safeRegisterIntent(intent) {
|
|
1428
|
+
async safeRegisterIntent(intent, inputs) {
|
|
1504
1429
|
try {
|
|
1505
1430
|
return await this.arkProvider.registerIntent(intent);
|
|
1506
1431
|
}
|
|
@@ -1509,11 +1434,13 @@ class Wallet extends ReadonlyWallet {
|
|
|
1509
1434
|
if (error instanceof errors_1.ArkError &&
|
|
1510
1435
|
error.code === 0 &&
|
|
1511
1436
|
error.message.includes("duplicated input")) {
|
|
1512
|
-
//
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1437
|
+
// Clear any queued intent spending these exact inputs. The
|
|
1438
|
+
// previous implementation signed a proof over getVtxos() only,
|
|
1439
|
+
// which misses boarding UTXOs — the most common trigger for
|
|
1440
|
+
// "duplicated input" on the auto-settle path. Signing the
|
|
1441
|
+
// caller's own inputs keeps the proof surgical and correct
|
|
1442
|
+
// regardless of whether the stuck input is a VTXO or boarding.
|
|
1443
|
+
const deleteIntent = await this.makeDeleteIntentSignature(inputs);
|
|
1517
1444
|
await this.arkProvider.deleteIntent(deleteIntent);
|
|
1518
1445
|
// try again
|
|
1519
1446
|
return this.arkProvider.registerIntent(intent);
|
|
@@ -1581,9 +1508,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1581
1508
|
scripts: allScripts,
|
|
1582
1509
|
});
|
|
1583
1510
|
for (const vtxo of fetchedVtxos) {
|
|
1584
|
-
const vtxoScript = vtxo.script
|
|
1585
|
-
? scriptMap.get(vtxo.script)
|
|
1586
|
-
: undefined;
|
|
1511
|
+
const vtxoScript = scriptMap.get(vtxo.script);
|
|
1587
1512
|
if (!vtxoScript)
|
|
1588
1513
|
continue;
|
|
1589
1514
|
if (vtxo.virtualStatus.state === "swept" ||
|
|
@@ -1810,9 +1735,17 @@ class Wallet extends ReadonlyWallet {
|
|
|
1810
1735
|
outputs.push(extension_1.Extension.create([assetPacket]).txOut());
|
|
1811
1736
|
}
|
|
1812
1737
|
const sentAmount = recipients.reduce((sum, r) => sum + r.amount, 0);
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1738
|
+
// Optimistically hide selected coins from concurrent getVtxos() while
|
|
1739
|
+
// the offchain tx is in flight.
|
|
1740
|
+
this._addPendingSpends(selectedCoins);
|
|
1741
|
+
try {
|
|
1742
|
+
const { arkTxid, signedCheckpointTxs } = await this.buildAndSubmitOffchainTx(selectedCoins, outputs);
|
|
1743
|
+
await this.updateDbAfterOffchainTx(selectedCoins, arkTxid, signedCheckpointTxs, sentAmount, BigInt(changeAmount), changeReceiver ? changeIndex : 0, changeReceiver?.assets);
|
|
1744
|
+
return arkTxid;
|
|
1745
|
+
}
|
|
1746
|
+
finally {
|
|
1747
|
+
this._removePendingSpends(selectedCoins);
|
|
1748
|
+
}
|
|
1816
1749
|
}
|
|
1817
1750
|
/**
|
|
1818
1751
|
* Build an offchain transaction from the given inputs and outputs,
|
|
@@ -1886,8 +1819,9 @@ class Wallet extends ReadonlyWallet {
|
|
|
1886
1819
|
console.warn(`updateDbAfterOffchainTx: inputs length (${inputs.length}) differs from signedCheckpointTxs length (${signedCheckpointTxs.length})`);
|
|
1887
1820
|
}
|
|
1888
1821
|
const safeLength = Math.min(inputs.length, signedCheckpointTxs.length);
|
|
1889
|
-
|
|
1890
|
-
|
|
1822
|
+
const cm = await this.getContractManager();
|
|
1823
|
+
const annotatedInputs = await cm.annotateVtxos(inputs);
|
|
1824
|
+
for (const [inputIndex, vtxo] of annotatedInputs.entries()) {
|
|
1891
1825
|
if (inputIndex < safeLength &&
|
|
1892
1826
|
signedCheckpointTxs[inputIndex]) {
|
|
1893
1827
|
const checkpoint = btc_signer_1.Transaction.fromPSBT(base_1.base64.decode(signedCheckpointTxs[inputIndex]));
|
|
@@ -1947,6 +1881,7 @@ class Wallet extends ReadonlyWallet {
|
|
|
1947
1881
|
confirmed: false,
|
|
1948
1882
|
},
|
|
1949
1883
|
assets: changeAssets,
|
|
1884
|
+
script: base_1.hex.encode(this.offchainTapscript.pkScript),
|
|
1950
1885
|
};
|
|
1951
1886
|
}
|
|
1952
1887
|
await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
|
|
@@ -1977,10 +1912,14 @@ class Wallet extends ReadonlyWallet {
|
|
|
1977
1912
|
const inputArkTxIds = new Set();
|
|
1978
1913
|
const boardingUtxoToRemove = new Set();
|
|
1979
1914
|
const isVtxo = (input) => "virtualStatus" in input;
|
|
1915
|
+
const vtxoInputs = inputs.filter(isVtxo);
|
|
1916
|
+
const cm = await this.getContractManager();
|
|
1917
|
+
const annotatedVtxos = await cm.annotateVtxos(vtxoInputs);
|
|
1918
|
+
const annotatedByKey = new Map(annotatedVtxos.map((v) => [`${v.txid}:${v.vout}`, v]));
|
|
1980
1919
|
for (const input of inputs) {
|
|
1981
1920
|
if (isVtxo(input)) {
|
|
1982
1921
|
// virtual output = mark it settled
|
|
1983
|
-
const vtxo =
|
|
1922
|
+
const vtxo = annotatedByKey.get(`${input.txid}:${input.vout}`);
|
|
1984
1923
|
if (vtxo.arkTxId) {
|
|
1985
1924
|
inputArkTxIds.add(vtxo.arkTxId);
|
|
1986
1925
|
}
|
|
@@ -2067,7 +2006,7 @@ function selectVirtualCoins(coins, targetAmount) {
|
|
|
2067
2006
|
*/
|
|
2068
2007
|
async function waitForIncomingFunds(wallet) {
|
|
2069
2008
|
let stopFunc;
|
|
2070
|
-
|
|
2009
|
+
return new Promise((resolve) => {
|
|
2071
2010
|
wallet
|
|
2072
2011
|
.notifyIncomingFunds((coins) => {
|
|
2073
2012
|
resolve(coins);
|
|
@@ -2078,5 +2017,4 @@ async function waitForIncomingFunds(wallet) {
|
|
|
2078
2017
|
stopFunc = stop;
|
|
2079
2018
|
});
|
|
2080
2019
|
});
|
|
2081
|
-
return promise;
|
|
2082
2020
|
}
|
|
@@ -8,27 +8,24 @@ exports.CONTRACT_POLL_TASK_TYPE = "contract-poll";
|
|
|
8
8
|
*
|
|
9
9
|
* Replicates the polling subset of @see ContractManager.initialize:
|
|
10
10
|
* 1. Load all contracts from the contract repository.
|
|
11
|
-
* 2.
|
|
12
|
-
* 3.
|
|
13
|
-
* 4.
|
|
14
|
-
*
|
|
11
|
+
* 2. Paginated fetch of every VTXO (including spent) from the indexer.
|
|
12
|
+
* 3. Extend each VTXO with tapscript data.
|
|
13
|
+
* 4. Save to the wallet repository.
|
|
14
|
+
*
|
|
15
|
+
* NOTE: the indexer query deliberately omits `spendableOnly`. Every
|
|
16
|
+
* repository implements `saveVtxos` as an upsert with no batch delete,
|
|
17
|
+
* so filtering to spendable-only would leave VTXOs that became spent
|
|
18
|
+
* between polls marked as spendable forever. Fetching the full set lets
|
|
19
|
+
* the upsert overwrite stale records with their latest state.
|
|
15
20
|
*/
|
|
16
21
|
exports.contractPollProcessor = {
|
|
17
22
|
taskType: exports.CONTRACT_POLL_TASK_TYPE,
|
|
18
23
|
async execute(item, deps) {
|
|
19
24
|
const { contractRepository, walletRepository, indexerProvider, extendVtxo, } = deps;
|
|
20
25
|
const contracts = await contractRepository.getContracts();
|
|
21
|
-
const now = Date.now();
|
|
22
26
|
let contractsProcessed = 0;
|
|
23
27
|
let vtxosSaved = 0;
|
|
24
28
|
for (const contract of contracts) {
|
|
25
|
-
// Mark expired active contracts as inactive
|
|
26
|
-
if (contract.state === "active" &&
|
|
27
|
-
contract.expiresAt &&
|
|
28
|
-
contract.expiresAt <= now) {
|
|
29
|
-
contract.state = "inactive";
|
|
30
|
-
await contractRepository.saveContract(contract);
|
|
31
|
-
}
|
|
32
29
|
// Paginated fetch of spendable virtual outputs
|
|
33
30
|
const pageSize = 100;
|
|
34
31
|
let pageIndex = 0;
|
|
@@ -37,7 +34,6 @@ exports.contractPollProcessor = {
|
|
|
37
34
|
while (hasMore) {
|
|
38
35
|
const { vtxos, page } = await indexerProvider.getVtxos({
|
|
39
36
|
scripts: [contract.script],
|
|
40
|
-
spendableOnly: true,
|
|
41
37
|
pageIndex,
|
|
42
38
|
pageSize,
|
|
43
39
|
});
|
|
@@ -66,17 +66,8 @@ async function runTasks(queue, processors, deps) {
|
|
|
66
66
|
* can build deps without depending on Expo.
|
|
67
67
|
*/
|
|
68
68
|
function createTaskDependencies(options) {
|
|
69
|
-
const { walletRepository, contractRepository, indexerProvider, arkProvider, offchainTapscript, } = options;
|
|
70
69
|
return {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
indexerProvider,
|
|
74
|
-
arkProvider,
|
|
75
|
-
extendVtxo: (vtxo, contract) => {
|
|
76
|
-
if (contract) {
|
|
77
|
-
return (0, utils_1.extendVtxoFromContract)(vtxo, contract);
|
|
78
|
-
}
|
|
79
|
-
return (0, utils_1.extendVirtualCoin)({ offchainTapscript }, vtxo);
|
|
80
|
-
},
|
|
70
|
+
...options,
|
|
71
|
+
extendVtxo: (vtxo, contract) => (0, utils_1.extendVirtualCoinForContract)(vtxo, contract),
|
|
81
72
|
};
|
|
82
73
|
}
|
|
@@ -103,7 +103,6 @@ export function contractFromArkContract(encoded, options = {}) {
|
|
|
103
103
|
params,
|
|
104
104
|
state: options.state || "active",
|
|
105
105
|
createdAt: Date.now(),
|
|
106
|
-
expiresAt: options.expiresAt,
|
|
107
106
|
metadata: options.metadata,
|
|
108
107
|
};
|
|
109
108
|
}
|
|
@@ -129,7 +128,6 @@ export function contractFromArkContractWithAddress(encoded, serverPubKey, addres
|
|
|
129
128
|
address: vtxoScript.address(addressPrefix, serverPubKey).encode(),
|
|
130
129
|
state: options.state || "active",
|
|
131
130
|
createdAt: Date.now(),
|
|
132
|
-
expiresAt: options.expiresAt,
|
|
133
131
|
metadata: options.metadata,
|
|
134
132
|
};
|
|
135
133
|
}
|