@arkade-os/sdk 0.1.4 → 0.2.1

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 +157 -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 +43 -17
  8. package/dist/cjs/providers/ark.js +261 -321
  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 +61 -20
  15. package/dist/cjs/script/vhtlc.js +85 -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 +61 -34
  32. package/dist/cjs/wallet/serviceWorker/worker.js +120 -108
  33. package/dist/cjs/wallet/unroll.js +270 -0
  34. package/dist/cjs/wallet/wallet.js +701 -454
  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 +31 -12
  41. package/dist/esm/providers/ark.js +259 -320
  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 +61 -20
  48. package/dist/esm/script/vhtlc.js +85 -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 +62 -35
  65. package/dist/esm/wallet/serviceWorker/worker.js +120 -108
  66. package/dist/esm/wallet/unroll.js +267 -0
  67. package/dist/esm/wallet/wallet.js +674 -461
  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 +24 -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 -25
  102. package/package.json +37 -35
  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,129 +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 Ark is configured and amount is suitable, send via offchain
328
- if (this.arkProvider && this.isOffchainSuitable(params.address)) {
329
- return this.sendOffchain(params, zeroFee);
330
- }
331
- // Otherwise, send via onchain
332
- return this.sendOnchain(params);
333
- }
334
- isOffchainSuitable(address) {
335
- try {
336
- ArkAddress.decode(address);
337
- return true;
338
- }
339
- catch (e) {
340
- return false;
341
- }
342
- }
343
- async sendOnchain(params) {
344
- const coins = await this.getCoins();
345
- const feeRate = params.feeRate || Wallet.FEE_RATE;
346
- // Ensure fee is an integer by rounding up
347
- const estimatedFee = Math.ceil(174 * feeRate);
348
- const totalNeeded = params.amount + estimatedFee;
349
- // Select coins
350
- const selected = selectCoins(coins, totalNeeded);
351
- if (!selected.inputs) {
352
- throw new Error("Insufficient funds");
353
- }
354
- // Create transaction
355
- let tx = new Transaction();
356
- // Add inputs
357
- for (const input of selected.inputs) {
358
- tx.addInput({
359
- txid: input.txid,
360
- index: input.vout,
361
- witnessUtxo: {
362
- script: this.onchainP2TR.script,
363
- amount: BigInt(input.value),
364
- },
365
- tapInternalKey: this.onchainP2TR.tapInternalKey,
366
- });
367
- }
368
- // Add payment output
369
- tx.addOutputAddress(params.address, BigInt(params.amount), this.network);
370
- // Add change output if needed
371
- if (selected.changeAmount > 0) {
372
- tx.addOutputAddress(this.onchainAddress, BigInt(selected.changeAmount), this.network);
373
- }
374
- // Sign inputs and Finalize
375
- tx = await this.identity.sign(tx);
376
- tx.finalize();
377
- // Broadcast
378
- const txid = await this.onchainProvider.broadcastTransaction(tx.hex);
379
- return txid;
380
- }
381
- async sendOffchain(params, zeroFee = true) {
382
- if (!this.arkProvider ||
383
- !this.offchainAddress ||
384
- !this.offchainTapscript) {
385
- throw new Error("wallet not initialized");
386
- }
387
- const virtualCoins = await this.getVirtualCoins();
388
- const estimatedFee = zeroFee
389
- ? 0
390
- : Math.ceil(174 * (params.feeRate || Wallet.FEE_RATE));
391
- const totalNeeded = params.amount + estimatedFee;
392
- const selected = selectVirtualCoins(virtualCoins, totalNeeded);
393
- if (!selected || !selected.inputs) {
394
- throw new Error("Insufficient funds");
308
+ if (!isValidArkAddress(params.address)) {
309
+ throw new Error("Invalid Ark address " + params.address);
395
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);
396
316
  const selectedLeaf = this.offchainTapscript.forfeit();
397
317
  if (!selectedLeaf) {
398
318
  throw new Error("Selected leaf not found");
399
319
  }
320
+ const outputAddress = ArkAddress.decode(params.address);
321
+ const outputScript = BigInt(params.amount) < this.dustAmount
322
+ ? outputAddress.subdustPkScript
323
+ : outputAddress.pkScript;
400
324
  const outputs = [
401
325
  {
402
- address: params.address,
326
+ script: outputScript,
403
327
  amount: BigInt(params.amount),
404
328
  },
405
329
  ];
406
330
  // add change output if needed
407
- if (selected.changeAmount > 0) {
331
+ if (selected.changeAmount > 0n) {
332
+ const changeOutputScript = selected.changeAmount < this.dustAmount
333
+ ? this.arkAddress.subdustPkScript
334
+ : this.arkAddress.pkScript;
408
335
  outputs.push({
409
- address: this.offchainAddress.encode(),
336
+ script: changeOutputScript,
410
337
  amount: BigInt(selected.changeAmount),
411
338
  });
412
339
  }
413
- const scripts = this.offchainTapscript.encode();
414
- let tx = createVirtualTx(selected.inputs.map((input) => ({
340
+ const tapTree = this.offchainTapscript.encode();
341
+ let offchainTx = buildOffchainTx(selected.inputs.map((input) => ({
415
342
  ...input,
416
343
  tapLeafScript: selectedLeaf,
417
- scripts,
418
- })), outputs);
419
- tx = await this.identity.sign(tx);
420
- const psbt = base64.encode(tx.toPSBT());
421
- 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;
422
357
  }
423
358
  async settle(params, eventCallback) {
424
- if (!this.arkProvider) {
425
- throw new Error("Ark provider not configured");
426
- }
427
- // validate arknotes inputs
428
359
  if (params?.inputs) {
429
360
  for (const input of params.inputs) {
361
+ // validate arknotes inputs
430
362
  if (typeof input === "string") {
431
363
  try {
432
364
  ArkNote.fromString(input);
@@ -440,9 +372,6 @@ export class Wallet {
440
372
  // if no params are provided, use all boarding and offchain utxos as inputs
441
373
  // and send all to the offchain address
442
374
  if (!params) {
443
- if (!this.offchainAddress) {
444
- throw new Error("Offchain address not configured");
445
- }
446
375
  let amount = 0;
447
376
  const boardingUtxos = await this.getBoardingUtxos();
448
377
  amount += boardingUtxos.reduce((sum, input) => sum + input.value, 0);
@@ -456,23 +385,34 @@ export class Wallet {
456
385
  inputs,
457
386
  outputs: [
458
387
  {
459
- address: this.offchainAddress.encode(),
388
+ address: await this.getAddress(),
460
389
  amount: BigInt(amount),
461
390
  },
462
391
  ],
463
392
  };
464
393
  }
465
- // register inputs
466
- const { requestId } = await this.arkProvider.registerInputsForNextRound(params.inputs.map((input) => {
467
- if (typeof input === "string") {
468
- 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;
469
404
  }
470
- return {
471
- outpoint: input,
472
- tapscripts: input.scripts,
473
- };
474
- }));
475
- 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
+ }
476
416
  // session holds the state of the musig2 signing process of the vtxo tree
477
417
  let session;
478
418
  const signingPublicKeys = [];
@@ -480,189 +420,287 @@ export class Wallet {
480
420
  session = this.identity.signerSession();
481
421
  signingPublicKeys.push(hex.encode(session.getPublicKey()));
482
422
  }
483
- // register outputs
484
- await this.arkProvider.registerOutputsForNextRound(requestId, params.outputs, signingPublicKeys);
485
- // start pinging every seconds
486
- const interval = setInterval(() => {
487
- this.arkProvider?.ping(requestId).catch(stopPing);
488
- }, 1000);
489
- let pingRunning = true;
490
- const stopPing = () => {
491
- if (pingRunning) {
492
- pingRunning = false;
493
- clearInterval(interval);
494
- }
495
- };
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);
496
428
  const abortController = new AbortController();
497
429
  // listen to settlement events
498
430
  try {
499
- const settlementStream = this.arkProvider.getEventStream(abortController.signal);
500
431
  let step;
501
- if (!hasOffchainOutputs) {
502
- // if there are no offchain outputs, we don't have to handle musig2 tree signatures
503
- // we can directly advance to the finalization step
504
- step = SettlementEventType.SigningNoncesGenerated;
505
- }
506
- const info = await this.arkProvider.getInfo();
507
- const sweepTapscript = CSVMultisigTapscript.encode({
508
- timelock: {
509
- value: info.batchExpiry,
510
- type: info.batchExpiry >= 512n ? "seconds" : "blocks",
511
- },
512
- pubkeys: [hex.decode(info.pubkey).slice(1)],
513
- }).script;
514
- 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;
515
444
  for await (const event of settlementStream) {
516
445
  if (eventCallback) {
517
446
  eventCallback(event);
518
447
  }
519
448
  switch (event.type) {
520
449
  // the settlement failed
521
- case SettlementEventType.Failed:
522
- 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) {
523
458
  continue;
524
459
  }
525
- stopPing();
526
- throw new Error(event.reason);
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) {
491
+ continue;
492
+ }
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;
527
509
  // the server has started the signing process of the vtxo tree transactions
528
510
  // the server expects the partial musig2 nonces for each tx
529
- case SettlementEventType.SigningStart:
530
- if (step !== undefined) {
511
+ case SettlementEventType.TreeSigningStarted:
512
+ if (step !== SettlementEventType.BatchStarted) {
531
513
  continue;
532
514
  }
533
- stopPing();
534
515
  if (hasOffchainOutputs) {
535
516
  if (!session) {
536
- throw new Error("Signing session not found");
517
+ throw new Error("Signing session not set");
518
+ }
519
+ if (!sweepTapTreeRoot) {
520
+ throw new Error("Sweep tap tree root not set");
537
521
  }
538
- await this.handleSettlementSigningEvent(event, sweepTapTreeRoot, session);
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);
539
527
  }
528
+ step = event.type;
540
529
  break;
541
530
  // the musig2 nonces of the vtxo tree transactions are generated
542
531
  // the server expects now the partial musig2 signatures
543
- case SettlementEventType.SigningNoncesGenerated:
544
- if (step !== SettlementEventType.SigningStart) {
532
+ case SettlementEventType.TreeNoncesAggregated:
533
+ if (step !== SettlementEventType.TreeSigningStarted) {
545
534
  continue;
546
535
  }
547
- stopPing();
548
536
  if (hasOffchainOutputs) {
549
537
  if (!session) {
550
- throw new Error("Signing session not found");
538
+ throw new Error("Signing session not set");
551
539
  }
552
540
  await this.handleSettlementSigningNoncesGeneratedEvent(event, session);
553
541
  }
542
+ step = event.type;
554
543
  break;
555
544
  // the vtxo tree is signed, craft, sign and submit forfeit transactions
556
545
  // if any boarding utxos are involved, the settlement tx is also signed
557
- case SettlementEventType.Finalization:
558
- if (step !== SettlementEventType.SigningNoncesGenerated) {
546
+ case SettlementEventType.BatchFinalization:
547
+ if (step !== SettlementEventType.TreeNoncesAggregated) {
559
548
  continue;
560
549
  }
561
- stopPing();
562
- 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;
563
559
  break;
564
560
  // the settlement is done, last event to be received
565
- case SettlementEventType.Finalized:
566
- if (step !== SettlementEventType.Finalization) {
561
+ case SettlementEventType.BatchFinalized:
562
+ if (step !== SettlementEventType.BatchFinalization) {
567
563
  continue;
568
564
  }
569
565
  abortController.abort();
570
- return event.roundTxid;
566
+ return event.commitmentTxid;
571
567
  }
572
- step = event.type;
573
568
  }
574
569
  }
575
570
  catch (error) {
571
+ // close the stream
576
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 { }
577
578
  throw error;
578
579
  }
579
580
  throw new Error("Settlement failed");
580
581
  }
581
- async exit(outpoints) {
582
- // TODO store the exit branches in repository
583
- // exit should not depend on the ark provider
584
- if (!this.arkProvider) {
585
- throw new Error("Ark provider not configured");
586
- }
587
- let vtxos = await this.getVtxos();
588
- if (outpoints && outpoints.length > 0) {
589
- vtxos = vtxos.filter((vtxo) => outpoints.some((outpoint) => vtxo.txid === outpoint.txid &&
590
- 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
+ });
591
609
  }
592
- if (vtxos.length === 0) {
593
- 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
+ })();
594
637
  }
595
- const trees = new Map();
596
- const transactions = [];
597
- for (const vtxo of vtxos) {
598
- const batchTxid = vtxo.virtualStatus.batchTxID;
599
- if (!batchTxid)
600
- continue;
601
- if (!trees.has(batchTxid)) {
602
- const round = await this.arkProvider.getRound(batchTxid);
603
- trees.set(batchTxid, round.vtxoTree);
604
- }
605
- const tree = trees.get(batchTxid);
606
- if (!tree) {
607
- 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;
608
657
  }
609
- const exitBranch = await tree.exitBranch(vtxo.txid, async (txid) => {
610
- const status = await this.onchainProvider.getTxStatus(txid);
611
- return status.confirmed;
612
- });
613
- transactions.push(...exitBranch);
614
658
  }
615
- const broadcastedTxs = new Map();
616
- for (const tx of transactions) {
617
- if (broadcastedTxs.has(tx))
618
- continue;
619
- const txid = await this.onchainProvider.broadcastTransaction(tx);
620
- broadcastedTxs.set(txid, true);
659
+ if (skip) {
660
+ return { skip };
621
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
+ };
622
676
  }
623
677
  // validates the vtxo tree, creates a signing session and generates the musig2 nonces
624
- async handleSettlementSigningEvent(event, sweepTapTreeRoot, session) {
625
- const vtxoTree = event.unsignedVtxoTree;
626
- if (!this.arkProvider) {
627
- throw new Error("Ark provider not configured");
628
- }
678
+ async handleSettlementSigningEvent(event, sweepTapTreeRoot, session, vtxoGraph) {
629
679
  // validate the unsigned vtxo tree
630
- validateVtxoTree(event.unsignedSettlementTx, vtxoTree, sweepTapTreeRoot);
680
+ const commitmentTx = Transaction.fromPSBT(base64.decode(event.unsignedCommitmentTx));
681
+ validateVtxoTxGraph(vtxoGraph, commitmentTx, sweepTapTreeRoot);
631
682
  // TODO check if our registered outputs are in the vtxo tree
632
- const settlementPsbt = base64.decode(event.unsignedSettlementTx);
633
- const settlementTx = Transaction.fromPSBT(settlementPsbt);
634
- const sharedOutput = settlementTx.getOutput(0);
683
+ const sharedOutput = commitmentTx.getOutput(0);
635
684
  if (!sharedOutput?.amount) {
636
685
  throw new Error("Shared output not found");
637
686
  }
638
- session.init(vtxoTree, sweepTapTreeRoot, sharedOutput.amount);
687
+ session.init(vtxoGraph, sweepTapTreeRoot, sharedOutput.amount);
639
688
  await this.arkProvider.submitTreeNonces(event.id, hex.encode(session.getPublicKey()), session.getNonces());
640
689
  }
641
690
  async handleSettlementSigningNoncesGeneratedEvent(event, session) {
642
- if (!this.arkProvider) {
643
- throw new Error("Ark provider not configured");
644
- }
645
691
  session.setAggregatedNonces(event.treeNonces);
646
692
  const signatures = session.sign();
647
693
  await this.arkProvider.submitTreeSignatures(event.id, hex.encode(session.getPublicKey()), signatures);
648
694
  }
649
- async handleSettlementFinalizationEvent(event, inputs, infos) {
650
- if (!this.arkProvider) {
651
- throw new Error("Ark provider not configured");
652
- }
653
- // parse the server forfeit address
654
- // server is expecting funds to be sent to this address
655
- const forfeitAddress = Address(this.network).decode(infos.forfeitAddress);
656
- const serverPkScript = OutScript.encode(forfeitAddress);
695
+ async handleSettlementFinalizationEvent(event, inputs, forfeitOutputScript, connectorsGraph) {
657
696
  // the signed forfeits transactions to submit
658
697
  const signedForfeits = [];
659
698
  const vtxos = await this.getVirtualCoins();
660
- let settlementPsbt = Transaction.fromPSBT(base64.decode(event.roundTx));
699
+ let settlementPsbt = Transaction.fromPSBT(base64.decode(event.commitmentTx));
661
700
  let hasBoardingUtxos = false;
662
- let connectorsTreeValid = false;
701
+ let connectorIndex = 0;
702
+ const connectorsLeaves = connectorsGraph?.leaves() || [];
663
703
  for (const input of inputs) {
664
- if (typeof input === "string")
665
- continue; // skip notes
666
704
  // check if the input is an offchain "virtual" coin
667
705
  const vtxo = vtxos.find((vtxo) => vtxo.txid === input.txid && vtxo.vout === input.vout);
668
706
  // boarding utxo, we need to sign the settlement tx
@@ -682,71 +720,246 @@ export class Wallet {
682
720
  continue;
683
721
  // input found in the settlement tx, sign it
684
722
  settlementPsbt.updateInput(i, {
685
- tapLeafScript: [input.tapLeafScript],
723
+ tapLeafScript: [input.forfeitTapLeafScript],
686
724
  });
687
725
  inputIndexes.push(i);
688
726
  }
689
727
  settlementPsbt = await this.identity.sign(settlementPsbt, inputIndexes);
690
728
  continue;
691
729
  }
692
- if (!connectorsTreeValid) {
693
- // validate that the connectors tree is valid and contains our expected connectors
694
- validateConnectorsTree(event.roundTx, event.connectors);
695
- 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;
696
733
  }
697
- const forfeitControlBlock = TaprootControlBlock.encode(input.tapLeafScript[0]);
698
- const tapscript = decodeTapscript(scriptFromTapLeafScript(input.tapLeafScript));
699
- const fees = TxWeightEstimator.create()
700
- .addKeySpendInput() // connector
701
- .addTapscriptInput(tapscript.witnessSize(100), // TODO: handle conditional script
702
- input.tapLeafScript[1].length - 1, forfeitControlBlock.length)
703
- .addP2WKHOutput()
704
- .vsize()
705
- .fee(event.minRelayFeeRate);
706
- const connectorsLeaves = event.connectors.leaves();
707
- const connectorOutpoint = event.connectorsIndex.get(`${vtxo.txid}:${vtxo.vout}`);
708
- if (!connectorOutpoint) {
709
- throw new Error("Connector outpoint not found");
734
+ if (connectorsLeaves.length === 0) {
735
+ throw new Error("connectors not received");
710
736
  }
711
- let connectorOutput;
712
- for (const leaf of connectorsLeaves) {
713
- if (leaf.txid === connectorOutpoint.txid) {
714
- try {
715
- const connectorTx = Transaction.fromPSBT(base64.decode(leaf.tx));
716
- connectorOutput = connectorTx.getOutput(connectorOutpoint.vout);
717
- break;
718
- }
719
- catch {
720
- throw new Error("Invalid connector tx");
721
- }
722
- }
737
+ if (connectorIndex >= connectorsLeaves.length) {
738
+ throw new Error("not enough connectors received");
723
739
  }
724
- if (!connectorOutput ||
725
- !connectorOutput.amount ||
726
- !connectorOutput.script) {
727
- 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");
728
745
  }
729
- let forfeitTx = buildForfeitTx({
730
- connectorInput: connectorOutpoint,
731
- connectorAmount: connectorOutput.amount,
732
- feeAmount: fees,
733
- serverPkScript,
734
- connectorPkScript: connectorOutput.script,
735
- vtxoAmount: BigInt(vtxo.value),
736
- vtxoInput: input,
737
- vtxoPkScript: VtxoScript.decode(input.scripts).pkScript,
738
- });
739
- // add the tapscript
740
- forfeitTx.updateInput(1, {
741
- tapLeafScript: [input.tapLeafScript],
742
- });
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);
743
772
  // do not sign the connector input
744
- forfeitTx = await this.identity.sign(forfeitTx, [1]);
773
+ forfeitTx = await this.identity.sign(forfeitTx, [0]);
745
774
  signedForfeits.push(base64.encode(forfeitTx.toPSBT()));
746
775
  }
747
- await this.arkProvider.submitSignedForfeitTxs(signedForfeits, hasBoardingUtxos
748
- ? base64.encode(settlementPsbt.toPSBT())
749
- : 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
+ };
750
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;
751
965
  }
752
- Wallet.FEE_RATE = 1; // sats/vbyte