@arkade-os/sdk 0.1.3 → 0.2.0

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 (114) hide show
  1. package/README.md +156 -174
  2. package/dist/cjs/arknote/index.js +61 -58
  3. package/dist/cjs/bip322/errors.js +13 -0
  4. package/dist/cjs/bip322/index.js +178 -0
  5. package/dist/cjs/forfeit.js +14 -25
  6. package/dist/cjs/identity/singleKey.js +68 -0
  7. package/dist/cjs/index.js +41 -17
  8. package/dist/cjs/providers/ark.js +253 -317
  9. package/dist/cjs/providers/indexer.js +525 -0
  10. package/dist/cjs/providers/onchain.js +193 -15
  11. package/dist/cjs/script/address.js +48 -17
  12. package/dist/cjs/script/base.js +120 -3
  13. package/dist/cjs/script/default.js +18 -4
  14. package/dist/cjs/script/tapscript.js +46 -14
  15. package/dist/cjs/script/vhtlc.js +27 -7
  16. package/dist/cjs/tree/signingSession.js +63 -106
  17. package/dist/cjs/tree/txTree.js +193 -0
  18. package/dist/cjs/tree/validation.js +79 -155
  19. package/dist/cjs/utils/anchor.js +35 -0
  20. package/dist/cjs/utils/arkTransaction.js +108 -0
  21. package/dist/cjs/utils/transactionHistory.js +84 -72
  22. package/dist/cjs/utils/txSizeEstimator.js +12 -0
  23. package/dist/cjs/utils/unknownFields.js +211 -0
  24. package/dist/cjs/wallet/index.js +12 -0
  25. package/dist/cjs/wallet/onchain.js +201 -0
  26. package/dist/cjs/wallet/ramps.js +95 -0
  27. package/dist/cjs/wallet/serviceWorker/db/vtxo/idb.js +32 -0
  28. package/dist/cjs/wallet/serviceWorker/request.js +15 -12
  29. package/dist/cjs/wallet/serviceWorker/response.js +22 -27
  30. package/dist/cjs/wallet/serviceWorker/utils.js +8 -0
  31. package/dist/cjs/wallet/serviceWorker/wallet.js +58 -34
  32. package/dist/cjs/wallet/serviceWorker/worker.js +117 -108
  33. package/dist/cjs/wallet/unroll.js +270 -0
  34. package/dist/cjs/wallet/wallet.js +701 -459
  35. package/dist/esm/arknote/index.js +61 -57
  36. package/dist/esm/bip322/errors.js +9 -0
  37. package/dist/esm/bip322/index.js +174 -0
  38. package/dist/esm/forfeit.js +15 -26
  39. package/dist/esm/identity/singleKey.js +64 -0
  40. package/dist/esm/index.js +30 -12
  41. package/dist/esm/providers/ark.js +252 -317
  42. package/dist/esm/providers/indexer.js +521 -0
  43. package/dist/esm/providers/onchain.js +193 -15
  44. package/dist/esm/script/address.js +48 -17
  45. package/dist/esm/script/base.js +120 -3
  46. package/dist/esm/script/default.js +18 -4
  47. package/dist/esm/script/tapscript.js +46 -14
  48. package/dist/esm/script/vhtlc.js +27 -7
  49. package/dist/esm/tree/signingSession.js +65 -108
  50. package/dist/esm/tree/txTree.js +189 -0
  51. package/dist/esm/tree/validation.js +75 -152
  52. package/dist/esm/utils/anchor.js +31 -0
  53. package/dist/esm/utils/arkTransaction.js +105 -0
  54. package/dist/esm/utils/transactionHistory.js +84 -72
  55. package/dist/esm/utils/txSizeEstimator.js +12 -0
  56. package/dist/esm/utils/unknownFields.js +173 -0
  57. package/dist/esm/wallet/index.js +9 -0
  58. package/dist/esm/wallet/onchain.js +196 -0
  59. package/dist/esm/wallet/ramps.js +91 -0
  60. package/dist/esm/wallet/serviceWorker/db/vtxo/idb.js +32 -0
  61. package/dist/esm/wallet/serviceWorker/request.js +15 -12
  62. package/dist/esm/wallet/serviceWorker/response.js +22 -27
  63. package/dist/esm/wallet/serviceWorker/utils.js +8 -0
  64. package/dist/esm/wallet/serviceWorker/wallet.js +59 -35
  65. package/dist/esm/wallet/serviceWorker/worker.js +117 -108
  66. package/dist/esm/wallet/unroll.js +267 -0
  67. package/dist/esm/wallet/wallet.js +674 -466
  68. package/dist/types/arknote/index.d.ts +40 -13
  69. package/dist/types/bip322/errors.d.ts +6 -0
  70. package/dist/types/bip322/index.d.ts +57 -0
  71. package/dist/types/forfeit.d.ts +2 -14
  72. package/dist/types/identity/singleKey.d.ts +27 -0
  73. package/dist/types/index.d.ts +23 -12
  74. package/dist/types/providers/ark.d.ts +114 -95
  75. package/dist/types/providers/indexer.d.ts +186 -0
  76. package/dist/types/providers/onchain.d.ts +41 -11
  77. package/dist/types/script/address.d.ts +26 -2
  78. package/dist/types/script/base.d.ts +13 -3
  79. package/dist/types/script/default.d.ts +22 -0
  80. package/dist/types/script/tapscript.d.ts +61 -5
  81. package/dist/types/script/vhtlc.d.ts +27 -0
  82. package/dist/types/tree/signingSession.d.ts +5 -5
  83. package/dist/types/tree/txTree.d.ts +28 -0
  84. package/dist/types/tree/validation.d.ts +15 -22
  85. package/dist/types/utils/anchor.d.ts +19 -0
  86. package/dist/types/utils/arkTransaction.d.ts +27 -0
  87. package/dist/types/utils/transactionHistory.d.ts +7 -1
  88. package/dist/types/utils/txSizeEstimator.d.ts +3 -0
  89. package/dist/types/utils/unknownFields.d.ts +83 -0
  90. package/dist/types/wallet/index.d.ts +51 -50
  91. package/dist/types/wallet/onchain.d.ts +49 -0
  92. package/dist/types/wallet/ramps.d.ts +32 -0
  93. package/dist/types/wallet/serviceWorker/db/vtxo/idb.d.ts +2 -0
  94. package/dist/types/wallet/serviceWorker/db/vtxo/index.d.ts +2 -0
  95. package/dist/types/wallet/serviceWorker/request.d.ts +14 -16
  96. package/dist/types/wallet/serviceWorker/response.d.ts +17 -19
  97. package/dist/types/wallet/serviceWorker/utils.d.ts +8 -0
  98. package/dist/types/wallet/serviceWorker/wallet.d.ts +36 -8
  99. package/dist/types/wallet/serviceWorker/worker.d.ts +7 -3
  100. package/dist/types/wallet/unroll.d.ts +102 -0
  101. package/dist/types/wallet/wallet.d.ts +71 -26
  102. package/package.json +14 -15
  103. package/dist/cjs/identity/inMemoryKey.js +0 -40
  104. package/dist/cjs/tree/vtxoTree.js +0 -231
  105. package/dist/cjs/utils/coinselect.js +0 -73
  106. package/dist/cjs/utils/psbt.js +0 -137
  107. package/dist/esm/identity/inMemoryKey.js +0 -36
  108. package/dist/esm/tree/vtxoTree.js +0 -191
  109. package/dist/esm/utils/coinselect.js +0 -69
  110. package/dist/esm/utils/psbt.js +0 -131
  111. package/dist/types/identity/inMemoryKey.d.ts +0 -12
  112. package/dist/types/tree/vtxoTree.d.ts +0 -33
  113. package/dist/types/utils/coinselect.d.ts +0 -21
  114. package/dist/types/utils/psbt.d.ts +0 -11
@@ -1,233 +1,216 @@
1
1
  import { base64, hex } from "@scure/base";
2
- import { Address, OutScript, p2tr, tapLeafHash, } from "@scure/btc-signer/payment";
3
- import { Transaction } from "@scure/btc-signer";
4
- import { TaprootControlBlock } from "@scure/btc-signer/psbt";
2
+ import * as bip68 from "bip68";
3
+ import { Address, OutScript, tapLeafHash } from "@scure/btc-signer/payment";
4
+ import { SigHash, Transaction } from "@scure/btc-signer";
5
+ import { TaprootControlBlock, } from "@scure/btc-signer/psbt";
5
6
  import { vtxosToTxs } from '../utils/transactionHistory.js';
6
- import { BIP21 } from '../utils/bip21.js';
7
7
  import { ArkAddress } from '../script/address.js';
8
8
  import { DefaultVtxo } from '../script/default.js';
9
- import { selectCoins, selectVirtualCoins } from '../utils/coinselect.js';
10
9
  import { getNetwork } from '../networks.js';
11
10
  import { ESPLORA_URL, EsploraProvider, } from '../providers/onchain.js';
12
11
  import { SettlementEventType, RestArkProvider, } from '../providers/ark.js';
13
12
  import { buildForfeitTx } from '../forfeit.js';
14
- import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
15
- import { validateConnectorsTree, validateVtxoTree } from '../tree/validation.js';
16
- import { TxType, } from './index.js';
17
- import { scriptFromTapLeafScript, VtxoScript } from '../script/base.js';
18
- import { CSVMultisigTapscript, decodeTapscript, } from '../script/tapscript.js';
19
- import { createVirtualTx } from '../utils/psbt.js';
13
+ import { validateConnectorsTxGraph, validateVtxoTxGraph, } from '../tree/validation.js';
14
+ import { isRecoverable, isSpendable, isSubdust, TxType, } from './index.js';
15
+ import { sha256, sha256x2 } from "@scure/btc-signer/utils";
16
+ import { VtxoScript } from '../script/base.js';
17
+ import { CSVMultisigTapscript } from '../script/tapscript.js';
18
+ import { buildOffchainTx } from '../utils/arkTransaction.js';
20
19
  import { ArkNote } from '../arknote/index.js';
21
- // Wallet does not store any data and rely on the Ark and onchain providers to fetch utxos and vtxos
20
+ import { BIP322 } from '../bip322/index.js';
21
+ import { RestIndexerProvider } from '../providers/indexer.js';
22
+ import { TxTree } from '../tree/txTree.js';
23
+ /**
24
+ * Main wallet implementation for Bitcoin transactions with Ark protocol support.
25
+ * The wallet does not store any data locally and relies on Ark and onchain
26
+ * providers to fetch UTXOs and VTXOs.
27
+ *
28
+ * @example
29
+ * ```typescript
30
+ * // Create a wallet
31
+ * const wallet = await Wallet.create({
32
+ * identity: SingleKey.fromHex('your_private_key'),
33
+ * arkServerUrl: 'https://ark.example.com',
34
+ * esploraUrl: 'https://mempool.space/api'
35
+ * });
36
+ *
37
+ * // Get addresses
38
+ * const arkAddress = await wallet.getAddress();
39
+ * const boardingAddress = await wallet.getBoardingAddress();
40
+ *
41
+ * // Send bitcoin
42
+ * const txid = await wallet.sendBitcoin({
43
+ * address: 'tb1...',
44
+ * amount: 50000
45
+ * });
46
+ * ```
47
+ */
22
48
  export class Wallet {
23
- constructor(identity, network, onchainProvider, onchainP2TR, arkProvider, arkServerPublicKey, offchainTapscript, boardingTapscript) {
49
+ constructor(identity, network, networkName, onchainProvider, arkProvider, indexerProvider, arkServerPublicKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, dustAmount) {
24
50
  this.identity = identity;
25
51
  this.network = network;
52
+ this.networkName = networkName;
26
53
  this.onchainProvider = onchainProvider;
27
- this.onchainP2TR = onchainP2TR;
28
54
  this.arkProvider = arkProvider;
55
+ this.indexerProvider = indexerProvider;
29
56
  this.arkServerPublicKey = arkServerPublicKey;
30
57
  this.offchainTapscript = offchainTapscript;
31
58
  this.boardingTapscript = boardingTapscript;
59
+ this.serverUnrollScript = serverUnrollScript;
60
+ this.forfeitOutputScript = forfeitOutputScript;
61
+ this.dustAmount = dustAmount;
32
62
  }
33
63
  static async create(config) {
34
- const network = getNetwork(config.network);
35
- const onchainProvider = new EsploraProvider(config.esploraUrl || ESPLORA_URL[config.network]);
36
- // Derive onchain address
37
64
  const pubkey = config.identity.xOnlyPublicKey();
38
65
  if (!pubkey) {
39
66
  throw new Error("Invalid configured public key");
40
67
  }
41
- let arkProvider;
42
- if (config.arkServerUrl) {
43
- arkProvider = new RestArkProvider(config.arkServerUrl);
44
- }
45
- // Save onchain Taproot address key-path only
46
- const onchainP2TR = p2tr(pubkey, undefined, network);
47
- if (arkProvider) {
48
- const info = await arkProvider.getInfo();
49
- if (info.network !== config.network) {
50
- throw new Error(`The Ark Server URL expects ${info.network} but ${config.network} was configured`);
51
- }
52
- const exitTimelock = {
53
- value: info.unilateralExitDelay,
54
- type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
55
- };
56
- const boardingTimelock = {
57
- value: info.unilateralExitDelay * 2n,
58
- type: info.unilateralExitDelay * 2n < 512n ? "blocks" : "seconds",
59
- };
60
- // Generate tapscripts for offchain and boarding address
61
- const serverPubKey = hex.decode(info.pubkey).slice(1);
62
- const bareVtxoTapscript = new DefaultVtxo.Script({
63
- pubKey: pubkey,
64
- serverPubKey,
65
- csvTimelock: exitTimelock,
66
- });
67
- const boardingTapscript = new DefaultVtxo.Script({
68
- pubKey: pubkey,
69
- serverPubKey,
70
- csvTimelock: boardingTimelock,
71
- });
72
- // Save tapscripts
73
- const offchainTapscript = bareVtxoTapscript;
74
- return new Wallet(config.identity, network, onchainProvider, onchainP2TR, arkProvider, serverPubKey, offchainTapscript, boardingTapscript);
75
- }
76
- return new Wallet(config.identity, network, onchainProvider, onchainP2TR);
77
- }
78
- get onchainAddress() {
79
- return this.onchainP2TR.address || "";
80
- }
81
- get boardingAddress() {
82
- if (!this.boardingTapscript || !this.arkServerPublicKey) {
83
- throw new Error("Boarding address not configured");
84
- }
85
- return this.boardingTapscript.address(this.network.hrp, this.arkServerPublicKey);
86
- }
87
- get boardingOnchainAddress() {
88
- if (!this.boardingTapscript) {
89
- throw new Error("Boarding address not configured");
90
- }
91
- return this.boardingTapscript.onchainAddress(this.network);
68
+ const arkProvider = new RestArkProvider(config.arkServerUrl);
69
+ const indexerProvider = new RestIndexerProvider(config.arkServerUrl);
70
+ const info = await arkProvider.getInfo();
71
+ const network = getNetwork(info.network);
72
+ const onchainProvider = new EsploraProvider(config.esploraUrl || ESPLORA_URL[info.network]);
73
+ const exitTimelock = {
74
+ value: info.unilateralExitDelay,
75
+ type: info.unilateralExitDelay < 512n ? "blocks" : "seconds",
76
+ };
77
+ const boardingTimelock = {
78
+ value: info.boardingExitDelay,
79
+ type: info.boardingExitDelay < 512n ? "blocks" : "seconds",
80
+ };
81
+ // Generate tapscripts for offchain and boarding address
82
+ const serverPubKey = hex.decode(info.signerPubkey).slice(1);
83
+ const bareVtxoTapscript = new DefaultVtxo.Script({
84
+ pubKey: pubkey,
85
+ serverPubKey,
86
+ csvTimelock: exitTimelock,
87
+ });
88
+ const boardingTapscript = new DefaultVtxo.Script({
89
+ pubKey: pubkey,
90
+ serverPubKey,
91
+ csvTimelock: boardingTimelock,
92
+ });
93
+ // Save tapscripts
94
+ const offchainTapscript = bareVtxoTapscript;
95
+ // the serverUnrollScript is the one used to create output scripts of the checkpoint transactions
96
+ const serverUnrollScript = CSVMultisigTapscript.encode({
97
+ timelock: exitTimelock,
98
+ pubkeys: [serverPubKey],
99
+ });
100
+ // parse the server forfeit address
101
+ // server is expecting funds to be sent to this address
102
+ const forfeitAddress = Address(network).decode(info.forfeitAddress);
103
+ const forfeitOutputScript = OutScript.encode(forfeitAddress);
104
+ return new Wallet(config.identity, network, info.network, onchainProvider, arkProvider, indexerProvider, serverPubKey, offchainTapscript, boardingTapscript, serverUnrollScript, forfeitOutputScript, info.dust);
92
105
  }
93
- get offchainAddress() {
94
- if (!this.offchainTapscript || !this.arkServerPublicKey) {
95
- throw new Error("Offchain address not configured");
96
- }
106
+ get arkAddress() {
97
107
  return this.offchainTapscript.address(this.network.hrp, this.arkServerPublicKey);
98
108
  }
99
- getAddress() {
100
- const addressInfo = {
101
- onchain: this.onchainAddress,
102
- bip21: BIP21.create({
103
- address: this.onchainAddress,
104
- }),
105
- };
106
- // Only include Ark-related fields if Ark provider is configured and address is available
107
- if (this.arkProvider &&
108
- this.offchainTapscript &&
109
- this.boardingTapscript &&
110
- this.arkServerPublicKey) {
111
- const offchainAddress = this.offchainAddress.encode();
112
- addressInfo.offchain = offchainAddress;
113
- addressInfo.bip21 = BIP21.create({
114
- address: this.onchainP2TR.address,
115
- ark: offchainAddress,
116
- });
117
- addressInfo.boarding = this.boardingOnchainAddress;
118
- }
119
- return Promise.resolve(addressInfo);
109
+ async getAddress() {
110
+ return this.arkAddress.encode();
120
111
  }
121
- getAddressInfo() {
122
- if (!this.arkProvider ||
123
- !this.offchainTapscript ||
124
- !this.boardingTapscript ||
125
- !this.arkServerPublicKey) {
126
- throw new Error("Ark provider not configured");
127
- }
128
- const offchainAddress = this.offchainAddress.encode();
129
- const boardingAddress = this.boardingOnchainAddress;
130
- return Promise.resolve({
131
- offchain: {
132
- address: offchainAddress,
133
- scripts: {
134
- exit: [this.offchainTapscript.exitScript],
135
- forfeit: [this.offchainTapscript.forfeitScript],
136
- },
137
- },
138
- boarding: {
139
- address: boardingAddress,
140
- scripts: {
141
- exit: [this.boardingTapscript.exitScript],
142
- forfeit: [this.boardingTapscript.forfeitScript],
143
- },
144
- },
145
- });
112
+ async getBoardingAddress() {
113
+ return this.boardingTapscript.onchainAddress(this.network);
146
114
  }
147
115
  async getBalance() {
148
- // Get onchain coins
149
- const coins = await this.getCoins();
150
- const onchainConfirmed = coins
151
- .filter((coin) => coin.status.confirmed)
116
+ const [boardingUtxos, vtxos] = await Promise.all([
117
+ this.getBoardingUtxos(),
118
+ this.getVtxos(),
119
+ ]);
120
+ // boarding
121
+ let confirmed = 0;
122
+ let unconfirmed = 0;
123
+ for (const utxo of boardingUtxos) {
124
+ if (utxo.status.confirmed) {
125
+ confirmed += utxo.value;
126
+ }
127
+ else {
128
+ unconfirmed += utxo.value;
129
+ }
130
+ }
131
+ // offchain
132
+ let settled = 0;
133
+ let preconfirmed = 0;
134
+ let recoverable = 0;
135
+ settled = vtxos
136
+ .filter((coin) => coin.virtualStatus.state === "settled")
152
137
  .reduce((sum, coin) => sum + coin.value, 0);
153
- const onchainUnconfirmed = coins
154
- .filter((coin) => !coin.status.confirmed)
138
+ preconfirmed = vtxos
139
+ .filter((coin) => coin.virtualStatus.state === "preconfirmed")
155
140
  .reduce((sum, coin) => sum + coin.value, 0);
156
- const onchainTotal = onchainConfirmed + onchainUnconfirmed;
157
- // Get offchain coins if Ark provider is configured
158
- let offchainSettled = 0;
159
- let offchainPending = 0;
160
- let offchainSwept = 0;
161
- if (this.arkProvider) {
162
- const vtxos = await this.getVirtualCoins();
163
- offchainSettled = vtxos
164
- .filter((coin) => coin.virtualStatus.state === "settled")
165
- .reduce((sum, coin) => sum + coin.value, 0);
166
- offchainPending = vtxos
167
- .filter((coin) => coin.virtualStatus.state === "pending")
168
- .reduce((sum, coin) => sum + coin.value, 0);
169
- offchainSwept = vtxos
170
- .filter((coin) => coin.virtualStatus.state === "swept")
171
- .reduce((sum, coin) => sum + coin.value, 0);
172
- }
173
- const offchainTotal = offchainSettled + offchainPending;
141
+ recoverable = vtxos
142
+ .filter((coin) => isSpendable(coin) && coin.virtualStatus.state === "swept")
143
+ .reduce((sum, coin) => sum + coin.value, 0);
144
+ const totalBoarding = confirmed + unconfirmed;
145
+ const totalOffchain = settled + preconfirmed + recoverable;
174
146
  return {
175
- onchain: {
176
- confirmed: onchainConfirmed,
177
- unconfirmed: onchainUnconfirmed,
178
- total: onchainTotal,
179
- },
180
- offchain: {
181
- swept: offchainSwept,
182
- settled: offchainSettled,
183
- pending: offchainPending,
184
- total: offchainTotal,
147
+ boarding: {
148
+ confirmed,
149
+ unconfirmed,
150
+ total: totalBoarding,
185
151
  },
186
- total: onchainTotal + offchainTotal,
152
+ settled,
153
+ preconfirmed,
154
+ available: settled + preconfirmed,
155
+ recoverable,
156
+ total: totalBoarding + totalOffchain,
187
157
  };
188
158
  }
189
- async getCoins() {
190
- // TODO: add caching logic to lower the number of requests to provider
191
- const address = await this.getAddress();
192
- return this.onchainProvider.getCoins(address.onchain);
193
- }
194
- async getVtxos() {
195
- if (!this.arkProvider || !this.offchainTapscript) {
196
- return [];
197
- }
198
- const address = await this.getAddress();
199
- if (!address.offchain) {
200
- return [];
201
- }
202
- const { spendableVtxos } = await this.arkProvider.getVirtualCoins(address.offchain);
159
+ async getVtxos(filter) {
160
+ const spendableVtxos = await this.getVirtualCoins(filter);
203
161
  const encodedOffchainTapscript = this.offchainTapscript.encode();
204
162
  const forfeit = this.offchainTapscript.forfeit();
163
+ const exit = this.offchainTapscript.exit();
205
164
  return spendableVtxos.map((vtxo) => ({
206
165
  ...vtxo,
207
- tapLeafScript: forfeit,
208
- scripts: encodedOffchainTapscript,
166
+ forfeitTapLeafScript: forfeit,
167
+ intentTapLeafScript: exit,
168
+ tapTree: encodedOffchainTapscript,
209
169
  }));
210
170
  }
211
- async getVirtualCoins() {
212
- if (!this.arkProvider) {
213
- return [];
171
+ async getVirtualCoins(filter = { withRecoverable: true, withUnrolled: false }) {
172
+ const scripts = [hex.encode(this.offchainTapscript.pkScript)];
173
+ const response = await this.indexerProvider.getVtxos({
174
+ scripts,
175
+ spendableOnly: true,
176
+ });
177
+ const vtxos = response.vtxos;
178
+ if (filter.withRecoverable) {
179
+ const response = await this.indexerProvider.getVtxos({
180
+ scripts,
181
+ recoverableOnly: true,
182
+ });
183
+ vtxos.push(...response.vtxos);
214
184
  }
215
- const address = await this.getAddress();
216
- if (!address.offchain) {
217
- return [];
185
+ if (filter.withUnrolled) {
186
+ const response = await this.indexerProvider.getVtxos({
187
+ scripts,
188
+ spentOnly: true,
189
+ });
190
+ vtxos.push(...response.vtxos.filter((vtxo) => vtxo.isUnrolled));
218
191
  }
219
- return this.arkProvider
220
- .getVirtualCoins(address.offchain)
221
- .then(({ spendableVtxos }) => spendableVtxos);
192
+ return vtxos;
222
193
  }
223
194
  async getTransactionHistory() {
224
- if (!this.arkProvider) {
195
+ if (!this.indexerProvider) {
225
196
  return [];
226
197
  }
227
- const { spendableVtxos, spentVtxos } = await this.arkProvider.getVirtualCoins(this.offchainAddress.encode());
228
- const { boardingTxs, roundsToIgnore } = await this.getBoardingTxs();
198
+ const response = await this.indexerProvider.getVtxos({
199
+ scripts: [hex.encode(this.offchainTapscript.pkScript)],
200
+ });
201
+ const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
202
+ const spendableVtxos = [];
203
+ const spentVtxos = [];
204
+ for (const vtxo of response.vtxos) {
205
+ if (isSpendable(vtxo)) {
206
+ spendableVtxos.push(vtxo);
207
+ }
208
+ else {
209
+ spentVtxos.push(vtxo);
210
+ }
211
+ }
229
212
  // convert VTXOs to offchain transactions
230
- const offchainTxs = vtxosToTxs(spendableVtxos, spentVtxos, roundsToIgnore);
213
+ const offchainTxs = vtxosToTxs(spendableVtxos, spentVtxos, commitmentsToIgnore);
231
214
  const txs = [...boardingTxs, ...offchainTxs];
232
215
  // sort transactions by creation time in descending order (newest first)
233
216
  txs.sort(
@@ -242,13 +225,10 @@ export class Wallet {
242
225
  return txs;
243
226
  }
244
227
  async getBoardingTxs() {
245
- if (!this.boardingAddress) {
246
- return { boardingTxs: [], roundsToIgnore: new Set() };
247
- }
248
- const boardingAddress = this.boardingOnchainAddress;
228
+ const boardingAddress = await this.getBoardingAddress();
249
229
  const txs = await this.onchainProvider.getTransactions(boardingAddress);
250
230
  const utxos = [];
251
- const roundsToIgnore = new Set();
231
+ const commitmentsToIgnore = new Set();
252
232
  for (const tx of txs) {
253
233
  for (let i = 0; i < tx.vout.length; i++) {
254
234
  const vout = tx.vout[i];
@@ -256,7 +236,7 @@ export class Wallet {
256
236
  const spentStatuses = await this.onchainProvider.getTxOutspends(tx.txid);
257
237
  const spentStatus = spentStatuses[i];
258
238
  if (spentStatus?.spent) {
259
- roundsToIgnore.add(spentStatus.txid);
239
+ commitmentsToIgnore.add(spentStatus.txid);
260
240
  }
261
241
  utxos.push({
262
242
  txid: tx.txid,
@@ -266,10 +246,11 @@ export class Wallet {
266
246
  confirmed: tx.status.confirmed,
267
247
  block_time: tx.status.block_time,
268
248
  },
249
+ isUnrolled: true,
269
250
  virtualStatus: {
270
- state: spentStatus?.spent ? "swept" : "pending",
271
- batchTxID: spentStatus?.spent
272
- ? spentStatus.txid
251
+ state: spentStatus?.spent ? "spent" : "settled",
252
+ commitmentTxIds: spentStatus?.spent
253
+ ? [spentStatus.txid]
273
254
  : undefined,
274
255
  },
275
256
  createdAt: tx.status.confirmed
@@ -285,12 +266,12 @@ export class Wallet {
285
266
  const tx = {
286
267
  key: {
287
268
  boardingTxid: utxo.txid,
288
- roundTxid: "",
289
- redeemTxid: "",
269
+ commitmentTxid: "",
270
+ arkTxid: "",
290
271
  },
291
272
  amount: utxo.value,
292
273
  type: TxType.TxReceived,
293
- settled: utxo.virtualStatus.state === "swept",
274
+ settled: utxo.virtualStatus.state === "spent",
294
275
  createdAt: utxo.status.block_time
295
276
  ? new Date(utxo.status.block_time * 1000).getTime()
296
277
  : 0,
@@ -304,132 +285,80 @@ export class Wallet {
304
285
  }
305
286
  return {
306
287
  boardingTxs: [...unconfirmedTxs, ...confirmedTxs],
307
- roundsToIgnore,
288
+ commitmentsToIgnore,
308
289
  };
309
290
  }
310
291
  async getBoardingUtxos() {
311
- if (!this.boardingAddress || !this.boardingTapscript) {
312
- throw new Error("Boarding address not configured");
313
- }
314
- const boardingUtxos = await this.onchainProvider.getCoins(this.boardingOnchainAddress);
292
+ const boardingAddress = await this.getBoardingAddress();
293
+ const boardingUtxos = await this.onchainProvider.getCoins(boardingAddress);
315
294
  const encodedBoardingTapscript = this.boardingTapscript.encode();
316
295
  const forfeit = this.boardingTapscript.forfeit();
296
+ const exit = this.boardingTapscript.exit();
317
297
  return boardingUtxos.map((utxo) => ({
318
298
  ...utxo,
319
- tapLeafScript: forfeit,
320
- scripts: encodedBoardingTapscript,
299
+ forfeitTapLeafScript: forfeit,
300
+ intentTapLeafScript: exit,
301
+ tapTree: encodedBoardingTapscript,
321
302
  }));
322
303
  }
323
- async sendBitcoin(params, zeroFee = true) {
304
+ async sendBitcoin(params) {
324
305
  if (params.amount <= 0) {
325
306
  throw new Error("Amount must be positive");
326
307
  }
327
- if (params.amount < Wallet.DUST_AMOUNT) {
328
- throw new Error("Amount is below dust limit");
329
- }
330
- // If Ark is configured and amount is suitable, send via offchain
331
- if (this.arkProvider && this.isOffchainSuitable(params.address)) {
332
- return this.sendOffchain(params, zeroFee);
333
- }
334
- // Otherwise, send via onchain
335
- return this.sendOnchain(params);
336
- }
337
- isOffchainSuitable(address) {
338
- try {
339
- ArkAddress.decode(address);
340
- return true;
341
- }
342
- catch (e) {
343
- return false;
344
- }
345
- }
346
- async sendOnchain(params) {
347
- const coins = await this.getCoins();
348
- const feeRate = params.feeRate || Wallet.FEE_RATE;
349
- // Ensure fee is an integer by rounding up
350
- const estimatedFee = Math.ceil(174 * feeRate);
351
- const totalNeeded = params.amount + estimatedFee;
352
- // Select coins
353
- const selected = selectCoins(coins, totalNeeded);
354
- if (!selected.inputs) {
355
- throw new Error("Insufficient funds");
356
- }
357
- // Create transaction
358
- let tx = new Transaction();
359
- // Add inputs
360
- for (const input of selected.inputs) {
361
- tx.addInput({
362
- txid: input.txid,
363
- index: input.vout,
364
- witnessUtxo: {
365
- script: this.onchainP2TR.script,
366
- amount: BigInt(input.value),
367
- },
368
- tapInternalKey: this.onchainP2TR.tapInternalKey,
369
- });
370
- }
371
- // Add payment output
372
- tx.addOutputAddress(params.address, BigInt(params.amount), this.network);
373
- // Add change output if needed
374
- if (selected.changeAmount > 0) {
375
- tx.addOutputAddress(this.onchainAddress, BigInt(selected.changeAmount), this.network);
376
- }
377
- // Sign inputs and Finalize
378
- tx = await this.identity.sign(tx);
379
- tx.finalize();
380
- // Broadcast
381
- const txid = await this.onchainProvider.broadcastTransaction(tx.hex);
382
- return txid;
383
- }
384
- async sendOffchain(params, zeroFee = true) {
385
- if (!this.arkProvider ||
386
- !this.offchainAddress ||
387
- !this.offchainTapscript) {
388
- throw new Error("wallet not initialized");
389
- }
390
- const virtualCoins = await this.getVirtualCoins();
391
- const estimatedFee = zeroFee
392
- ? 0
393
- : Math.ceil(174 * (params.feeRate || Wallet.FEE_RATE));
394
- const totalNeeded = params.amount + estimatedFee;
395
- const selected = selectVirtualCoins(virtualCoins, totalNeeded);
396
- if (!selected || !selected.inputs) {
397
- throw new Error("Insufficient funds");
308
+ if (!isValidArkAddress(params.address)) {
309
+ throw new Error("Invalid Ark address " + params.address);
398
310
  }
311
+ // recoverable and subdust coins can't be spent in offchain tx
312
+ const virtualCoins = await this.getVirtualCoins({
313
+ withRecoverable: false,
314
+ });
315
+ const selected = selectVirtualCoins(virtualCoins, params.amount);
399
316
  const selectedLeaf = this.offchainTapscript.forfeit();
400
317
  if (!selectedLeaf) {
401
318
  throw new Error("Selected leaf not found");
402
319
  }
320
+ const outputAddress = ArkAddress.decode(params.address);
321
+ const outputScript = BigInt(params.amount) < this.dustAmount
322
+ ? outputAddress.subdustPkScript
323
+ : outputAddress.pkScript;
403
324
  const outputs = [
404
325
  {
405
- address: params.address,
326
+ script: outputScript,
406
327
  amount: BigInt(params.amount),
407
328
  },
408
329
  ];
409
330
  // add change output if needed
410
- if (selected.changeAmount > 0) {
331
+ if (selected.changeAmount > 0n) {
332
+ const changeOutputScript = selected.changeAmount < this.dustAmount
333
+ ? this.arkAddress.subdustPkScript
334
+ : this.arkAddress.pkScript;
411
335
  outputs.push({
412
- address: this.offchainAddress.encode(),
336
+ script: changeOutputScript,
413
337
  amount: BigInt(selected.changeAmount),
414
338
  });
415
339
  }
416
- const scripts = this.offchainTapscript.encode();
417
- let tx = createVirtualTx(selected.inputs.map((input) => ({
340
+ const tapTree = this.offchainTapscript.encode();
341
+ let offchainTx = buildOffchainTx(selected.inputs.map((input) => ({
418
342
  ...input,
419
343
  tapLeafScript: selectedLeaf,
420
- scripts,
421
- })), outputs);
422
- tx = await this.identity.sign(tx);
423
- const psbt = base64.encode(tx.toPSBT());
424
- return this.arkProvider.submitVirtualTx(psbt);
344
+ tapTree,
345
+ })), outputs, this.serverUnrollScript);
346
+ const signedVirtualTx = await this.identity.sign(offchainTx.arkTx);
347
+ const { arkTxid, signedCheckpointTxs } = await this.arkProvider.submitTx(base64.encode(signedVirtualTx.toPSBT()), offchainTx.checkpoints.map((c) => base64.encode(c.toPSBT())));
348
+ // TODO persist final virtual tx and checkpoints to repository
349
+ // sign the checkpoints
350
+ const finalCheckpoints = await Promise.all(signedCheckpointTxs.map(async (c) => {
351
+ const tx = Transaction.fromPSBT(base64.decode(c));
352
+ const signedCheckpoint = await this.identity.sign(tx);
353
+ return base64.encode(signedCheckpoint.toPSBT());
354
+ }));
355
+ await this.arkProvider.finalizeTx(arkTxid, finalCheckpoints);
356
+ return arkTxid;
425
357
  }
426
358
  async settle(params, eventCallback) {
427
- if (!this.arkProvider) {
428
- throw new Error("Ark provider not configured");
429
- }
430
- // validate arknotes inputs
431
359
  if (params?.inputs) {
432
360
  for (const input of params.inputs) {
361
+ // validate arknotes inputs
433
362
  if (typeof input === "string") {
434
363
  try {
435
364
  ArkNote.fromString(input);
@@ -443,9 +372,6 @@ export class Wallet {
443
372
  // if no params are provided, use all boarding and offchain utxos as inputs
444
373
  // and send all to the offchain address
445
374
  if (!params) {
446
- if (!this.offchainAddress) {
447
- throw new Error("Offchain address not configured");
448
- }
449
375
  let amount = 0;
450
376
  const boardingUtxos = await this.getBoardingUtxos();
451
377
  amount += boardingUtxos.reduce((sum, input) => sum + input.value, 0);
@@ -459,23 +385,34 @@ export class Wallet {
459
385
  inputs,
460
386
  outputs: [
461
387
  {
462
- address: this.offchainAddress.encode(),
388
+ address: await this.getAddress(),
463
389
  amount: BigInt(amount),
464
390
  },
465
391
  ],
466
392
  };
467
393
  }
468
- // register inputs
469
- const { requestId } = await this.arkProvider.registerInputsForNextRound(params.inputs.map((input) => {
470
- if (typeof input === "string") {
471
- return input;
394
+ const onchainOutputIndexes = [];
395
+ const outputs = [];
396
+ let hasOffchainOutputs = false;
397
+ for (const [index, output] of params.outputs.entries()) {
398
+ let script;
399
+ try {
400
+ // offchain
401
+ const addr = ArkAddress.decode(output.address);
402
+ script = addr.pkScript;
403
+ hasOffchainOutputs = true;
472
404
  }
473
- return {
474
- outpoint: input,
475
- tapscripts: input.scripts,
476
- };
477
- }));
478
- const hasOffchainOutputs = params.outputs.some((output) => this.isOffchainSuitable(output.address));
405
+ catch {
406
+ // onchain
407
+ const addr = Address(this.network).decode(output.address);
408
+ script = OutScript.encode(addr);
409
+ onchainOutputIndexes.push(index);
410
+ }
411
+ outputs.push({
412
+ amount: output.amount,
413
+ script,
414
+ });
415
+ }
479
416
  // session holds the state of the musig2 signing process of the vtxo tree
480
417
  let session;
481
418
  const signingPublicKeys = [];
@@ -483,189 +420,287 @@ export class Wallet {
483
420
  session = this.identity.signerSession();
484
421
  signingPublicKeys.push(hex.encode(session.getPublicKey()));
485
422
  }
486
- // register outputs
487
- await this.arkProvider.registerOutputsForNextRound(requestId, params.outputs, signingPublicKeys);
488
- // start pinging every seconds
489
- const interval = setInterval(() => {
490
- this.arkProvider?.ping(requestId).catch(stopPing);
491
- }, 1000);
492
- let pingRunning = true;
493
- const stopPing = () => {
494
- if (pingRunning) {
495
- pingRunning = false;
496
- clearInterval(interval);
497
- }
498
- };
423
+ const [intent, deleteIntent] = await Promise.all([
424
+ this.makeRegisterIntentSignature(params.inputs, outputs, onchainOutputIndexes, signingPublicKeys),
425
+ this.makeDeleteIntentSignature(params.inputs),
426
+ ]);
427
+ const intentId = await this.arkProvider.registerIntent(intent);
499
428
  const abortController = new AbortController();
500
429
  // listen to settlement events
501
430
  try {
502
- const settlementStream = this.arkProvider.getEventStream(abortController.signal);
503
431
  let step;
504
- if (!hasOffchainOutputs) {
505
- // if there are no offchain outputs, we don't have to handle musig2 tree signatures
506
- // we can directly advance to the finalization step
507
- step = SettlementEventType.SigningNoncesGenerated;
508
- }
509
- const info = await this.arkProvider.getInfo();
510
- const sweepTapscript = CSVMultisigTapscript.encode({
511
- timelock: {
512
- value: info.batchExpiry,
513
- type: info.batchExpiry >= 512n ? "seconds" : "blocks",
514
- },
515
- pubkeys: [hex.decode(info.pubkey).slice(1)],
516
- }).script;
517
- const sweepTapTreeRoot = tapLeafHash(sweepTapscript);
432
+ const topics = [
433
+ ...signingPublicKeys,
434
+ ...params.inputs.map((input) => `${input.txid}:${input.vout}`),
435
+ ];
436
+ const settlementStream = this.arkProvider.getEventStream(abortController.signal, topics);
437
+ // roundId, sweepTapTreeRoot and forfeitOutputScript are set once the BatchStarted event is received
438
+ let roundId;
439
+ let sweepTapTreeRoot;
440
+ const vtxoChunks = [];
441
+ const connectorsChunks = [];
442
+ let vtxoGraph;
443
+ let connectorsGraph;
518
444
  for await (const event of settlementStream) {
519
445
  if (eventCallback) {
520
446
  eventCallback(event);
521
447
  }
522
448
  switch (event.type) {
523
449
  // the settlement failed
524
- case SettlementEventType.Failed:
525
- if (step === undefined) {
450
+ case SettlementEventType.BatchFailed:
451
+ // fail if the roundId is the one joined
452
+ if (event.id === roundId) {
453
+ throw new Error(event.reason);
454
+ }
455
+ break;
456
+ case SettlementEventType.BatchStarted:
457
+ if (step !== undefined) {
458
+ continue;
459
+ }
460
+ const res = await this.handleBatchStartedEvent(event, intentId, this.arkServerPublicKey, this.forfeitOutputScript);
461
+ if (!res.skip) {
462
+ step = event.type;
463
+ sweepTapTreeRoot = res.sweepTapTreeRoot;
464
+ roundId = res.roundId;
465
+ if (!hasOffchainOutputs) {
466
+ // if there are no offchain outputs, we don't have to handle musig2 tree signatures
467
+ // we can directly advance to the finalization step
468
+ step = SettlementEventType.TreeNoncesAggregated;
469
+ }
470
+ }
471
+ break;
472
+ case SettlementEventType.TreeTx:
473
+ if (step !== SettlementEventType.BatchStarted &&
474
+ step !== SettlementEventType.TreeNoncesAggregated) {
475
+ continue;
476
+ }
477
+ // index 0 = vtxo tree
478
+ if (event.batchIndex === 0) {
479
+ vtxoChunks.push(event.chunk);
480
+ // index 1 = connectors tree
481
+ }
482
+ else if (event.batchIndex === 1) {
483
+ connectorsChunks.push(event.chunk);
484
+ }
485
+ else {
486
+ throw new Error(`Invalid batch index: ${event.batchIndex}`);
487
+ }
488
+ break;
489
+ case SettlementEventType.TreeSignature:
490
+ if (step !== SettlementEventType.TreeNoncesAggregated) {
526
491
  continue;
527
492
  }
528
- stopPing();
529
- throw new Error(event.reason);
493
+ if (!hasOffchainOutputs) {
494
+ continue;
495
+ }
496
+ if (!vtxoGraph) {
497
+ throw new Error("Vtxo graph not set, something went wrong");
498
+ }
499
+ // index 0 = vtxo graph
500
+ if (event.batchIndex === 0) {
501
+ const tapKeySig = hex.decode(event.signature);
502
+ vtxoGraph.update(event.txid, (tx) => {
503
+ tx.updateInput(0, {
504
+ tapKeySig,
505
+ });
506
+ });
507
+ }
508
+ break;
530
509
  // the server has started the signing process of the vtxo tree transactions
531
510
  // the server expects the partial musig2 nonces for each tx
532
- case SettlementEventType.SigningStart:
533
- if (step !== undefined) {
511
+ case SettlementEventType.TreeSigningStarted:
512
+ if (step !== SettlementEventType.BatchStarted) {
534
513
  continue;
535
514
  }
536
- stopPing();
537
515
  if (hasOffchainOutputs) {
538
516
  if (!session) {
539
- throw new Error("Signing session not found");
517
+ throw new Error("Signing session not set");
540
518
  }
541
- await this.handleSettlementSigningEvent(event, sweepTapTreeRoot, session);
519
+ if (!sweepTapTreeRoot) {
520
+ throw new Error("Sweep tap tree root not set");
521
+ }
522
+ if (vtxoChunks.length === 0) {
523
+ throw new Error("unsigned vtxo graph not received");
524
+ }
525
+ vtxoGraph = TxTree.create(vtxoChunks);
526
+ await this.handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph);
542
527
  }
528
+ step = event.type;
543
529
  break;
544
530
  // the musig2 nonces of the vtxo tree transactions are generated
545
531
  // the server expects now the partial musig2 signatures
546
- case SettlementEventType.SigningNoncesGenerated:
547
- if (step !== SettlementEventType.SigningStart) {
532
+ case SettlementEventType.TreeNoncesAggregated:
533
+ if (step !== SettlementEventType.TreeSigningStarted) {
548
534
  continue;
549
535
  }
550
- stopPing();
551
536
  if (hasOffchainOutputs) {
552
537
  if (!session) {
553
- throw new Error("Signing session not found");
538
+ throw new Error("Signing session not set");
554
539
  }
555
540
  await this.handleSettlementSigningNoncesGeneratedEvent(event, session);
556
541
  }
542
+ step = event.type;
557
543
  break;
558
544
  // the vtxo tree is signed, craft, sign and submit forfeit transactions
559
545
  // if any boarding utxos are involved, the settlement tx is also signed
560
- case SettlementEventType.Finalization:
561
- if (step !== SettlementEventType.SigningNoncesGenerated) {
546
+ case SettlementEventType.BatchFinalization:
547
+ if (step !== SettlementEventType.TreeNoncesAggregated) {
562
548
  continue;
563
549
  }
564
- stopPing();
565
- await this.handleSettlementFinalizationEvent(event, params.inputs, info);
550
+ if (!this.forfeitOutputScript) {
551
+ throw new Error("Forfeit output script not set");
552
+ }
553
+ if (connectorsChunks.length > 0) {
554
+ connectorsGraph = TxTree.create(connectorsChunks);
555
+ validateConnectorsTxGraph(event.commitmentTx, connectorsGraph);
556
+ }
557
+ await this.handleSettlementFinalizationEvent(event, params.inputs, this.forfeitOutputScript, connectorsGraph);
558
+ step = event.type;
566
559
  break;
567
560
  // the settlement is done, last event to be received
568
- case SettlementEventType.Finalized:
569
- if (step !== SettlementEventType.Finalization) {
561
+ case SettlementEventType.BatchFinalized:
562
+ if (step !== SettlementEventType.BatchFinalization) {
570
563
  continue;
571
564
  }
572
565
  abortController.abort();
573
- return event.roundTxid;
566
+ return event.commitmentTxid;
574
567
  }
575
- step = event.type;
576
568
  }
577
569
  }
578
570
  catch (error) {
571
+ // close the stream
579
572
  abortController.abort();
573
+ try {
574
+ // delete the intent to not be stuck in the queue
575
+ await this.arkProvider.deleteIntent(deleteIntent);
576
+ }
577
+ catch { }
580
578
  throw error;
581
579
  }
582
580
  throw new Error("Settlement failed");
583
581
  }
584
- async exit(outpoints) {
585
- // TODO store the exit branches in repository
586
- // exit should not depend on the ark provider
587
- if (!this.arkProvider) {
588
- throw new Error("Ark provider not configured");
589
- }
590
- let vtxos = await this.getVtxos();
591
- if (outpoints && outpoints.length > 0) {
592
- vtxos = vtxos.filter((vtxo) => outpoints.some((outpoint) => vtxo.txid === outpoint.txid &&
593
- vtxo.vout === outpoint.vout));
582
+ async notifyIncomingFunds(eventCallback) {
583
+ const arkAddress = await this.getAddress();
584
+ const boardingAddress = await this.getBoardingAddress();
585
+ let onchainStopFunc;
586
+ let indexerStopFunc;
587
+ if (this.onchainProvider && boardingAddress) {
588
+ onchainStopFunc = await this.onchainProvider.watchAddresses([boardingAddress], (txs) => {
589
+ const coins = txs
590
+ .map((tx) => {
591
+ const vout = tx.vout.findIndex((v) => v.scriptpubkey_address === boardingAddress);
592
+ if (vout === -1) {
593
+ console.warn(`No vout found for address ${boardingAddress} in transaction ${tx.txid}`);
594
+ return null;
595
+ }
596
+ return {
597
+ txid: tx.txid,
598
+ vout,
599
+ value: Number(tx.vout[vout].value),
600
+ status: tx.status,
601
+ };
602
+ })
603
+ .filter((coin) => coin !== null);
604
+ eventCallback({
605
+ type: "utxo",
606
+ coins,
607
+ });
608
+ });
594
609
  }
595
- if (vtxos.length === 0) {
596
- throw new Error("No vtxos to exit");
610
+ if (this.indexerProvider && arkAddress) {
611
+ const offchainScript = this.offchainTapscript;
612
+ const subscriptionId = await this.indexerProvider.subscribeForScripts([
613
+ hex.encode(offchainScript.pkScript),
614
+ ]);
615
+ const abortController = new AbortController();
616
+ const subscription = this.indexerProvider.getSubscription(subscriptionId, abortController.signal);
617
+ indexerStopFunc = async () => {
618
+ abortController.abort();
619
+ await this.indexerProvider?.unsubscribeForScripts(subscriptionId);
620
+ };
621
+ // Handle subscription updates asynchronously without blocking
622
+ (async () => {
623
+ try {
624
+ for await (const update of subscription) {
625
+ if (update.newVtxos?.length > 0) {
626
+ eventCallback({
627
+ type: "vtxo",
628
+ vtxos: update.newVtxos,
629
+ });
630
+ }
631
+ }
632
+ }
633
+ catch (error) {
634
+ console.error("Subscription error:", error);
635
+ }
636
+ })();
597
637
  }
598
- const trees = new Map();
599
- const transactions = [];
600
- for (const vtxo of vtxos) {
601
- const batchTxid = vtxo.virtualStatus.batchTxID;
602
- if (!batchTxid)
603
- continue;
604
- if (!trees.has(batchTxid)) {
605
- const round = await this.arkProvider.getRound(batchTxid);
606
- trees.set(batchTxid, round.vtxoTree);
607
- }
608
- const tree = trees.get(batchTxid);
609
- if (!tree) {
610
- throw new Error("Tree not found");
638
+ const stopFunc = () => {
639
+ onchainStopFunc?.();
640
+ indexerStopFunc?.();
641
+ };
642
+ return stopFunc;
643
+ }
644
+ async handleBatchStartedEvent(event, intentId, serverPubKey, forfeitOutputScript) {
645
+ const utf8IntentId = new TextEncoder().encode(intentId);
646
+ const intentIdHash = sha256(utf8IntentId);
647
+ const intentIdHashStr = hex.encode(new Uint8Array(intentIdHash));
648
+ let skip = true;
649
+ // check if our intent ID hash matches any in the event
650
+ for (const idHash of event.intentIdHashes) {
651
+ if (idHash === intentIdHashStr) {
652
+ if (!this.arkProvider) {
653
+ throw new Error("Ark provider not configured");
654
+ }
655
+ await this.arkProvider.confirmRegistration(intentId);
656
+ skip = false;
611
657
  }
612
- const exitBranch = await tree.exitBranch(vtxo.txid, async (txid) => {
613
- const status = await this.onchainProvider.getTxStatus(txid);
614
- return status.confirmed;
615
- });
616
- transactions.push(...exitBranch);
617
658
  }
618
- const broadcastedTxs = new Map();
619
- for (const tx of transactions) {
620
- if (broadcastedTxs.has(tx))
621
- continue;
622
- const txid = await this.onchainProvider.broadcastTransaction(tx);
623
- broadcastedTxs.set(txid, true);
659
+ if (skip) {
660
+ return { skip };
624
661
  }
662
+ const sweepTapscript = CSVMultisigTapscript.encode({
663
+ timelock: {
664
+ value: event.batchExpiry,
665
+ type: event.batchExpiry >= 512n ? "seconds" : "blocks",
666
+ },
667
+ pubkeys: [serverPubKey],
668
+ }).script;
669
+ const sweepTapTreeRoot = tapLeafHash(sweepTapscript);
670
+ return {
671
+ roundId: event.id,
672
+ sweepTapTreeRoot,
673
+ forfeitOutputScript,
674
+ skip: false,
675
+ };
625
676
  }
626
677
  // validates the vtxo tree, creates a signing session and generates the musig2 nonces
627
- async handleSettlementSigningEvent(event, sweepTapTreeRoot, session) {
628
- const vtxoTree = event.unsignedVtxoTree;
629
- if (!this.arkProvider) {
630
- throw new Error("Ark provider not configured");
631
- }
678
+ async handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph) {
632
679
  // validate the unsigned vtxo tree
633
- validateVtxoTree(event.unsignedSettlementTx, vtxoTree, sweepTapTreeRoot);
680
+ const commitmentTx = Transaction.fromPSBT(base64.decode(event.unsignedCommitmentTx));
681
+ validateVtxoTxGraph(vtxoGraph, commitmentTx, sweepTapTreeRoot);
634
682
  // TODO check if our registered outputs are in the vtxo tree
635
- const settlementPsbt = base64.decode(event.unsignedSettlementTx);
636
- const settlementTx = Transaction.fromPSBT(settlementPsbt);
637
- const sharedOutput = settlementTx.getOutput(0);
683
+ const sharedOutput = commitmentTx.getOutput(0);
638
684
  if (!sharedOutput?.amount) {
639
685
  throw new Error("Shared output not found");
640
686
  }
641
- session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount);
687
+ session.init(vtxoGraph, sweepTapTreeRoot, sharedOutput.amount);
642
688
  await this.arkProvider.submitTreeNonces(event.id, hex.encode(session.getPublicKey()), session.getNonces());
643
689
  }
644
690
  async handleSettlementSigningNoncesGeneratedEvent(event, session) {
645
- if (!this.arkProvider) {
646
- throw new Error("Ark provider not configured");
647
- }
648
691
  session.setAggregatedNonces(event.treeNonces);
649
692
  const signatures = session.sign();
650
693
  await this.arkProvider.submitTreeSignatures(event.id, hex.encode(session.getPublicKey()), signatures);
651
694
  }
652
- async handleSettlementFinalizationEvent(event, inputs, infos) {
653
- if (!this.arkProvider) {
654
- throw new Error("Ark provider not configured");
655
- }
656
- // parse the server forfeit address
657
- // server is expecting funds to be sent to this address
658
- const forfeitAddress = Address(this.network).decode(infos.forfeitAddress);
659
- const serverPkScript = OutScript.encode(forfeitAddress);
695
+ async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
660
696
  // the signed forfeits transactions to submit
661
697
  const signedForfeits = [];
662
698
  const vtxos = await this.getVirtualCoins();
663
- let settlementPsbt = Transaction.fromPSBT(base64.decode(event.roundTx));
699
+ let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
664
700
  let hasBoardingUtxos = false;
665
- let connectorsTreeValid = false;
701
+ let connectorIndex = 0;
702
+ const connectorsLeaves = connectorsGraph?.leaves() || [];
666
703
  for (const input of inputs) {
667
- if (typeof input === "string")
668
- continue; // skip notes
669
704
  // check if the input is an offchain "virtual" coin
670
705
  const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
671
706
  // boarding utxo, we need to sign the settlement tx
@@ -685,73 +720,246 @@ export class Wallet {
685
720
  continue;
686
721
  // input found in the settlement tx, sign it
687
722
  settlementPsbt.updateInput(i, {
688
- tapLeafScript: [input.tapLeafScript],
723
+ tapLeafScript: [input.forfeitTapLeafScript],
689
724
  });
690
725
  inputIndexes.push(i);
691
726
  }
692
727
  settlementPsbt = await this.identity.sign(settlementPsbt, inputIndexes);
693
728
  continue;
694
729
  }
695
- if (!connectorsTreeValid) {
696
- // validate that the connectors tree is valid and contains our expected connectors
697
- validateConnectorsTree(event.roundTx, event.connectors);
698
- connectorsTreeValid = true;
730
+ if (isRecoverable(vtxo) || isSubdust(vtxo, this.dustAmount)) {
731
+ // recoverable or subdust coin, we don't need to create a forfeit tx
732
+ continue;
699
733
  }
700
- const forfeitControlBlock = TaprootControlBlock.encode(input.tapLeafScript[0]);
701
- const tapscript = decodeTapscript(scriptFromTapLeafScript(input.tapLeafScript));
702
- const fees = TxWeightEstimator.create()
703
- .addKeySpendInput() // connector
704
- .addTapscriptInput(tapscript.witnessSize(100), // TODO: handle conditional script
705
- input.tapLeafScript[1].length - 1, forfeitControlBlock.length)
706
- .addP2WKHOutput()
707
- .vsize()
708
- .fee(event.minRelayFeeRate);
709
- const connectorsLeaves = event.connectors.leaves();
710
- const connectorOutpoint = event.connectorsIndex.get(`${vtxo.txid}:${vtxo.vout}`);
711
- if (!connectorOutpoint) {
712
- throw new Error("Connector outpoint not found");
734
+ if (connectorsLeaves.length === 0) {
735
+ throw new Error("connectors not received");
713
736
  }
714
- let connectorOutput;
715
- for (const leaf of connectorsLeaves) {
716
- if (leaf.txid === connectorOutpoint.txid) {
717
- try {
718
- const connectorTx = Transaction.fromPSBT(base64.decode(leaf.tx));
719
- connectorOutput = connectorTx.getOutput(connectorOutpoint.vout);
720
- break;
721
- }
722
- catch {
723
- throw new Error("Invalid connector tx");
724
- }
725
- }
737
+ if (connectorIndex >= connectorsLeaves.length) {
738
+ throw new Error("not enough connectors received");
726
739
  }
727
- if (!connectorOutput ||
728
- !connectorOutput.amount ||
729
- !connectorOutput.script) {
730
- throw new Error("Connector output not found");
740
+ const connectorLeaf = connectorsLeaves[connectorIndex];
741
+ const connectorTxId = hex.encode(sha256x2(connectorLeaf.toBytes(true)).reverse());
742
+ const connectorOutput = connectorLeaf.getOutput(0);
743
+ if (!connectorOutput) {
744
+ throw new Error("connector output not found");
731
745
  }
732
- let forfeitTx = buildForfeitTx({
733
- connectorInput: connectorOutpoint,
734
- connectorAmount: connectorOutput.amount,
735
- feeAmount: fees,
736
- serverPkScript,
737
- connectorPkScript: connectorOutput.script,
738
- vtxoAmount: BigInt(vtxo.value),
739
- vtxoInput: input,
740
- vtxoPkScript: VtxoScript.decode(input.scripts).pkScript,
741
- });
742
- // add the tapscript
743
- forfeitTx.updateInput(1, {
744
- tapLeafScript: [input.tapLeafScript],
745
- });
746
+ const connectorAmount = connectorOutput.amount;
747
+ const connectorPkScript = connectorOutput.script;
748
+ if (!connectorAmount || !connectorPkScript) {
749
+ throw new Error("invalid connector output");
750
+ }
751
+ connectorIndex++;
752
+ let forfeitTx = buildForfeitTx([
753
+ {
754
+ txid: input.txid,
755
+ index: input.vout,
756
+ witnessUtxo: {
757
+ amount: BigInt(vtxo.value),
758
+ script: VtxoScript.decode(input.tapTree).pkScript,
759
+ },
760
+ sighashType: SigHash.DEFAULT,
761
+ tapLeafScript: [input.forfeitTapLeafScript],
762
+ },
763
+ {
764
+ txid: connectorTxId,
765
+ index: 0,
766
+ witnessUtxo: {
767
+ amount: connectorAmount,
768
+ script: connectorPkScript,
769
+ },
770
+ },
771
+ ], forfeitOutputScript);
746
772
  // do not sign the connector input
747
- forfeitTx = await this.identity.sign(forfeitTx, [1]);
773
+ forfeitTx = await this.identity.sign(forfeitTx, [0]);
748
774
  signedForfeits.push(base64.encode(forfeitTx.toPSBT()));
749
775
  }
750
- await this.arkProvider.submitSignedForfeitTxs(signedForfeits, hasBoardingUtxos
751
- ? base64.encode(settlementPsbt.toPSBT())
752
- : undefined);
776
+ if (signedForfeits.length > 0 || hasBoardingUtxos) {
777
+ await this.arkProvider.submitSignedForfeitTxs(signedForfeits, hasBoardingUtxos
778
+ ? base64.encode(settlementPsbt.toPSBT())
779
+ : undefined);
780
+ }
781
+ }
782
+ async makeRegisterIntentSignature(bip322Inputs, outputs, onchainOutputsIndexes, cosignerPubKeys) {
783
+ const nowSeconds = Math.floor(Date.now() / 1000);
784
+ const { inputs, inputTapTrees, finalizer } = this.prepareBIP322Inputs(bip322Inputs);
785
+ const message = {
786
+ type: "register",
787
+ input_tap_trees: inputTapTrees,
788
+ onchain_output_indexes: onchainOutputsIndexes,
789
+ valid_at: nowSeconds,
790
+ expire_at: nowSeconds + 2 * 60, // valid for 2 minutes
791
+ cosigners_public_keys: cosignerPubKeys,
792
+ };
793
+ const encodedMessage = JSON.stringify(message, null, 0);
794
+ const signature = await this.makeBIP322Signature(encodedMessage, inputs, finalizer, outputs);
795
+ return {
796
+ signature,
797
+ message: encodedMessage,
798
+ };
799
+ }
800
+ async makeDeleteIntentSignature(bip322Inputs) {
801
+ const nowSeconds = Math.floor(Date.now() / 1000);
802
+ const { inputs, finalizer } = this.prepareBIP322Inputs(bip322Inputs);
803
+ const message = {
804
+ type: "delete",
805
+ expire_at: nowSeconds + 2 * 60, // valid for 2 minutes
806
+ };
807
+ const encodedMessage = JSON.stringify(message, null, 0);
808
+ const signature = await this.makeBIP322Signature(encodedMessage, inputs, finalizer);
809
+ return {
810
+ signature,
811
+ message: encodedMessage,
812
+ };
813
+ }
814
+ prepareBIP322Inputs(bip322Inputs) {
815
+ const inputs = [];
816
+ const inputTapTrees = [];
817
+ const inputExtraWitnesses = [];
818
+ for (const bip322Input of bip322Inputs) {
819
+ const vtxoScript = VtxoScript.decode(bip322Input.tapTree);
820
+ const sequence = getSequence(bip322Input);
821
+ inputs.push({
822
+ txid: hex.decode(bip322Input.txid),
823
+ index: bip322Input.vout,
824
+ witnessUtxo: {
825
+ amount: BigInt(bip322Input.value),
826
+ script: vtxoScript.pkScript,
827
+ },
828
+ sequence,
829
+ tapLeafScript: [bip322Input.intentTapLeafScript],
830
+ });
831
+ inputTapTrees.push(hex.encode(bip322Input.tapTree));
832
+ inputExtraWitnesses.push(bip322Input.extraWitness || []);
833
+ }
834
+ return {
835
+ inputs,
836
+ inputTapTrees,
837
+ finalizer: finalizeWithExtraWitnesses(inputExtraWitnesses),
838
+ };
753
839
  }
840
+ async makeBIP322Signature(message, inputs, finalizer, outputs) {
841
+ const proof = BIP322.create(message, inputs, outputs);
842
+ const signedProof = await this.identity.sign(proof);
843
+ return BIP322.signature(signedProof, finalizer);
844
+ }
845
+ }
846
+ Wallet.MIN_FEE_RATE = 1; // sats/vbyte
847
+ function finalizeWithExtraWitnesses(inputExtraWitnesses) {
848
+ return function (tx) {
849
+ for (let i = 0; i < tx.inputsLength; i++) {
850
+ try {
851
+ tx.finalizeIdx(i);
852
+ }
853
+ catch (e) {
854
+ // handle empty witness error
855
+ if (e instanceof Error &&
856
+ e.message.includes("finalize/taproot: empty witness")) {
857
+ const tapLeaves = tx.getInput(i).tapLeafScript;
858
+ if (!tapLeaves || tapLeaves.length <= 0)
859
+ throw e;
860
+ const [cb, s] = tapLeaves[0];
861
+ const script = s.slice(0, -1);
862
+ tx.updateInput(i, {
863
+ finalScriptWitness: [
864
+ script,
865
+ TaprootControlBlock.encode(cb),
866
+ ],
867
+ });
868
+ }
869
+ }
870
+ const finalScriptWitness = tx.getInput(i).finalScriptWitness;
871
+ if (!finalScriptWitness)
872
+ throw new Error("input not finalized");
873
+ // input 0 and 1 spend the same pkscript
874
+ const extra = inputExtraWitnesses[i === 0 ? 0 : i - 1];
875
+ if (extra && extra.length > 0) {
876
+ tx.updateInput(i, {
877
+ finalScriptWitness: [...extra, ...finalScriptWitness],
878
+ });
879
+ }
880
+ }
881
+ };
882
+ }
883
+ function getSequence(bip322Input) {
884
+ let sequence = undefined;
885
+ try {
886
+ const scriptWithLeafVersion = bip322Input.intentTapLeafScript[1];
887
+ const script = scriptWithLeafVersion.subarray(0, scriptWithLeafVersion.length - 1);
888
+ const params = CSVMultisigTapscript.decode(script).params;
889
+ sequence = bip68.encode(params.timelock.type === "blocks"
890
+ ? { blocks: Number(params.timelock.value) }
891
+ : { seconds: Number(params.timelock.value) });
892
+ }
893
+ catch { }
894
+ return sequence;
895
+ }
896
+ function isValidArkAddress(address) {
897
+ try {
898
+ ArkAddress.decode(address);
899
+ return true;
900
+ }
901
+ catch (e) {
902
+ return false;
903
+ }
904
+ }
905
+ /**
906
+ * Select virtual coins to reach a target amount, prioritizing those closer to expiry
907
+ * @param coins List of virtual coins to select from
908
+ * @param targetAmount Target amount to reach in satoshis
909
+ * @returns Selected coins and change amount
910
+ */
911
+ function selectVirtualCoins(coins, targetAmount) {
912
+ // Sort VTXOs by expiry (ascending) and amount (descending)
913
+ const sortedCoins = [...coins].sort((a, b) => {
914
+ // First sort by expiry if available
915
+ const expiryA = a.virtualStatus.batchExpiry || Number.MAX_SAFE_INTEGER;
916
+ const expiryB = b.virtualStatus.batchExpiry || Number.MAX_SAFE_INTEGER;
917
+ if (expiryA !== expiryB) {
918
+ return expiryA - expiryB; // Earlier expiry first
919
+ }
920
+ // Then sort by amount
921
+ return b.value - a.value; // Larger amount first
922
+ });
923
+ const selectedCoins = [];
924
+ let selectedAmount = 0;
925
+ // Select coins until we have enough
926
+ for (const coin of sortedCoins) {
927
+ selectedCoins.push(coin);
928
+ selectedAmount += coin.value;
929
+ if (selectedAmount >= targetAmount) {
930
+ break;
931
+ }
932
+ }
933
+ if (selectedAmount === targetAmount) {
934
+ return { inputs: selectedCoins, changeAmount: 0n };
935
+ }
936
+ // Check if we have enough
937
+ if (selectedAmount < targetAmount) {
938
+ throw new Error("Insufficient funds");
939
+ }
940
+ const changeAmount = BigInt(selectedAmount - targetAmount);
941
+ return {
942
+ inputs: selectedCoins,
943
+ changeAmount,
944
+ };
945
+ }
946
+ /**
947
+ * Wait for incoming funds to the wallet
948
+ * @param wallet - The wallet to wait for incoming funds
949
+ * @returns A promise that resolves the next new coins received by the wallet's address
950
+ */
951
+ export async function waitForIncomingFunds(wallet) {
952
+ let stopFunc;
953
+ const promise = new Promise((resolve) => {
954
+ wallet
955
+ .notifyIncomingFunds((coins) => {
956
+ resolve(coins);
957
+ if (stopFunc)
958
+ stopFunc();
959
+ })
960
+ .then((stop) => {
961
+ stopFunc = stop;
962
+ });
963
+ });
964
+ return promise;
754
965
  }
755
- // TODO get dust from ark server?
756
- Wallet.DUST_AMOUNT = BigInt(546); // Bitcoin dust limit in satoshis = 546
757
- Wallet.FEE_RATE = 1; // sats/vbyte