@arkade-os/sdk 0.4.23 → 0.4.25

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 (60) hide show
  1. package/README.md +21 -1
  2. package/dist/cjs/contracts/contractManager.js +66 -5
  3. package/dist/cjs/contracts/contractWatcher.js +9 -3
  4. package/dist/cjs/contracts/handlers/default.js +3 -2
  5. package/dist/cjs/contracts/handlers/delegate.js +3 -2
  6. package/dist/cjs/contracts/handlers/helpers.js +2 -58
  7. package/dist/cjs/contracts/handlers/vhtlc.js +7 -6
  8. package/dist/cjs/contracts/vtxoOwnership.js +78 -0
  9. package/dist/cjs/index.js +3 -3
  10. package/dist/cjs/repositories/inMemory/walletRepository.js +35 -0
  11. package/dist/cjs/repositories/indexedDB/walletRepository.js +117 -0
  12. package/dist/cjs/repositories/realm/walletRepository.js +28 -0
  13. package/dist/cjs/repositories/sqlite/walletRepository.js +23 -0
  14. package/dist/cjs/script/base.js +12 -47
  15. package/dist/cjs/script/tapscript.js +97 -73
  16. package/dist/cjs/utils/timelock.js +59 -0
  17. package/dist/cjs/utils/unknownFields.js +2 -39
  18. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +71 -10
  19. package/dist/cjs/wallet/serviceWorker/wallet.js +10 -0
  20. package/dist/cjs/wallet/unroll.js +79 -67
  21. package/dist/cjs/wallet/vtxo-manager.js +112 -16
  22. package/dist/cjs/wallet/wallet.js +64 -8
  23. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +7 -2
  24. package/dist/esm/contracts/contractManager.js +66 -5
  25. package/dist/esm/contracts/contractWatcher.js +9 -3
  26. package/dist/esm/contracts/handlers/default.js +2 -1
  27. package/dist/esm/contracts/handlers/delegate.js +2 -1
  28. package/dist/esm/contracts/handlers/helpers.js +1 -22
  29. package/dist/esm/contracts/handlers/vhtlc.js +2 -1
  30. package/dist/esm/contracts/vtxoOwnership.js +69 -0
  31. package/dist/esm/index.js +1 -1
  32. package/dist/esm/repositories/inMemory/walletRepository.js +35 -0
  33. package/dist/esm/repositories/indexedDB/walletRepository.js +117 -0
  34. package/dist/esm/repositories/realm/walletRepository.js +28 -0
  35. package/dist/esm/repositories/sqlite/walletRepository.js +23 -0
  36. package/dist/esm/script/base.js +12 -14
  37. package/dist/esm/script/tapscript.js +97 -40
  38. package/dist/esm/utils/timelock.js +22 -0
  39. package/dist/esm/utils/unknownFields.js +2 -6
  40. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +71 -10
  41. package/dist/esm/wallet/serviceWorker/wallet.js +10 -0
  42. package/dist/esm/wallet/unroll.js +78 -67
  43. package/dist/esm/wallet/vtxo-manager.js +112 -16
  44. package/dist/esm/wallet/wallet.js +62 -6
  45. package/dist/esm/worker/expo/processors/contractPollProcessor.js +7 -2
  46. package/dist/types/contracts/contractManager.d.ts +17 -1
  47. package/dist/types/contracts/handlers/helpers.d.ts +0 -9
  48. package/dist/types/contracts/vtxoOwnership.d.ts +33 -0
  49. package/dist/types/index.d.ts +1 -1
  50. package/dist/types/repositories/inMemory/walletRepository.d.ts +4 -1
  51. package/dist/types/repositories/indexedDB/walletRepository.d.ts +4 -1
  52. package/dist/types/repositories/realm/walletRepository.d.ts +4 -1
  53. package/dist/types/repositories/sqlite/walletRepository.d.ts +4 -1
  54. package/dist/types/repositories/walletRepository.d.ts +21 -0
  55. package/dist/types/script/tapscript.d.ts +4 -0
  56. package/dist/types/utils/timelock.d.ts +9 -0
  57. package/dist/types/wallet/serviceWorker/wallet-message-handler.d.ts +14 -2
  58. package/dist/types/wallet/unroll.d.ts +10 -0
  59. package/dist/types/wallet/vtxo-manager.d.ts +32 -5
  60. package/package.json +1 -1
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Unroll = void 0;
4
+ exports.prepareUnrollTransaction = prepareUnrollTransaction;
4
5
  const base_1 = require("@scure/base");
5
6
  const btc_signer_1 = require("@scure/btc-signer");
6
- const helpers_1 = require("../contracts/handlers/helpers");
7
+ const timelock_1 = require("../utils/timelock");
7
8
  const indexer_1 = require("../providers/indexer");
8
9
  const base_2 = require("../script/base");
9
10
  const txSizeEstimator_1 = require("../utils/txSizeEstimator");
@@ -129,10 +130,12 @@ var Unroll;
129
130
  // finalize Arkade transaction
130
131
  tx.finalize();
131
132
  }
133
+ const pkg = await this.bumper.bumpP2A(tx);
132
134
  return {
133
135
  type: StepType.UNROLL,
134
136
  tx,
135
- do: doUnroll(this.bumper, this.explorer, tx),
137
+ pkg,
138
+ do: doUnroll(this.explorer, pkg),
136
139
  };
137
140
  }
138
141
  /**
@@ -164,79 +167,88 @@ var Unroll;
164
167
  * @returns the txid of the transaction spending the unrolled funds
165
168
  */
166
169
  async function completeUnroll(wallet, vtxoTxids, outputAddress) {
167
- const chainTip = await wallet.onchainProvider.getChainTip();
168
- let vtxos = await wallet.getVtxos({ withUnrolled: true });
169
- vtxos = vtxos.filter((vtxo) => vtxoTxids.includes(vtxo.txid));
170
- if (vtxos.length === 0) {
171
- throw new Error("No vtxos to complete unroll");
172
- }
173
- const inputs = [];
174
- let totalAmount = 0n;
175
- const txWeightEstimator = txSizeEstimator_1.TxWeightEstimator.create();
176
- for (const vtxo of vtxos) {
177
- if (!vtxo.isUnrolled) {
178
- throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`);
179
- }
180
- const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
181
- if (!txStatus.confirmed) {
182
- throw new Error(`tx ${vtxo.txid} is not confirmed`);
183
- }
184
- const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo);
185
- if (!exit) {
186
- throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`);
187
- }
188
- const spendingLeaf = base_2.VtxoScript.decode(vtxo.tapTree).findLeaf(base_1.hex.encode(exit.script));
189
- if (!spendingLeaf) {
190
- throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`);
191
- }
192
- totalAmount += BigInt(vtxo.value);
193
- const sequence = (0, helpers_1.timelockToSequence)(exit.params.timelock);
194
- inputs.push({
195
- txid: vtxo.txid,
196
- index: vtxo.vout,
197
- tapLeafScript: [spendingLeaf],
198
- sequence,
199
- witnessUtxo: {
200
- amount: BigInt(vtxo.value),
201
- script: base_2.VtxoScript.decode(vtxo.tapTree).pkScript,
202
- },
203
- sighashType: btc_signer_1.SigHash.DEFAULT,
204
- });
205
- txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, btc_signer_1.TaprootControlBlock.encode(spendingLeaf[0]).length);
206
- }
207
- const tx = new transaction_1.Transaction({ version: 2 });
208
- for (const input of inputs) {
209
- tx.addInput(input);
210
- }
211
- txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
212
- let feeRate = await wallet.onchainProvider.getFeeRate();
213
- if (!feeRate || feeRate < wallet_1.Wallet.MIN_FEE_RATE) {
214
- feeRate = wallet_1.Wallet.MIN_FEE_RATE;
215
- }
216
- const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
217
- if (feeAmount > totalAmount) {
218
- throw new Error("fee amount is greater than the total amount");
219
- }
220
- const sendAmount = totalAmount - feeAmount;
221
- if (sendAmount < BigInt(utils_1.DUST_AMOUNT)) {
222
- throw new Error("send amount is less than dust amount");
223
- }
224
- tx.addOutputAddress(outputAddress, sendAmount);
225
- const signedTx = await wallet.identity.sign(tx);
226
- signedTx.finalize();
170
+ const signedTx = await prepareUnrollTransaction(wallet, vtxoTxids, outputAddress);
227
171
  await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
228
172
  return signedTx.id;
229
173
  }
230
174
  Unroll.completeUnroll = completeUnroll;
231
175
  })(Unroll || (exports.Unroll = Unroll = {}));
176
+ /**
177
+ * Prepares the transaction that spends the CSV path to complete unrolling a VTXO.
178
+ * @param wallet the wallet owning the VTXO(s)
179
+ * @param vtxoTxIds the txids of the VTXO(s) to complete unroll
180
+ * @param outputAddress the address to send the unrolled funds to
181
+ * @throws if the VTXO(s) are not fully unrolled, if the txids are not found, if the tx is not confirmed, if no exit path is found or not available
182
+ * @returns the transaction spending the unrolled funds
183
+ */
184
+ async function prepareUnrollTransaction(wallet, vtxoTxIds, outputAddress) {
185
+ const chainTip = await wallet.onchainProvider.getChainTip();
186
+ let vtxos = await wallet.getVtxos({ withUnrolled: true });
187
+ vtxos = vtxos.filter((vtxo) => vtxoTxIds.includes(vtxo.txid));
188
+ if (vtxos.length === 0) {
189
+ throw new Error("No vtxos to complete unroll");
190
+ }
191
+ const inputs = [];
192
+ let totalAmount = 0n;
193
+ const txWeightEstimator = txSizeEstimator_1.TxWeightEstimator.create();
194
+ for (const vtxo of vtxos) {
195
+ if (!vtxo.isUnrolled) {
196
+ throw new Error(`Vtxo ${vtxo.txid}:${vtxo.vout} is not fully unrolled, use unroll first`);
197
+ }
198
+ const txStatus = await wallet.onchainProvider.getTxStatus(vtxo.txid);
199
+ if (!txStatus.confirmed) {
200
+ throw new Error(`tx ${vtxo.txid} is not confirmed`);
201
+ }
202
+ const exit = availableExitPath({ height: txStatus.blockHeight, time: txStatus.blockTime }, chainTip, vtxo);
203
+ if (!exit) {
204
+ throw new Error(`no available exit path found for vtxo ${vtxo.txid}:${vtxo.vout}`);
205
+ }
206
+ const spendingLeaf = base_2.VtxoScript.decode(vtxo.tapTree).findLeaf(base_1.hex.encode(exit.script));
207
+ if (!spendingLeaf) {
208
+ throw new Error(`spending leaf not found for vtxo ${vtxo.txid}:${vtxo.vout}`);
209
+ }
210
+ totalAmount += BigInt(vtxo.value);
211
+ const sequence = (0, timelock_1.timelockToSequence)(exit.params.timelock);
212
+ inputs.push({
213
+ txid: vtxo.txid,
214
+ index: vtxo.vout,
215
+ tapLeafScript: [spendingLeaf],
216
+ sequence,
217
+ witnessUtxo: {
218
+ amount: BigInt(vtxo.value),
219
+ script: base_2.VtxoScript.decode(vtxo.tapTree).pkScript,
220
+ },
221
+ sighashType: btc_signer_1.SigHash.DEFAULT,
222
+ });
223
+ txWeightEstimator.addTapscriptInput(64, spendingLeaf[1].length, btc_signer_1.TaprootControlBlock.encode(spendingLeaf[0]).length);
224
+ }
225
+ const tx = new transaction_1.Transaction({ version: 2 });
226
+ for (const input of inputs) {
227
+ tx.addInput(input);
228
+ }
229
+ txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
230
+ let feeRate = await wallet.onchainProvider.getFeeRate();
231
+ if (!feeRate || feeRate < wallet_1.Wallet.MIN_FEE_RATE) {
232
+ feeRate = wallet_1.Wallet.MIN_FEE_RATE;
233
+ }
234
+ const feeAmount = txWeightEstimator.vsize().fee(BigInt(feeRate));
235
+ if (feeAmount > totalAmount) {
236
+ throw new Error("fee amount is greater than the total amount");
237
+ }
238
+ const sendAmount = totalAmount - feeAmount;
239
+ if (sendAmount < BigInt(utils_1.DUST_AMOUNT)) {
240
+ throw new Error("send amount is less than dust amount");
241
+ }
242
+ tx.addOutputAddress(outputAddress, sendAmount, wallet.network);
243
+ const signedTx = await wallet.identity.sign(tx);
244
+ signedTx.finalize();
245
+ return signedTx;
246
+ }
232
247
  function sleep(ms) {
233
248
  return new Promise((resolve) => setTimeout(resolve, ms));
234
249
  }
235
- function doUnroll(bumper, onchainProvider, tx) {
236
- return async () => {
237
- const [parent, child] = await bumper.bumpP2A(tx);
238
- await onchainProvider.broadcastTransaction(parent, child);
239
- };
250
+ function doUnroll(onchainProvider, pkg) {
251
+ return () => onchainProvider.broadcastTransaction(...pkg).then(() => undefined);
240
252
  }
241
253
  function doWait(onchainProvider, txid) {
242
254
  return () => {
@@ -4,6 +4,7 @@ exports.VtxoManager = exports.DEFAULT_SETTLEMENT_CONFIG = exports.DEFAULT_RENEWA
4
4
  exports.isVtxoExpiringSoon = isVtxoExpiringSoon;
5
5
  exports.getExpiringAndRecoverableVtxos = getExpiringAndRecoverableVtxos;
6
6
  const _1 = require(".");
7
+ const errors_1 = require("../providers/errors");
7
8
  const arkTransaction_1 = require("../utils/arkTransaction");
8
9
  const tapscript_1 = require("../script/tapscript");
9
10
  const base_1 = require("@scure/base");
@@ -437,10 +438,22 @@ class VtxoManager {
437
438
  try {
438
439
  // Get all virtual outputs (including recoverable ones)
439
440
  // Use default threshold to bypass settlementConfig gate (manual API should always work)
440
- const vtxos = await this.getExpiringVtxos(this.settlementConfig !== false &&
441
+ const threshold = this.settlementConfig !== false &&
441
442
  this.settlementConfig?.vtxoThreshold !== undefined
442
443
  ? this.settlementConfig.vtxoThreshold * 1000
443
- : exports.DEFAULT_RENEWAL_CONFIG.thresholdMs);
444
+ : exports.DEFAULT_RENEWAL_CONFIG.thresholdMs;
445
+ let vtxos = await this.getExpiringVtxos(threshold);
446
+ if (vtxos.length === 0) {
447
+ throw new Error("No VTXOs available to renew");
448
+ }
449
+ // Pre-flight: validate the chosen inputs against the indexer's
450
+ // authoritative state before submitting. The cursor-derived
451
+ // delta sync filters by `created_at`, so a VTXO created
452
+ // before the cursor and spent recently can sit in the local
453
+ // cache forever; settling against it yields a guaranteed
454
+ // VTXO_ALREADY_SPENT 400. Refreshing the candidates here
455
+ // catches that BEFORE the network round-trip.
456
+ vtxos = await this.revalidateBeforeSettle(vtxos, threshold);
444
457
  if (vtxos.length === 0) {
445
458
  throw new Error("No VTXOs available to renew");
446
459
  }
@@ -689,9 +702,11 @@ class VtxoManager {
689
702
  if (e.message.includes("VTXO_ALREADY_SPENT")) {
690
703
  // Our local VTXO cache is stale vs. the
691
704
  // server's authoritative view. Trigger a
692
- // throttled refresh to reconcile, then skip
693
- // the next cycle will see fresh data.
694
- void this.maybeRefreshAfterVtxoSpent();
705
+ // throttled, targeted refresh on the
706
+ // offending outpoint (if the server told
707
+ // us which one), then skip — the next
708
+ // cycle will see fresh data.
709
+ void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
695
710
  return;
696
711
  }
697
712
  }
@@ -716,13 +731,20 @@ class VtxoManager {
716
731
  /**
717
732
  * VTXO_ALREADY_SPENT means the server's authoritative view of VTXO state
718
733
  * is ahead of ours — cross-instance race, pre-lock snapshot drift, or an
719
- * SSE gap left stale data in the local cache. Silent-swallowing guarantees
720
- * the same error on the next cycle because nothing reconciles the cache,
721
- * so instead we trigger a full refreshVtxos() to advance the global sync
722
- * cursor. Throttled to prevent a buggy indexer from causing a refresh
723
- * storm.
734
+ * SSE gap left stale data in the local cache. Silent-swallowing
735
+ * guarantees the same error on the next cycle because nothing
736
+ * reconciles the cache.
737
+ *
738
+ * The cursor-derived delta sync filters by `created_at`, so a VTXO that
739
+ * was created before the cursor but spent recently can never be
740
+ * reconciled by `refreshVtxos()`. Use `refreshOutpoints` for surgical
741
+ * recovery: query the indexer for the specific stale outpoint and
742
+ * upsert its authoritative state into the wallet repository.
743
+ *
744
+ * Throttled because the same VTXO can fire repeatedly before the
745
+ * upsert observably propagates through the renewal selector.
724
746
  */
725
- maybeRefreshAfterVtxoSpent() {
747
+ maybeRefreshAfterVtxoSpent(spentOutpoint) {
726
748
  if (this.vtxoSpentRefreshPromise) {
727
749
  return this.vtxoSpentRefreshPromise;
728
750
  }
@@ -735,7 +757,13 @@ class VtxoManager {
735
757
  this.vtxoSpentRefreshPromise = (async () => {
736
758
  try {
737
759
  const contractManager = await this.wallet.getContractManager();
738
- await contractManager.refreshVtxos();
760
+ if (spentOutpoint) {
761
+ await contractManager.refreshOutpoints([spentOutpoint]);
762
+ }
763
+ else {
764
+ // No outpoint metadata — fall back to the broader refresh.
765
+ await contractManager.refreshVtxos();
766
+ }
739
767
  }
740
768
  catch (e) {
741
769
  console.error("Error refreshing VTXOs after VTXO_ALREADY_SPENT:", e);
@@ -746,6 +774,66 @@ class VtxoManager {
746
774
  })();
747
775
  return this.vtxoSpentRefreshPromise;
748
776
  }
777
+ /**
778
+ * Extract the offending VTXO outpoint from a `VTXO_ALREADY_SPENT` error,
779
+ * if the server attached one in `metadata.vtxo_outpoint`. Returns
780
+ * `undefined` when the error isn't a parsed ArkError, isn't this code,
781
+ * or doesn't carry the metadata.
782
+ */
783
+ extractSpentOutpoint(error) {
784
+ const ark = (0, errors_1.maybeArkError)(error);
785
+ if (!ark || ark.name !== "VTXO_ALREADY_SPENT")
786
+ return undefined;
787
+ const raw = ark.metadata?.vtxo_outpoint;
788
+ if (typeof raw !== "string")
789
+ return undefined;
790
+ const [txid, voutStr] = raw.split(":");
791
+ if (!txid || !voutStr)
792
+ return undefined;
793
+ const vout = Number(voutStr);
794
+ if (!Number.isInteger(vout) || vout < 0)
795
+ return undefined;
796
+ return { txid, vout };
797
+ }
798
+ /**
799
+ * Reconcile the chosen VTXOs with the indexer's authoritative state
800
+ * before submitting a settle intent. Pulls the canonical record for
801
+ * each candidate outpoint via {@link IContractManager.refreshOutpoints}
802
+ * (which upserts the result into the wallet repository), then
803
+ * re-selects through the standard expiring-vtxo filter so anything
804
+ * the refresh flagged as spent is dropped.
805
+ *
806
+ * Best-effort: a failed refresh just falls back to the original
807
+ * candidates and lets the post-submit `VTXO_ALREADY_SPENT` recovery
808
+ * handle whatever slipped through.
809
+ */
810
+ async revalidateBeforeSettle(candidates, thresholdMs) {
811
+ if (candidates.length === 0)
812
+ return candidates;
813
+ try {
814
+ const cm = await this.wallet.getContractManager();
815
+ await cm.refreshOutpoints(candidates.map((v) => ({ txid: v.txid, vout: v.vout })));
816
+ }
817
+ catch (e) {
818
+ console.error("Error pre-validating VTXOs before settle:", e);
819
+ return candidates;
820
+ }
821
+ // Re-select from the now-fresh local cache. Anything previously
822
+ // selected but spent gets filtered out by the standard
823
+ // `isSpendable`/`isSpent` checks inside getVtxos / getExpiringVtxos.
824
+ try {
825
+ const refreshed = await this.getExpiringVtxos(thresholdMs);
826
+ const candidateKeys = new Set(candidates.map((v) => `${v.txid}:${v.vout}`));
827
+ // Restrict to vtxos that were also in the original candidate set
828
+ // — `getExpiringVtxos` may surface NEW vtxos and we don't want
829
+ // pre-flight to silently expand the input set.
830
+ return refreshed.filter((v) => candidateKeys.has(`${v.txid}:${v.vout}`));
831
+ }
832
+ catch (e) {
833
+ console.error("Error re-selecting VTXOs after pre-validate:", e);
834
+ return candidates;
835
+ }
836
+ }
749
837
  /** Computes the next poll delay, applying exponential backoff on failures. */
750
838
  getNextPollDelay() {
751
839
  if (this.settlementConfig === false)
@@ -887,6 +975,13 @@ class VtxoManager {
887
975
  if (!this.renewalInProgress) {
888
976
  try {
889
977
  expiringVtxos = await this.getExpiringVtxos();
978
+ // Pre-flight validation: see comment in `renewVtxos`. The
979
+ // local cache may carry vtxos that the indexer already
980
+ // marks spent because the cursor-derived delta sync only
981
+ // catches `created_at`-recent updates, not status changes
982
+ // for older VTXOs.
983
+ expiringVtxos =
984
+ await this.revalidateBeforeSettle(expiringVtxos);
890
985
  }
891
986
  catch (e) {
892
987
  // Non-fatal: fall back to boarding-only settle.
@@ -978,11 +1073,12 @@ class VtxoManager {
978
1073
  e.message.includes("VTXO_ALREADY_SPENT")) {
979
1074
  // Local VTXO cache is stale vs. the server's
980
1075
  // authoritative view — not a transient failure.
981
- // Trigger a throttled refresh and skip this cycle
982
- // without bumping the failure counter, so the next
983
- // poll can retry once the cache reconciles.
1076
+ // Trigger a throttled, targeted refresh on the
1077
+ // offending outpoint and skip this cycle without
1078
+ // bumping the failure counter, so the next poll
1079
+ // can retry once the cache reconciles.
984
1080
  staleCacheSkip = true;
985
- void this.maybeRefreshAfterVtxoSpent();
1081
+ void this.maybeRefreshAfterVtxoSpent(this.extractSpentOutpoint(e));
986
1082
  }
987
1083
  else {
988
1084
  throw e;
@@ -37,8 +37,9 @@ const delegator_1 = require("./delegator");
37
37
  const repositories_1 = require("../repositories");
38
38
  const contractManager_1 = require("../contracts/contractManager");
39
39
  const handlers_1 = require("../contracts/handlers");
40
- const helpers_1 = require("../contracts/handlers/helpers");
40
+ const timelock_1 = require("../utils/timelock");
41
41
  const syncCursors_1 = require("../utils/syncCursors");
42
+ const vtxoOwnership_1 = require("../contracts/vtxoOwnership");
42
43
  const getArkadeServerUrl = ({ arkServerUrl, }) => arkServerUrl || _1.DEFAULT_ARKADE_SERVER_URL;
43
44
  exports.getArkadeServerUrl = getArkadeServerUrl;
44
45
  // Historical unilateral exit delay for mainnet (~7 days in seconds).
@@ -55,7 +56,7 @@ function dedupeTimelocks(timelocks) {
55
56
  const seen = new Set();
56
57
  const deduped = [];
57
58
  for (const timelock of timelocks) {
58
- const sequence = (0, helpers_1.timelockToSequence)(timelock).toString();
59
+ const sequence = (0, timelock_1.timelockToSequence)(timelock).toString();
59
60
  if (seen.has(sequence))
60
61
  continue;
61
62
  seen.add(sequence);
@@ -648,7 +649,7 @@ class ReadonlyWallet {
648
649
  watcherConfig: this.watcherConfig,
649
650
  });
650
651
  for (const csvTimelock of this.walletContractTimelocks) {
651
- const csvTimelockStr = (0, helpers_1.timelockToSequence)(csvTimelock).toString();
652
+ const csvTimelockStr = (0, timelock_1.timelockToSequence)(csvTimelock).toString();
652
653
  const defaultScript = new default_1.DefaultVtxo.Script({
653
654
  pubKey: this.offchainTapscript.options.pubKey,
654
655
  serverPubKey: this.offchainTapscript.options.serverPubKey,
@@ -1830,7 +1831,7 @@ class Wallet extends ReadonlyWallet {
1830
1831
  }
1831
1832
  }
1832
1833
  const createdAt = Date.now();
1833
- const addr = this.arkAddress.encode();
1834
+ const primaryAddr = this.arkAddress.encode();
1834
1835
  // Only save a change virtual output for preconfirmed coins (those with a batchExpiry).
1835
1836
  // Inputs without a batchExpiry are already settled/unrolled and don't need tracking.
1836
1837
  let changeVtxo;
@@ -1857,8 +1858,37 @@ class Wallet extends ReadonlyWallet {
1857
1858
  script: base_1.hex.encode(this.offchainTapscript.pkScript),
1858
1859
  };
1859
1860
  }
1860
- await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
1861
- await this.walletRepository.saveTransactions(addr, [
1861
+ // Route spent rows to their owning contract bucket. The wallet's
1862
+ // primary contract is registered with the manager at boot, so
1863
+ // `addrByScript` already includes it; in a multi-contract spend
1864
+ // each input may belong to a different contract.
1865
+ const contracts = await cm.getContracts();
1866
+ const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
1867
+ const spentByScript = new Map();
1868
+ for (const v of spentVtxos) {
1869
+ if (!v.script) {
1870
+ throw new Error(`Wallet.updateDbAfterOffchainTx: spent VTXO ${v.txid}:${v.vout} has no script`);
1871
+ }
1872
+ const arr = spentByScript.get(v.script) ?? [];
1873
+ arr.push(v);
1874
+ spentByScript.set(v.script, arr);
1875
+ }
1876
+ for (const [script, vtxos] of spentByScript) {
1877
+ // User-initiated send path: a wrong-script row here means the
1878
+ // wallet is about to record ownership against the wrong
1879
+ // contract — fail loudly rather than persist inconsistent state.
1880
+ (0, vtxoOwnership_1.validateVtxosForScript)(vtxos, script, "Wallet.updateDbAfterOffchainTx");
1881
+ const targetAddr = addrByScript.get(script);
1882
+ if (!targetAddr) {
1883
+ throw new Error(`Wallet.updateDbAfterOffchainTx: no contract owns script ${script}`);
1884
+ }
1885
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script, address: targetAddr }, vtxos);
1886
+ }
1887
+ // Change is always primary-script by construction.
1888
+ if (changeVtxo) {
1889
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script: changeVtxo.script, address: primaryAddr }, [changeVtxo]);
1890
+ }
1891
+ await this.walletRepository.saveTransactions(primaryAddr, [
1862
1892
  {
1863
1893
  key: {
1864
1894
  boardingTxid: "",
@@ -1874,12 +1904,12 @@ class Wallet extends ReadonlyWallet {
1874
1904
  }
1875
1905
  catch (e) {
1876
1906
  console.warn("error saving offchain tx to repository", e);
1907
+ throw e;
1877
1908
  }
1878
1909
  }
1879
1910
  // mark virtual outputs as spent/settled, remove boarding inputs
1880
1911
  async updateDbAfterSettle(inputs, commitmentTxid) {
1881
1912
  try {
1882
- const addr = this.arkAddress.encode();
1883
1913
  const boardingAddress = await this.getBoardingAddress();
1884
1914
  const spentVtxos = [];
1885
1915
  const inputArkTxIds = new Set();
@@ -1912,7 +1942,32 @@ class Wallet extends ReadonlyWallet {
1912
1942
  }
1913
1943
  }
1914
1944
  if (spentVtxos.length > 0) {
1915
- await this.walletRepository.saveVtxos(addr, spentVtxos);
1945
+ // Route settled rows to their owning contract bucket. In a
1946
+ // multi-contract settle the inputs may belong to several
1947
+ // contracts; the wallet's primary contract is registered with
1948
+ // the manager at boot, so its address is in `addrByScript`
1949
+ // alongside the rest.
1950
+ const contracts = await cm.getContracts();
1951
+ const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
1952
+ const byScript = new Map();
1953
+ for (const v of spentVtxos) {
1954
+ if (!v.script) {
1955
+ throw new Error(`Wallet.updateDbAfterSettle: spent VTXO ${v.txid}:${v.vout} has no script`);
1956
+ }
1957
+ const arr = byScript.get(v.script) ?? [];
1958
+ arr.push(v);
1959
+ byScript.set(v.script, arr);
1960
+ }
1961
+ for (const [script, vtxos] of byScript) {
1962
+ // User-initiated settle path: refuse to record a settle
1963
+ // against the wrong script.
1964
+ (0, vtxoOwnership_1.validateVtxosForScript)(vtxos, script, "Wallet.updateDbAfterSettle");
1965
+ const targetAddr = addrByScript.get(script);
1966
+ if (!targetAddr) {
1967
+ throw new Error(`Wallet.updateDbAfterSettle: no contract owns script ${script}`);
1968
+ }
1969
+ await (0, vtxoOwnership_1.saveVtxosForContract)(this.walletRepository, { script, address: targetAddr }, vtxos);
1970
+ }
1916
1971
  }
1917
1972
  if (boardingUtxoToRemove.size > 0) {
1918
1973
  const currentUtxos = await this.walletRepository.getUtxos(boardingAddress);
@@ -1926,6 +1981,7 @@ class Wallet extends ReadonlyWallet {
1926
1981
  }
1927
1982
  catch (e) {
1928
1983
  console.warn("error updating repository after settle", e);
1984
+ throw e;
1929
1985
  }
1930
1986
  }
1931
1987
  }
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.contractPollProcessor = exports.CONTRACT_POLL_TASK_TYPE = void 0;
4
+ const vtxoOwnership_1 = require("../../../contracts/vtxoOwnership");
4
5
  exports.CONTRACT_POLL_TASK_TYPE = "contract-poll";
5
6
  /**
6
7
  * Polls the indexer for the latest VTXO state of every contract and
@@ -43,8 +44,12 @@ exports.contractPollProcessor = {
43
44
  hasMore = page ? vtxos.length === pageSize : false;
44
45
  pageIndex++;
45
46
  }
46
- await walletRepository.saveVtxos(contract.address, allVtxos);
47
- vtxosSaved += allVtxos.length;
47
+ // Skip wrong-script rows (legacy duplicates or indexer drift)
48
+ // before persisting; the loop must keep going for the remaining
49
+ // contracts even when one row is rejected.
50
+ const filtered = (0, vtxoOwnership_1.warnAndFilterVtxosForScript)(allVtxos, contract.script, "contractPollProcessor");
51
+ await (0, vtxoOwnership_1.saveVtxosForContract)(walletRepository, contract, filtered);
52
+ vtxosSaved += filtered.length;
48
53
  contractsProcessed++;
49
54
  }
50
55
  return {
@@ -3,6 +3,7 @@ import { ContractWatcher } from './contractWatcher.js';
3
3
  import { contractHandlers } from './handlers/index.js';
4
4
  import { extendVirtualCoinForContract } from '../wallet/utils.js';
5
5
  import { advanceSyncCursor, computeSyncWindow, cursorCutoff, getSyncCursor, } from '../utils/syncCursors.js';
6
+ import { getVtxosForContract, saveVtxosForContract, warnAndFilterVtxosForScript, } from './vtxoOwnership.js';
6
7
  const DEFAULT_PAGE_SIZE = 500;
7
8
  /**
8
9
  * Central manager for contract lifecycle and operations.
@@ -354,11 +355,61 @@ export class ContractManager {
354
355
  const contracts = opts?.scripts
355
356
  ? await this.getContracts({ script: opts.scripts })
356
357
  : undefined;
358
+ // Only forward an explicit window when the caller supplied one. An
359
+ // empty `{ after: undefined, before: undefined }` would short-circuit
360
+ // both the cursor-derived `?after=` query in `syncContracts` (because
361
+ // `??` doesn't fire on a non-nullish object) AND the cursor-advance
362
+ // gate (which requires `options.window === undefined`), turning every
363
+ // `refreshVtxos()` call into an unbounded full re-scan whose cursor
364
+ // never moves forward.
365
+ const hasExplicitWindow = opts?.after !== undefined || opts?.before !== undefined;
357
366
  await this.syncContracts({
358
367
  contracts,
359
- window: { after: opts?.after, before: opts?.before },
368
+ window: hasExplicitWindow
369
+ ? { after: opts?.after, before: opts?.before }
370
+ : undefined,
360
371
  });
361
372
  }
373
+ async refreshOutpoints(outpoints) {
374
+ if (outpoints.length === 0)
375
+ return;
376
+ const { vtxos } = await this.config.indexerProvider.getVtxos({
377
+ outpoints,
378
+ });
379
+ if (vtxos.length === 0)
380
+ return;
381
+ // Filter to outputs whose script we own. Map them to their owning
382
+ // contract so we can write through to the right per-address entry
383
+ // in the wallet repository.
384
+ const scripts = Array.from(new Set(vtxos.map((v) => v.script)));
385
+ const contracts = await this.config.contractRepository.getContracts({
386
+ script: scripts,
387
+ });
388
+ const scriptToContract = new Map(contracts.map((c) => [c.script, c]));
389
+ const owned = vtxos.filter((v) => scriptToContract.has(v.script));
390
+ if (owned.length === 0)
391
+ return;
392
+ const annotated = await this.annotateVtxos(owned);
393
+ const byAddress = new Map();
394
+ for (const vtxo of annotated) {
395
+ const contract = scriptToContract.get(vtxo.script);
396
+ if (!contract)
397
+ continue;
398
+ const address = contract.address;
399
+ const arr = byAddress.get(address) ?? [];
400
+ arr.push(vtxo);
401
+ byAddress.set(address, arr);
402
+ }
403
+ for (const [address, addressVtxos] of byAddress) {
404
+ const contract = contracts.find((c) => c.address === address);
405
+ if (contract) {
406
+ await saveVtxosForContract(this.config.walletRepository, contract, addressVtxos);
407
+ }
408
+ else {
409
+ await this.config.walletRepository.saveVtxos(address, addressVtxos);
410
+ }
411
+ }
412
+ }
362
413
  /**
363
414
  * Check if currently watching.
364
415
  */
@@ -399,9 +450,9 @@ export class ContractManager {
399
450
  this.emitEvent(event);
400
451
  }
401
452
  async getVtxosForContracts(contracts) {
402
- const res = await Promise.all(contracts.map(({ script, address }) => this.config.walletRepository.getVtxos(address).then((vtxos) => vtxos.map((vtxo) => ({
453
+ const res = await Promise.all(contracts.map((contract) => getVtxosForContract(this.config.walletRepository, contract).then((vtxos) => vtxos.map((vtxo) => ({
403
454
  ...vtxo,
404
- contractScript: script,
455
+ contractScript: contract.script,
405
456
  })))));
406
457
  return res.flat();
407
458
  }
@@ -467,7 +518,14 @@ export class ContractManager {
467
518
  });
468
519
  }
469
520
  for (const [addr, contractVtxos] of byContract) {
470
- await this.config.walletRepository.saveVtxos(addr, contractVtxos);
521
+ // The bucket is keyed by contract address, so the script filter
522
+ // here is the same as the contract's. Skip wrong-script rows
523
+ // rather than crash the reconcile loop.
524
+ const contract = contracts.find((c) => c.address === addr);
525
+ const filtered = warnAndFilterVtxosForScript(contractVtxos, contract.script, "ContractManager.reconcilePendingFrontier");
526
+ if (filtered.length === 0)
527
+ continue;
528
+ await saveVtxosForContract(this.config.walletRepository, contract, filtered);
471
529
  }
472
530
  }
473
531
  async fetchContractVxosFromIndexer(contracts, pageSize, syncWindow) {
@@ -477,7 +535,10 @@ export class ContractManager {
477
535
  result.set(contractScript, vtxos);
478
536
  const contract = contracts.find((c) => c.script === contractScript);
479
537
  if (contract) {
480
- await this.config.walletRepository.saveVtxos(contract.address, vtxos);
538
+ const filtered = warnAndFilterVtxosForScript(vtxos, contract.script, "ContractManager.fetchContractVxosFromIndexer");
539
+ if (filtered.length === 0)
540
+ continue;
541
+ await saveVtxosForContract(this.config.walletRepository, contract, filtered);
481
542
  }
482
543
  }
483
544
  return result;
@@ -1,5 +1,6 @@
1
1
  import { extendVirtualCoinForContract } from '../wallet/utils.js';
2
2
  import { isEventSourceError } from '../providers/utils.js';
3
+ import { getVtxosForContract } from './vtxoOwnership.js';
3
4
  /**
4
5
  * Watches multiple contracts for virtual output state changes with resilient connection handling.
5
6
  *
@@ -87,7 +88,10 @@ export class ContractWatcher {
87
88
  */
88
89
  async seedLastKnownVtxos(state) {
89
90
  try {
90
- const cached = await this.config.walletRepository.getVtxos(state.contract.address);
91
+ // Apply the same script gate used by getContractVtxos so a legacy
92
+ // wrong-script row in the address bucket can't seed the baseline
93
+ // and then look "spent" on the first poll.
94
+ const cached = await getVtxosForContract(this.config.walletRepository, state.contract);
91
95
  for (const vtxo of cached) {
92
96
  if (vtxo.isSpent)
93
97
  continue;
@@ -166,8 +170,10 @@ export class ContractWatcher {
166
170
  return true;
167
171
  })
168
172
  .map(async (state) => {
169
- // Use contract address as cache key
170
- const cached = await repo.getVtxos(state.contract.address);
173
+ // Use contract address as cache key. Legacy address buckets
174
+ // can contain rows from other contracts; gate by script before
175
+ // converting so a wrong-script row never reaches the watcher.
176
+ const cached = await getVtxosForContract(repo, state.contract);
171
177
  if (cached.length > 0) {
172
178
  // Convert to ContractVtxo with contractScript
173
179
  const contractVtxos = cached.map((v) => ({