@arkade-os/sdk 0.4.22 → 0.4.24

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 (105) hide show
  1. package/README.md +116 -13
  2. package/dist/cjs/contracts/arkcontract.js +2 -1
  3. package/dist/cjs/contracts/contractManager.js +29 -4
  4. package/dist/cjs/contracts/contractWatcher.js +9 -3
  5. package/dist/cjs/contracts/handlers/default.js +3 -2
  6. package/dist/cjs/contracts/handlers/delegate.js +3 -2
  7. package/dist/cjs/contracts/handlers/helpers.js +2 -58
  8. package/dist/cjs/contracts/handlers/vhtlc.js +7 -6
  9. package/dist/cjs/contracts/vtxoOwnership.js +60 -0
  10. package/dist/cjs/identity/descriptor.js +75 -4
  11. package/dist/cjs/identity/hdCapableIdentity.js +2 -0
  12. package/dist/cjs/identity/seedIdentity.js +225 -103
  13. package/dist/cjs/identity/serialize.js +5 -0
  14. package/dist/cjs/identity/staticDescriptorProvider.js +1 -1
  15. package/dist/cjs/index.js +12 -3
  16. package/dist/cjs/providers/electrum.js +285 -79
  17. package/dist/cjs/providers/expoIndexer.js +1 -1
  18. package/dist/cjs/providers/indexer.js +2 -2
  19. package/dist/cjs/providers/onchain.js +9 -3
  20. package/dist/cjs/repositories/migrations/walletRepositoryImpl.js +6 -2
  21. package/dist/cjs/repositories/realm/walletRepository.js +2 -2
  22. package/dist/cjs/repositories/serialization.js +34 -1
  23. package/dist/cjs/repositories/sqlite/walletRepository.js +4 -2
  24. package/dist/cjs/script/address.js +2 -1
  25. package/dist/cjs/script/base.js +12 -47
  26. package/dist/cjs/script/tapscript.js +97 -73
  27. package/dist/cjs/utils/timelock.js +59 -0
  28. package/dist/cjs/utils/transactionHistory.js +4 -4
  29. package/dist/cjs/utils/unknownFields.js +2 -39
  30. package/dist/cjs/wallet/asset-manager.js +18 -18
  31. package/dist/cjs/wallet/asset.js +10 -8
  32. package/dist/cjs/wallet/delegator.js +2 -2
  33. package/dist/cjs/wallet/hdDescriptorProvider.js +159 -0
  34. package/dist/cjs/wallet/index.js +5 -1
  35. package/dist/cjs/wallet/onchain.js +2 -1
  36. package/dist/cjs/wallet/serviceWorker/wallet-message-handler.js +60 -10
  37. package/dist/cjs/wallet/serviceWorker/wallet.js +5 -4
  38. package/dist/cjs/wallet/unroll.js +79 -67
  39. package/dist/cjs/wallet/validation.js +2 -3
  40. package/dist/cjs/wallet/wallet.js +91 -22
  41. package/dist/cjs/worker/expo/processors/contractPollProcessor.js +7 -2
  42. package/dist/esm/contracts/arkcontract.js +2 -1
  43. package/dist/esm/contracts/contractManager.js +29 -4
  44. package/dist/esm/contracts/contractWatcher.js +9 -3
  45. package/dist/esm/contracts/handlers/default.js +2 -1
  46. package/dist/esm/contracts/handlers/delegate.js +2 -1
  47. package/dist/esm/contracts/handlers/helpers.js +1 -22
  48. package/dist/esm/contracts/handlers/vhtlc.js +2 -1
  49. package/dist/esm/contracts/vtxoOwnership.js +53 -0
  50. package/dist/esm/identity/descriptor.js +74 -5
  51. package/dist/esm/identity/hdCapableIdentity.js +1 -0
  52. package/dist/esm/identity/seedIdentity.js +225 -103
  53. package/dist/esm/identity/serialize.js +5 -0
  54. package/dist/esm/identity/staticDescriptorProvider.js +1 -1
  55. package/dist/esm/index.js +7 -4
  56. package/dist/esm/providers/electrum.js +284 -78
  57. package/dist/esm/providers/expoIndexer.js +1 -1
  58. package/dist/esm/providers/indexer.js +2 -2
  59. package/dist/esm/providers/onchain.js +9 -3
  60. package/dist/esm/repositories/migrations/walletRepositoryImpl.js +6 -2
  61. package/dist/esm/repositories/realm/walletRepository.js +3 -3
  62. package/dist/esm/repositories/serialization.js +27 -0
  63. package/dist/esm/repositories/sqlite/walletRepository.js +5 -3
  64. package/dist/esm/script/address.js +2 -1
  65. package/dist/esm/script/base.js +12 -14
  66. package/dist/esm/script/tapscript.js +97 -40
  67. package/dist/esm/utils/timelock.js +22 -0
  68. package/dist/esm/utils/transactionHistory.js +4 -4
  69. package/dist/esm/utils/unknownFields.js +2 -6
  70. package/dist/esm/wallet/asset-manager.js +18 -18
  71. package/dist/esm/wallet/asset.js +10 -8
  72. package/dist/esm/wallet/delegator.js +2 -2
  73. package/dist/esm/wallet/hdDescriptorProvider.js +155 -0
  74. package/dist/esm/wallet/index.js +4 -0
  75. package/dist/esm/wallet/onchain.js +2 -1
  76. package/dist/esm/wallet/serviceWorker/wallet-message-handler.js +60 -10
  77. package/dist/esm/wallet/serviceWorker/wallet.js +5 -4
  78. package/dist/esm/wallet/unroll.js +78 -67
  79. package/dist/esm/wallet/validation.js +2 -3
  80. package/dist/esm/wallet/wallet.js +88 -20
  81. package/dist/esm/worker/expo/processors/contractPollProcessor.js +7 -2
  82. package/dist/types/contracts/arkcontract.d.ts +1 -1
  83. package/dist/types/contracts/handlers/helpers.d.ts +0 -9
  84. package/dist/types/contracts/vtxoOwnership.d.ts +25 -0
  85. package/dist/types/identity/descriptor.d.ts +26 -0
  86. package/dist/types/identity/descriptorProvider.d.ts +11 -4
  87. package/dist/types/identity/hdCapableIdentity.d.ts +44 -0
  88. package/dist/types/identity/index.d.ts +1 -0
  89. package/dist/types/identity/seedIdentity.d.ts +113 -29
  90. package/dist/types/identity/serialize.d.ts +12 -0
  91. package/dist/types/identity/staticDescriptorProvider.d.ts +1 -1
  92. package/dist/types/index.d.ts +6 -3
  93. package/dist/types/providers/electrum.d.ts +115 -15
  94. package/dist/types/providers/onchain.d.ts +6 -0
  95. package/dist/types/repositories/serialization.d.ts +26 -2
  96. package/dist/types/script/address.d.ts +1 -1
  97. package/dist/types/script/tapscript.d.ts +4 -0
  98. package/dist/types/utils/timelock.d.ts +9 -0
  99. package/dist/types/wallet/hdDescriptorProvider.d.ts +93 -0
  100. package/dist/types/wallet/index.d.ts +19 -10
  101. package/dist/types/wallet/onchain.d.ts +1 -1
  102. package/dist/types/wallet/serviceWorker/wallet.d.ts +1 -1
  103. package/dist/types/wallet/unroll.d.ts +10 -0
  104. package/dist/types/wallet/wallet.d.ts +4 -1
  105. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Wallet = exports.ReadonlyWallet = void 0;
3
+ exports.Wallet = exports.ReadonlyWallet = exports.getArkadeServerUrl = void 0;
4
4
  exports.selectVirtualCoins = selectVirtualCoins;
5
5
  exports.waitForIncomingFunds = waitForIncomingFunds;
6
6
  const base_1 = require("@scure/base");
@@ -37,8 +37,11 @@ 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");
43
+ const getArkadeServerUrl = ({ arkServerUrl, }) => arkServerUrl || _1.DEFAULT_ARKADE_SERVER_URL;
44
+ exports.getArkadeServerUrl = getArkadeServerUrl;
42
45
  // Historical unilateral exit delay for mainnet (~7 days in seconds).
43
46
  // Kept so existing wallets can still discover and spend VTXOs sent to the
44
47
  // legacy address after arkd starts advertising a different delay.
@@ -53,7 +56,7 @@ function dedupeTimelocks(timelocks) {
53
56
  const seen = new Set();
54
57
  const deduped = [];
55
58
  for (const timelock of timelocks) {
56
- const sequence = (0, helpers_1.timelockToSequence)(timelock).toString();
59
+ const sequence = (0, timelock_1.timelockToSequence)(timelock).toString();
57
60
  if (seen.has(sequence))
58
61
  continue;
59
62
  seen.add(sequence);
@@ -125,10 +128,7 @@ class ReadonlyWallet {
125
128
  // Use provided arkProvider instance or create a new one from arkServerUrl
126
129
  const arkProvider = config.arkProvider ||
127
130
  (() => {
128
- if (!config.arkServerUrl) {
129
- throw new Error("Either arkProvider or arkServerUrl must be provided");
130
- }
131
- return new ark_1.RestArkProvider(config.arkServerUrl);
131
+ return new ark_1.RestArkProvider((0, exports.getArkadeServerUrl)(config));
132
132
  })();
133
133
  // Extract arkServerUrl from provider if not explicitly provided
134
134
  const arkServerUrl = config.arkServerUrl || arkProvider.serverUrl;
@@ -307,7 +307,7 @@ class ReadonlyWallet {
307
307
  continue;
308
308
  if (vtxo.assets) {
309
309
  for (const a of vtxo.assets) {
310
- const current = assetBalances.get(a.assetId) ?? 0;
310
+ const current = assetBalances.get(a.assetId) ?? 0n;
311
311
  assetBalances.set(a.assetId, current + a.amount);
312
312
  }
313
313
  }
@@ -649,7 +649,7 @@ class ReadonlyWallet {
649
649
  watcherConfig: this.watcherConfig,
650
650
  });
651
651
  for (const csvTimelock of this.walletContractTimelocks) {
652
- const csvTimelockStr = (0, helpers_1.timelockToSequence)(csvTimelock).toString();
652
+ const csvTimelockStr = (0, timelock_1.timelockToSequence)(csvTimelock).toString();
653
653
  const defaultScript = new default_1.DefaultVtxo.Script({
654
654
  pubKey: this.offchainTapscript.options.pubKey,
655
655
  serverPubKey: this.offchainTapscript.options.serverPubKey,
@@ -1129,12 +1129,12 @@ class Wallet extends ReadonlyWallet {
1129
1129
  for (const [, assets] of assetInputs) {
1130
1130
  for (const asset of assets) {
1131
1131
  const existing = allAssets.get(asset.assetId) ?? 0n;
1132
- allAssets.set(asset.assetId, existing + BigInt(asset.amount));
1132
+ allAssets.set(asset.assetId, existing + asset.amount);
1133
1133
  }
1134
1134
  }
1135
1135
  outputAssets = [];
1136
1136
  for (const [assetId, amount] of allAssets) {
1137
- outputAssets.push({ assetId, amount: Number(amount) });
1137
+ outputAssets.push({ assetId, amount });
1138
1138
  }
1139
1139
  }
1140
1140
  const recipients = params.outputs.map((output, i) => ({
@@ -1569,7 +1569,7 @@ class Wallet extends ReadonlyWallet {
1569
1569
  * const txid = await wallet.send({
1570
1570
  * address: 'ark1q...',
1571
1571
  * amount: 1000, // (optional, default to dust) btc amount to send to the output
1572
- * assets: [{ assetId: 'abc123...', amount: 50 }] // (optional) list of assets to send
1572
+ * assets: [{ assetId: 'abc123...', amount: 50n }] // (optional) list of assets to send
1573
1573
  * });
1574
1574
  * ```
1575
1575
  */
@@ -1600,7 +1600,7 @@ class Wallet extends ReadonlyWallet {
1600
1600
  continue;
1601
1601
  }
1602
1602
  for (const receiverAsset of recipient.assets) {
1603
- let amountToSelect = BigInt(receiverAsset.amount);
1603
+ let amountToSelect = receiverAsset.amount;
1604
1604
  // check if existing change covers the needed amount
1605
1605
  const existingChange = assetChanges.get(receiverAsset.assetId) ?? 0n;
1606
1606
  if (existingChange >= amountToSelect) {
@@ -1627,7 +1627,7 @@ class Wallet extends ReadonlyWallet {
1627
1627
  continue;
1628
1628
  }
1629
1629
  const existing = assetChanges.get(a.assetId) ?? 0n;
1630
- assetChanges.set(a.assetId, existing + BigInt(a.amount));
1630
+ assetChanges.set(a.assetId, existing + a.amount);
1631
1631
  }
1632
1632
  }
1633
1633
  }
@@ -1647,7 +1647,7 @@ class Wallet extends ReadonlyWallet {
1647
1647
  if (coin.assets) {
1648
1648
  for (const asset of coin.assets) {
1649
1649
  const existing = assetChanges.get(asset.assetId) ?? 0n;
1650
- assetChanges.set(asset.assetId, existing + BigInt(asset.amount));
1650
+ assetChanges.set(asset.assetId, existing + asset.amount);
1651
1651
  }
1652
1652
  }
1653
1653
  }
@@ -1669,7 +1669,7 @@ class Wallet extends ReadonlyWallet {
1669
1669
  if (coin.assets) {
1670
1670
  for (const asset of coin.assets) {
1671
1671
  const existing = assetChanges.get(asset.assetId) ?? 0n;
1672
- assetChanges.set(asset.assetId, existing + BigInt(asset.amount));
1672
+ assetChanges.set(asset.assetId, existing + asset.amount);
1673
1673
  }
1674
1674
  }
1675
1675
  }
@@ -1684,7 +1684,7 @@ class Wallet extends ReadonlyWallet {
1684
1684
  const changeAssets = [];
1685
1685
  for (const [assetId, amount] of assetChanges) {
1686
1686
  if (amount > 0n) {
1687
- changeAssets.push({ assetId, amount: Number(amount) });
1687
+ changeAssets.push({ assetId, amount });
1688
1688
  }
1689
1689
  }
1690
1690
  changeIndex = outputs.length;
@@ -1831,7 +1831,7 @@ class Wallet extends ReadonlyWallet {
1831
1831
  }
1832
1832
  }
1833
1833
  const createdAt = Date.now();
1834
- const addr = this.arkAddress.encode();
1834
+ const primaryAddr = this.arkAddress.encode();
1835
1835
  // Only save a change virtual output for preconfirmed coins (those with a batchExpiry).
1836
1836
  // Inputs without a batchExpiry are already settled/unrolled and don't need tracking.
1837
1837
  let changeVtxo;
@@ -1858,8 +1858,45 @@ class Wallet extends ReadonlyWallet {
1858
1858
  script: base_1.hex.encode(this.offchainTapscript.pkScript),
1859
1859
  };
1860
1860
  }
1861
- await this.walletRepository.saveVtxos(addr, changeVtxo ? [...spentVtxos, changeVtxo] : spentVtxos);
1862
- 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
+ const byAddress = new Map();
1877
+ for (const [script, vtxos] of spentByScript) {
1878
+ // User-initiated send path: a wrong-script row here means the
1879
+ // wallet is about to record ownership against the wrong
1880
+ // contract — fail loudly rather than persist inconsistent state.
1881
+ (0, vtxoOwnership_1.validateVtxosForScript)(vtxos, script, "Wallet.updateDbAfterOffchainTx");
1882
+ const targetAddr = addrByScript.get(script);
1883
+ if (!targetAddr) {
1884
+ throw new Error(`Wallet.updateDbAfterOffchainTx: no contract owns script ${script}`);
1885
+ }
1886
+ const bucket = byAddress.get(targetAddr) ?? [];
1887
+ bucket.push(...vtxos);
1888
+ byAddress.set(targetAddr, bucket);
1889
+ }
1890
+ // Change is always primary-script by construction.
1891
+ if (changeVtxo) {
1892
+ const bucket = byAddress.get(primaryAddr) ?? [];
1893
+ bucket.push(changeVtxo);
1894
+ byAddress.set(primaryAddr, bucket);
1895
+ }
1896
+ for (const [addr, vtxos] of byAddress) {
1897
+ await this.walletRepository.saveVtxos(addr, vtxos);
1898
+ }
1899
+ await this.walletRepository.saveTransactions(primaryAddr, [
1863
1900
  {
1864
1901
  key: {
1865
1902
  boardingTxid: "",
@@ -1875,12 +1912,12 @@ class Wallet extends ReadonlyWallet {
1875
1912
  }
1876
1913
  catch (e) {
1877
1914
  console.warn("error saving offchain tx to repository", e);
1915
+ throw e;
1878
1916
  }
1879
1917
  }
1880
1918
  // mark virtual outputs as spent/settled, remove boarding inputs
1881
1919
  async updateDbAfterSettle(inputs, commitmentTxid) {
1882
1920
  try {
1883
- const addr = this.arkAddress.encode();
1884
1921
  const boardingAddress = await this.getBoardingAddress();
1885
1922
  const spentVtxos = [];
1886
1923
  const inputArkTxIds = new Set();
@@ -1913,7 +1950,38 @@ class Wallet extends ReadonlyWallet {
1913
1950
  }
1914
1951
  }
1915
1952
  if (spentVtxos.length > 0) {
1916
- await this.walletRepository.saveVtxos(addr, spentVtxos);
1953
+ // Route settled rows to their owning contract bucket. In a
1954
+ // multi-contract settle the inputs may belong to several
1955
+ // contracts; the wallet's primary contract is registered with
1956
+ // the manager at boot, so its address is in `addrByScript`
1957
+ // alongside the rest.
1958
+ const contracts = await cm.getContracts();
1959
+ const addrByScript = new Map(contracts.map((c) => [c.script, c.address]));
1960
+ const byAddress = new Map();
1961
+ const byScript = new Map();
1962
+ for (const v of spentVtxos) {
1963
+ if (!v.script) {
1964
+ throw new Error(`Wallet.updateDbAfterSettle: spent VTXO ${v.txid}:${v.vout} has no script`);
1965
+ }
1966
+ const arr = byScript.get(v.script) ?? [];
1967
+ arr.push(v);
1968
+ byScript.set(v.script, arr);
1969
+ }
1970
+ for (const [script, vtxos] of byScript) {
1971
+ // User-initiated settle path: refuse to record a settle
1972
+ // against the wrong script.
1973
+ (0, vtxoOwnership_1.validateVtxosForScript)(vtxos, script, "Wallet.updateDbAfterSettle");
1974
+ const targetAddr = addrByScript.get(script);
1975
+ if (!targetAddr) {
1976
+ throw new Error(`Wallet.updateDbAfterSettle: no contract owns script ${script}`);
1977
+ }
1978
+ const bucket = byAddress.get(targetAddr) ?? [];
1979
+ bucket.push(...vtxos);
1980
+ byAddress.set(targetAddr, bucket);
1981
+ }
1982
+ for (const [bucketAddr, vtxos] of byAddress) {
1983
+ await this.walletRepository.saveVtxos(bucketAddr, vtxos);
1984
+ }
1917
1985
  }
1918
1986
  if (boardingUtxoToRemove.size > 0) {
1919
1987
  const currentUtxos = await this.walletRepository.getUtxos(boardingAddress);
@@ -1927,6 +1995,7 @@ class Wallet extends ReadonlyWallet {
1927
1995
  }
1928
1996
  catch (e) {
1929
1997
  console.warn("error updating repository after settle", e);
1998
+ throw e;
1930
1999
  }
1931
2000
  }
1932
2001
  }
@@ -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 walletRepository.saveVtxos(contract.address, filtered);
52
+ vtxosSaved += filtered.length;
48
53
  contractsProcessed++;
49
54
  }
50
55
  return {
@@ -1,5 +1,6 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { contractHandlers } from './handlers/index.js';
3
+ import { DEFAULT_ARKADE_HRP } from '../wallet/index.js';
3
4
  /**
4
5
  * Prefix for arkcontract strings.
5
6
  */
@@ -115,7 +116,7 @@ export function contractFromArkContract(encoded, options = {}) {
115
116
  * @param options - Additional options
116
117
  * @returns A complete Contract object
117
118
  */
118
- export function contractFromArkContractWithAddress(encoded, serverPubKey, addressPrefix, options = {}) {
119
+ export function contractFromArkContractWithAddress(encoded, serverPubKey, addressPrefix = DEFAULT_ARKADE_HRP, options = {}) {
119
120
  const parsed = decodeArkContract(encoded);
120
121
  const handler = contractHandlers.getOrThrow(parsed.type);
121
122
  const params = parsed.data;
@@ -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 { filterVtxosForScript, warnAndFilterVtxosForScript, } from './vtxoOwnership.js';
6
7
  const DEFAULT_PAGE_SIZE = 500;
7
8
  /**
8
9
  * Central manager for contract lifecycle and operations.
@@ -354,9 +355,19 @@ 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
  }
362
373
  /**
@@ -399,7 +410,11 @@ export class ContractManager {
399
410
  this.emitEvent(event);
400
411
  }
401
412
  async getVtxosForContracts(contracts) {
402
- const res = await Promise.all(contracts.map(({ script, address }) => this.config.walletRepository.getVtxos(address).then((vtxos) => vtxos.map((vtxo) => ({
413
+ const res = await Promise.all(contracts.map(({ script, address }) => this.config.walletRepository.getVtxos(address).then((vtxos) =>
414
+ // Address buckets may carry legacy duplicate rows from
415
+ // other contracts that once shared the same address —
416
+ // gate by script so the wrong-script row never wins.
417
+ filterVtxosForScript(vtxos, script).map((vtxo) => ({
403
418
  ...vtxo,
404
419
  contractScript: script,
405
420
  })))));
@@ -467,7 +482,14 @@ export class ContractManager {
467
482
  });
468
483
  }
469
484
  for (const [addr, contractVtxos] of byContract) {
470
- await this.config.walletRepository.saveVtxos(addr, contractVtxos);
485
+ // The bucket is keyed by contract address, so the script filter
486
+ // here is the same as the contract's. Skip wrong-script rows
487
+ // rather than crash the reconcile loop.
488
+ const contract = contracts.find((c) => c.address === addr);
489
+ const filtered = warnAndFilterVtxosForScript(contractVtxos, contract.script, "ContractManager.reconcilePendingFrontier");
490
+ if (filtered.length === 0)
491
+ continue;
492
+ await this.config.walletRepository.saveVtxos(addr, filtered);
471
493
  }
472
494
  }
473
495
  async fetchContractVxosFromIndexer(contracts, pageSize, syncWindow) {
@@ -477,7 +499,10 @@ export class ContractManager {
477
499
  result.set(contractScript, vtxos);
478
500
  const contract = contracts.find((c) => c.script === contractScript);
479
501
  if (contract) {
480
- await this.config.walletRepository.saveVtxos(contract.address, vtxos);
502
+ const filtered = warnAndFilterVtxosForScript(vtxos, contract.script, "ContractManager.fetchContractVxosFromIndexer");
503
+ if (filtered.length === 0)
504
+ continue;
505
+ await this.config.walletRepository.saveVtxos(contract.address, filtered);
481
506
  }
482
507
  }
483
508
  return result;
@@ -1,5 +1,6 @@
1
1
  import { extendVirtualCoinForContract } from '../wallet/utils.js';
2
2
  import { isEventSourceError } from '../providers/utils.js';
3
+ import { filterVtxosForScript } 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 = filterVtxosForScript(await this.config.walletRepository.getVtxos(state.contract.address), state.contract.script);
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 = filterVtxosForScript(await repo.getVtxos(state.contract.address), state.contract.script);
171
177
  if (cached.length > 0) {
172
178
  // Convert to ContractVtxo with contractScript
173
179
  const contractVtxos = cached.map((v) => ({
@@ -1,6 +1,7 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { DefaultVtxo } from '../../script/default.js';
3
- import { isCsvSpendable, sequenceToTimelock, timelockToSequence, } from './helpers.js';
3
+ import { isCsvSpendable } from './helpers.js';
4
+ import { sequenceToTimelock, timelockToSequence } from '../../utils/timelock.js';
4
5
  import { normalizeToDescriptor, extractPubKey, } from '../../identity/descriptor.js';
5
6
  /**
6
7
  * Extract pubkey bytes from a descriptor or hex string.
@@ -1,7 +1,8 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { DelegateVtxo } from '../../script/delegate.js';
3
3
  import { DefaultVtxo } from '../../script/default.js';
4
- import { isCsvSpendable, sequenceToTimelock, timelockToSequence, } from './helpers.js';
4
+ import { isCsvSpendable } from './helpers.js';
5
+ import { sequenceToTimelock, timelockToSequence } from '../../utils/timelock.js';
5
6
  /**
6
7
  * Handler for delegate wallet virtual outputs.
7
8
  *
@@ -1,4 +1,4 @@
1
- import * as bip68 from "bip68";
1
+ import { sequenceToTimelock } from '../../utils/timelock.js';
2
2
  import { isDescriptor, extractPubKey } from '../../identity/descriptor.js';
3
3
  /**
4
4
  * Extract raw hex pubkey from a value that may be a descriptor or raw hex.
@@ -16,27 +16,6 @@ function extractRawPubKey(value) {
16
16
  return undefined;
17
17
  }
18
18
  }
19
- /**
20
- * Convert RelativeTimelock to BIP68 sequence number.
21
- */
22
- export function timelockToSequence(timelock) {
23
- return bip68.encode(timelock.type === "blocks"
24
- ? { blocks: Number(timelock.value) }
25
- : { seconds: Number(timelock.value) });
26
- }
27
- /**
28
- * Convert BIP68 sequence number back to RelativeTimelock.
29
- */
30
- export function sequenceToTimelock(sequence) {
31
- const decoded = bip68.decode(sequence);
32
- if ("blocks" in decoded && decoded.blocks !== undefined) {
33
- return { type: "blocks", value: BigInt(decoded.blocks) };
34
- }
35
- if ("seconds" in decoded && decoded.seconds !== undefined) {
36
- return { type: "seconds", value: BigInt(decoded.seconds) };
37
- }
38
- throw new Error(`Invalid BIP68 sequence: ${sequence}`);
39
- }
40
19
  /**
41
20
  * Resolve wallet's role from explicit role or by matching descriptor/pubkey.
42
21
  */
@@ -1,6 +1,7 @@
1
1
  import { hex } from "@scure/base";
2
2
  import { VHTLC } from '../../script/vhtlc.js';
3
- import { isCltvSatisfied, isCsvSpendable, resolveRole, sequenceToTimelock, timelockToSequence, } from './helpers.js';
3
+ import { isCltvSatisfied, isCsvSpendable, resolveRole } from './helpers.js';
4
+ import { sequenceToTimelock, timelockToSequence } from '../../utils/timelock.js';
4
5
  /**
5
6
  * Handler for Virtual Hash Time Lock Contract (VHTLC).
6
7
  *
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Tier 1 helpers that enforce VTXO ownership at call sites that already know
3
+ * the intended contract script. Address-keyed repositories may still hand back
4
+ * legacy duplicate rows under the wrong bucket; these helpers gate reads and
5
+ * writes so a wrong-script row never wins.
6
+ *
7
+ * `script` is the authoritative ownership key. Equality is strict: a missing
8
+ * or empty `vtxo.script` never matches.
9
+ */
10
+ export function vtxoOutpoint(vtxo) {
11
+ return `${vtxo.txid}:${vtxo.vout}`;
12
+ }
13
+ export function isVtxoForScript(vtxo, script) {
14
+ return !!vtxo.script && vtxo.script === script;
15
+ }
16
+ export function filterVtxosForScript(vtxos, script) {
17
+ return vtxos.filter((v) => isVtxoForScript(v, script));
18
+ }
19
+ /**
20
+ * Background/indexer sync flavour: drop wrong-script rows and log enough
21
+ * context to identify each rejection. Returns only matching rows so the
22
+ * caller can keep going.
23
+ */
24
+ export function warnAndFilterVtxosForScript(vtxos, script, context) {
25
+ const matches = [];
26
+ const rejected = [];
27
+ for (const v of vtxos) {
28
+ if (isVtxoForScript(v, script)) {
29
+ matches.push(v);
30
+ }
31
+ else {
32
+ rejected.push(`${vtxoOutpoint(v)}(script=${v.script ?? ""})`);
33
+ }
34
+ }
35
+ if (rejected.length > 0) {
36
+ console.warn(`${context}: dropped ${rejected.length} wrong-script VTXO(s) for script ${script}: ${rejected.join(", ")}`);
37
+ }
38
+ return matches;
39
+ }
40
+ /**
41
+ * User-initiated transaction/signing flavour: throw before persisting or
42
+ * signing inconsistent ownership state. Silently skipping here would hide a
43
+ * serious bug in the wallet's spend path.
44
+ */
45
+ export function validateVtxosForScript(vtxos, script, context) {
46
+ const mismatches = vtxos.filter((v) => !isVtxoForScript(v, script));
47
+ if (mismatches.length === 0)
48
+ return;
49
+ const detail = mismatches
50
+ .map((v) => `${vtxoOutpoint(v)}(script=${v.script ?? ""})`)
51
+ .join(", ");
52
+ throw new Error(`${context}: refusing to persist ${mismatches.length} VTXO(s) whose script does not match ${script}: ${detail}`);
53
+ }
@@ -1,7 +1,72 @@
1
- import { expand, networks, } from "@bitcoinerlab/descriptors-scure";
1
+ import { expand, networks } from "@bitcoinerlab/descriptors-scure";
2
2
  import { hex } from "@scure/base";
3
- function inferNetwork(descriptor) {
4
- return descriptor.includes("tpub") ? networks.testnet : networks.bitcoin;
3
+ /**
4
+ * True iff `descriptor` is a Bitcoin mainnet descriptor (xpub-prefixed
5
+ * BIP32 key).
6
+ *
7
+ * Note: testnet, signet, regtest, mutinynet, and other non-mainnet
8
+ * networks all share the same `tpub` BIP32 version bytes — they cannot
9
+ * be distinguished from one another at the descriptor level. Callers
10
+ * that need a `Network` constants object for `expand()` pick
11
+ * `networks.bitcoin` vs `networks.testnet` themselves; callers that
12
+ * need the *actual* network the wallet is on must track that
13
+ * out-of-band.
14
+ */
15
+ export function isMainnetDescriptor(descriptor) {
16
+ return !descriptor.includes("tpub");
17
+ }
18
+ /**
19
+ * Shared "does `candidate` belong to the identity backed by
20
+ * `ourDescriptor`?" predicate.
21
+ *
22
+ * - HD descriptors (expanding to a `bip32` key) match by account xpub
23
+ * on both sides — index-agnostic, so a wildcard template and any
24
+ * concrete index under it all collapse to the same xpub.
25
+ * - Bare `tr(pubkey)` candidates fall back to comparing the candidate
26
+ * pubkey against `ourXOnlyPubkey` (the cached pubkey on the identity
27
+ * side, since pulling it from `ourDescriptor` would require an index
28
+ * substitution the caller already performed).
29
+ */
30
+ export function descriptorIsOurs(candidate, ourDescriptor, ourXOnlyPubkey) {
31
+ if (!isDescriptor(candidate))
32
+ return false;
33
+ try {
34
+ const candidateInfo = expand({
35
+ descriptor: candidate,
36
+ network: isMainnetDescriptor(candidate)
37
+ ? networks.bitcoin
38
+ : networks.testnet,
39
+ }).expansionMap?.["@0"];
40
+ if (!candidateInfo)
41
+ return false;
42
+ if (candidateInfo.bip32) {
43
+ const ourBip32 = expand({
44
+ descriptor: ourDescriptor,
45
+ network: isMainnetDescriptor(ourDescriptor)
46
+ ? networks.bitcoin
47
+ : networks.testnet,
48
+ }).expansionMap?.["@0"]?.bip32;
49
+ if (!ourBip32)
50
+ return false;
51
+ return ourBip32.toBase58() === candidateInfo.bip32.toBase58();
52
+ }
53
+ if (candidateInfo.pubkey) {
54
+ // For tr() the library hands back a 32-byte x-only key, but
55
+ // strip a leading parity byte defensively so a 33-byte
56
+ // compressed key (mismatched length) doesn't silently
57
+ // false-negative against our 32-byte x-only side.
58
+ const candidatePub = candidateInfo.pubkey.length === 33
59
+ ? candidateInfo.pubkey.subarray(1)
60
+ : candidateInfo.pubkey;
61
+ if (candidatePub.length !== ourXOnlyPubkey.length)
62
+ return false;
63
+ return hex.encode(candidatePub) === hex.encode(ourXOnlyPubkey);
64
+ }
65
+ return false;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
5
70
  }
6
71
  /**
7
72
  * Check if a string is a descriptor of the shape `tr(...)`.
@@ -43,7 +108,9 @@ export function extractPubKey(descriptor) {
43
108
  if (!isDescriptor(descriptor)) {
44
109
  return descriptor;
45
110
  }
46
- const network = inferNetwork(descriptor);
111
+ const network = isMainnetDescriptor(descriptor)
112
+ ? networks.bitcoin
113
+ : networks.testnet;
47
114
  const expansion = expand({ descriptor, network });
48
115
  if (!expansion.expansionMap) {
49
116
  throw new Error("Cannot extract pubkey from descriptor: expansion failed.");
@@ -70,7 +137,9 @@ export function parseHDDescriptor(descriptor) {
70
137
  }
71
138
  let expansion;
72
139
  try {
73
- const network = inferNetwork(descriptor);
140
+ const network = isMainnetDescriptor(descriptor)
141
+ ? networks.bitcoin
142
+ : networks.testnet;
74
143
  expansion = expand({ descriptor, network });
75
144
  }
76
145
  catch {
@@ -0,0 +1 @@
1
+ export {};