@arkade-os/sdk 0.3.9 → 0.3.11

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 (36) hide show
  1. package/dist/cjs/arkfee/celenv.js +43 -0
  2. package/dist/cjs/arkfee/estimator.js +143 -0
  3. package/dist/cjs/arkfee/index.js +5 -0
  4. package/dist/cjs/arkfee/types.js +25 -0
  5. package/dist/cjs/index.js +15 -0
  6. package/dist/cjs/intent/index.js +21 -0
  7. package/dist/cjs/providers/ark.js +2 -5
  8. package/dist/cjs/providers/indexer.js +1 -0
  9. package/dist/cjs/utils/transactionHistory.js +118 -156
  10. package/dist/cjs/wallet/ramps.js +96 -11
  11. package/dist/cjs/wallet/serviceWorker/worker.js +4 -31
  12. package/dist/cjs/wallet/wallet.js +68 -35
  13. package/dist/esm/arkfee/celenv.js +40 -0
  14. package/dist/esm/arkfee/estimator.js +139 -0
  15. package/dist/esm/arkfee/index.js +1 -0
  16. package/dist/esm/arkfee/types.js +21 -0
  17. package/dist/esm/index.js +1 -0
  18. package/dist/esm/intent/index.js +21 -0
  19. package/dist/esm/providers/ark.js +2 -5
  20. package/dist/esm/providers/indexer.js +1 -0
  21. package/dist/esm/utils/transactionHistory.js +117 -155
  22. package/dist/esm/wallet/ramps.js +96 -11
  23. package/dist/esm/wallet/serviceWorker/worker.js +4 -31
  24. package/dist/esm/wallet/wallet.js +68 -35
  25. package/dist/types/arkfee/celenv.d.ts +25 -0
  26. package/dist/types/arkfee/estimator.d.ts +49 -0
  27. package/dist/types/arkfee/index.d.ts +2 -0
  28. package/dist/types/arkfee/types.d.ts +37 -0
  29. package/dist/types/index.d.ts +1 -0
  30. package/dist/types/intent/index.d.ts +1 -0
  31. package/dist/types/providers/ark.d.ts +3 -8
  32. package/dist/types/utils/transactionHistory.d.ts +12 -5
  33. package/dist/types/wallet/index.d.ts +2 -0
  34. package/dist/types/wallet/ramps.d.ts +2 -1
  35. package/dist/types/wallet/serviceWorker/worker.d.ts +0 -1
  36. package/package.json +114 -123
@@ -1,165 +1,127 @@
1
1
  import { TxType } from '../wallet/index.js';
2
+ const txKey = {
3
+ commitmentTxid: "",
4
+ boardingTxid: "",
5
+ arkTxid: "",
6
+ };
2
7
  /**
3
- * @param spendable - Vtxos that are spendable
4
- * @param spent - Vtxos that are spent
5
- * @param boardingBatchTxids - Set of boarding batch txids
6
- * @returns Ark transactions
8
+ * Builds the transaction history by analyzing virtual coins (VTXOs), boarding transactions, and ignored commitments.
9
+ * History is sorted from newest to oldest and is composed only of SENT and RECEIVED transactions.
10
+ *
11
+ * @param {VirtualCoin[]} vtxos - An array of virtual coins representing the user's transactions and balances.
12
+ * @param {ArkTransaction[]} allBoardingTxs - An array of boarding transactions to include in the history.
13
+ * @param {Set<string>} commitmentsToIgnore - A set of commitment IDs that should be excluded from processing.
14
+ * @return {ExtendedArkTransaction[]} A sorted array of extended Ark transactions, representing the transaction history.
7
15
  */
8
- export function vtxosToTxs(spendable, spent, boardingBatchTxids) {
9
- const txs = [];
10
- // Receive case
11
- // All vtxos are received unless:
12
- // - they resulted from a settlement (either boarding or refresh)
13
- // - they are the change of a spend tx
14
- let vtxosLeftToCheck = [...spent];
15
- for (const vtxo of [...spendable, ...spent]) {
16
- if (vtxo.virtualStatus.state !== "preconfirmed" &&
17
- vtxo.virtualStatus.commitmentTxIds &&
18
- vtxo.virtualStatus.commitmentTxIds.some((txid) => boardingBatchTxids.has(txid))) {
19
- continue;
20
- }
21
- const settleVtxos = findVtxosSpentInSettlement(vtxosLeftToCheck, vtxo);
22
- vtxosLeftToCheck = removeVtxosFromList(vtxosLeftToCheck, settleVtxos);
23
- const settleAmount = reduceVtxosAmount(settleVtxos);
24
- if (vtxo.value <= settleAmount) {
25
- continue; // settlement or change, ignore
26
- }
27
- const spentVtxos = findVtxosSpentInPayment(vtxosLeftToCheck, vtxo);
28
- vtxosLeftToCheck = removeVtxosFromList(vtxosLeftToCheck, spentVtxos);
29
- const spentAmount = reduceVtxosAmount(spentVtxos);
30
- if (vtxo.value <= spentAmount) {
31
- continue; // settlement or change, ignore
32
- }
33
- const txKey = {
34
- commitmentTxid: "",
35
- boardingTxid: "",
36
- arkTxid: "",
37
- };
38
- let settled = vtxo.virtualStatus.state !== "preconfirmed";
39
- if (vtxo.virtualStatus.state === "preconfirmed") {
40
- txKey.arkTxid = vtxo.txid;
41
- if (vtxo.spentBy) {
42
- settled = true;
16
+ export function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgnore) {
17
+ const fromOldestVtxo = [...vtxos].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
18
+ const sent = [];
19
+ let received = [];
20
+ for (const vtxo of fromOldestVtxo) {
21
+ if (vtxo.status.isLeaf) {
22
+ // If this vtxo is a leaf and it's not the settlement of a boarding or there's no vtxo refreshed by it,
23
+ // it's translated into a received batch transaction
24
+ if (!commitmentsToIgnore.has(vtxo.virtualStatus.commitmentTxIds[0]) &&
25
+ fromOldestVtxo.filter((v) => v.settledBy === vtxo.virtualStatus.commitmentTxIds[0]).length === 0) {
26
+ received.push({
27
+ key: {
28
+ ...txKey,
29
+ commitmentTxid: vtxo.virtualStatus.commitmentTxIds[0],
30
+ },
31
+ tag: "batch",
32
+ type: TxType.TxReceived,
33
+ amount: vtxo.value,
34
+ settled: vtxo.status.isLeaf || vtxo.isSpent,
35
+ createdAt: vtxo.createdAt.getTime(),
36
+ });
43
37
  }
44
38
  }
45
- else {
46
- txKey.commitmentTxid =
47
- vtxo.virtualStatus.commitmentTxIds?.[0] || "";
39
+ else if (fromOldestVtxo.filter((v) => v.arkTxId === vtxo.txid).length === 0) {
40
+ // If this vtxo is preconfirmed and does not spend any other vtxos,
41
+ // it's translated into a received offchain transaction
42
+ received.push({
43
+ key: { ...txKey, arkTxid: vtxo.txid },
44
+ tag: "offchain",
45
+ type: TxType.TxReceived,
46
+ amount: vtxo.value,
47
+ settled: vtxo.status.isLeaf || vtxo.isSpent,
48
+ createdAt: vtxo.createdAt.getTime(),
49
+ });
48
50
  }
49
- txs.push({
50
- key: txKey,
51
- amount: vtxo.value - settleAmount - spentAmount,
52
- type: TxType.TxReceived,
53
- createdAt: vtxo.createdAt.getTime(),
54
- settled,
55
- });
56
- }
57
- // vtxos by settled by or ark txid
58
- const vtxosByTxid = new Map();
59
- for (const v of spent) {
60
- if (v.settledBy) {
61
- if (!vtxosByTxid.has(v.settledBy)) {
62
- vtxosByTxid.set(v.settledBy, []);
51
+ // If the vtxo is spent, it's translated into a sent transaction unless:
52
+ // - it's been refreshed (we don't want to add any record in this case)
53
+ // - a sent transaction has been already added to avoid duplicates (can happen if many vtxos have been spent in the same tx or forfeited in the same batch)
54
+ if (vtxo.isSpent) {
55
+ // If the vtxo is spent offchain, it's translated into offchain sent tx
56
+ if (vtxo.arkTxId &&
57
+ !sent.some((s) => s.key.arkTxid === vtxo.arkTxId)) {
58
+ const changes = fromOldestVtxo.filter((_) => _.txid === vtxo.arkTxId);
59
+ let txAmount = 0;
60
+ let txTime = 0;
61
+ if (changes.length > 0) {
62
+ const changeAmount = changes.reduce((acc, v) => acc + v.value, 0);
63
+ // We want to find all the other VTXOs spent by the same transaction to
64
+ // calculate the full amount of the change.
65
+ const allSpent = fromOldestVtxo.filter((v) => v.arkTxId === vtxo.arkTxId);
66
+ const spentAmount = allSpent.reduce((acc, v) => acc + v.value, 0);
67
+ txAmount = spentAmount - changeAmount;
68
+ txTime = changes[0].createdAt.getTime();
69
+ }
70
+ else {
71
+ txAmount = vtxo.value;
72
+ // TODO: fetch the vtxo with /v1/indexer/vtxos?outpoints=<vtxo.arkTxid:0> to know when the tx was made
73
+ txTime = vtxo.createdAt.getTime() + 1;
74
+ }
75
+ sent.push({
76
+ key: { ...txKey, arkTxid: vtxo.arkTxId },
77
+ tag: "offchain",
78
+ type: TxType.TxSent,
79
+ amount: txAmount,
80
+ settled: true,
81
+ createdAt: txTime,
82
+ });
63
83
  }
64
- const currentVtxos = vtxosByTxid.get(v.settledBy);
65
- vtxosByTxid.set(v.settledBy, [...currentVtxos, v]);
66
- }
67
- if (!v.arkTxId) {
68
- continue;
69
- }
70
- if (!vtxosByTxid.has(v.arkTxId)) {
71
- vtxosByTxid.set(v.arkTxId, []);
72
- }
73
- const currentVtxos = vtxosByTxid.get(v.arkTxId);
74
- vtxosByTxid.set(v.arkTxId, [...currentVtxos, v]);
75
- }
76
- for (const [sb, vtxos] of vtxosByTxid) {
77
- const resultedVtxos = findVtxosResultedFromTxid([...spendable, ...spent], sb);
78
- const resultedAmount = reduceVtxosAmount(resultedVtxos);
79
- const spentAmount = reduceVtxosAmount(vtxos);
80
- if (spentAmount <= resultedAmount) {
81
- continue; // settlement or change, ignore
82
- }
83
- const vtxo = getVtxo(resultedVtxos, vtxos);
84
- const txKey = {
85
- commitmentTxid: "",
86
- boardingTxid: "",
87
- arkTxid: "",
88
- };
89
- if (vtxo.virtualStatus.state === "preconfirmed") {
90
- txKey.arkTxid = resultedAmount === 0 ? vtxo.arkTxId : vtxo.txid;
91
- }
92
- else {
93
- txKey.commitmentTxid =
94
- vtxo.virtualStatus.commitmentTxIds?.[0] || "";
95
- }
96
- txs.push({
97
- key: txKey,
98
- amount: spentAmount - resultedAmount,
99
- type: TxType.TxSent,
100
- createdAt: vtxo.createdAt.getTime(),
101
- settled: true,
102
- });
103
- }
104
- return txs;
105
- }
106
- /**
107
- * Helper function to find vtxos that were spent in a settlement
108
- */
109
- function findVtxosSpentInSettlement(vtxos, vtxo) {
110
- if (vtxo.virtualStatus.state === "preconfirmed") {
111
- return [];
112
- }
113
- return vtxos.filter((v) => {
114
- if (!v.settledBy)
115
- return false;
116
- return (vtxo.virtualStatus.commitmentTxIds?.includes(v.settledBy) ?? false);
117
- });
118
- }
119
- /**
120
- * Helper function to find vtxos that were spent in a payment
121
- */
122
- function findVtxosSpentInPayment(vtxos, vtxo) {
123
- return vtxos.filter((v) => {
124
- if (!v.arkTxId)
125
- return false;
126
- return v.arkTxId === vtxo.txid;
127
- });
128
- }
129
- /**
130
- * Helper function to find vtxos that resulted from a spentBy transaction
131
- */
132
- function findVtxosResultedFromTxid(vtxos, txid) {
133
- return vtxos.filter((v) => {
134
- if (v.virtualStatus.state !== "preconfirmed" &&
135
- v.virtualStatus.commitmentTxIds?.includes(txid)) {
136
- return true;
137
- }
138
- return v.txid === txid;
139
- });
140
- }
141
- /**
142
- * Helper function to reduce vtxos to their total amount
143
- */
144
- function reduceVtxosAmount(vtxos) {
145
- return vtxos.reduce((sum, v) => sum + v.value, 0);
146
- }
147
- /**
148
- * Helper function to get a vtxo from a list of vtxos
149
- */
150
- function getVtxo(resultedVtxos, spentVtxos) {
151
- if (resultedVtxos.length === 0) {
152
- return spentVtxos[0];
153
- }
154
- return resultedVtxos[0];
155
- }
156
- function removeVtxosFromList(vtxos, vtxosToRemove) {
157
- return vtxos.filter((v) => {
158
- for (const vtxoToRemove of vtxosToRemove) {
159
- if (v.txid === vtxoToRemove.txid && v.vout === vtxoToRemove.vout) {
160
- return false;
84
+ // If the vtxo is forfeited in a batch and the total sum of forfeited vtxos is bigger than the sum of new vtxos,
85
+ // it's translated into an exit sent tx
86
+ if (vtxo.settledBy &&
87
+ !commitmentsToIgnore.has(vtxo.settledBy) &&
88
+ !sent.some((s) => s.key.commitmentTxid === vtxo.settledBy)) {
89
+ const changes = fromOldestVtxo.filter((v) => v.status.isLeaf &&
90
+ v.virtualStatus.commitmentTxIds?.every((_) => vtxo.settledBy === _));
91
+ const forfeitVtxos = fromOldestVtxo.filter((v) => v.settledBy === vtxo.settledBy);
92
+ const forfeitAmount = forfeitVtxos.reduce((acc, v) => acc + v.value, 0);
93
+ if (changes.length > 0) {
94
+ const settledAmount = changes.reduce((acc, v) => acc + v.value, 0);
95
+ // forfeitAmount > settledAmount --> collaborative exit with offchain change
96
+ // TODO: make this support fees!
97
+ if (forfeitAmount > settledAmount) {
98
+ sent.push({
99
+ key: { ...txKey, commitmentTxid: vtxo.settledBy },
100
+ tag: "exit",
101
+ type: TxType.TxSent,
102
+ amount: forfeitAmount - settledAmount,
103
+ settled: true,
104
+ createdAt: changes[0].createdAt.getTime(),
105
+ });
106
+ }
107
+ }
108
+ else {
109
+ // forfeitAmount > 0 && settledAmount == 0 --> collaborative exit without any offchain change
110
+ sent.push({
111
+ key: { ...txKey, commitmentTxid: vtxo.settledBy },
112
+ tag: "exit",
113
+ type: TxType.TxSent,
114
+ amount: forfeitAmount,
115
+ settled: true,
116
+ // TODO: fetch commitment tx with /v1/indexer/commitmentTx/<commitmentTxid> to know when the tx was made
117
+ createdAt: vtxo.createdAt.getTime() + 1,
118
+ });
119
+ }
161
120
  }
162
121
  }
163
- return true;
164
- });
122
+ }
123
+ // Boardings are always inbound amounts, and we only hide the ones to ignore.
124
+ const boardingTx = allBoardingTxs.map((tx) => ({ ...tx, tag: "boarding" }));
125
+ const sorted = [...boardingTx, ...sent, ...received].sort((a, b) => b.createdAt - a.createdAt);
126
+ return sorted;
165
127
  }
@@ -1,3 +1,8 @@
1
+ import { Estimator } from '../arkfee/index.js';
2
+ import { Address, OutScript } from "@scure/btc-signer";
3
+ import { hex } from "@scure/base";
4
+ import { networks } from '../networks.js';
5
+ import { ArkAddress } from '../script/address.js';
1
6
  /**
2
7
  * Ramps is a class wrapping IWallet.settle method to provide a more convenient interface for onboarding and offboarding operations.
3
8
  *
@@ -15,22 +20,51 @@ export class Ramps {
15
20
  /**
16
21
  * Onboard boarding utxos.
17
22
  *
23
+ * @param feeInfo - The fee info to deduct from the onboard amount.
18
24
  * @param boardingUtxos - The boarding utxos to onboard. If not provided, all boarding utxos will be used.
19
25
  * @param amount - The amount to onboard. If not provided, the total amount of boarding utxos will be onboarded.
20
26
  * @param eventCallback - The callback to receive settlement events. optional.
21
27
  */
22
- async onboard(boardingUtxos, amount, eventCallback) {
28
+ async onboard(feeInfo, boardingUtxos, amount, eventCallback) {
23
29
  boardingUtxos = boardingUtxos ?? (await this.wallet.getBoardingUtxos());
24
- const totalAmount = boardingUtxos.reduce((acc, coin) => acc + BigInt(coin.value), 0n);
30
+ // Calculate input fees and filter out utxos where fee >= value
31
+ const estimator = new Estimator(feeInfo?.intentFee ?? {});
32
+ const filteredBoardingUtxos = [];
33
+ let totalAmount = 0n;
34
+ for (const utxo of boardingUtxos) {
35
+ const inputFee = estimator.evalOnchainInput({
36
+ amount: BigInt(utxo.value),
37
+ });
38
+ if (inputFee.satoshis >= utxo.value) {
39
+ // skip if fees are greater than or equal to the utxo value
40
+ continue;
41
+ }
42
+ filteredBoardingUtxos.push(utxo);
43
+ totalAmount += BigInt(utxo.value) - BigInt(inputFee.satoshis);
44
+ }
45
+ if (filteredBoardingUtxos.length === 0) {
46
+ throw new Error("No boarding utxos available after deducting fees");
47
+ }
25
48
  let change = 0n;
26
49
  if (amount) {
27
50
  if (amount > totalAmount) {
28
- throw new Error("Amount is greater than total amount of boarding utxos");
51
+ throw new Error("Amount is greater than total amount of boarding utxos after fees");
29
52
  }
30
53
  change = totalAmount - amount;
31
54
  }
32
55
  amount = amount ?? totalAmount;
56
+ // Calculate offchain output fee using Estimator
33
57
  const offchainAddress = await this.wallet.getAddress();
58
+ const offchainAddr = ArkAddress.decode(offchainAddress);
59
+ const offchainScript = hex.encode(offchainAddr.pkScript);
60
+ const outputFee = estimator.evalOffchainOutput({
61
+ amount,
62
+ script: offchainScript,
63
+ });
64
+ if (BigInt(outputFee.satoshis) > amount) {
65
+ throw new Error(`can't deduct fees from onboard amount (${outputFee.satoshis} > ${amount})`);
66
+ }
67
+ amount -= BigInt(outputFee.satoshis);
34
68
  const outputs = [
35
69
  {
36
70
  address: offchainAddress,
@@ -45,7 +79,7 @@ export class Ramps {
45
79
  });
46
80
  }
47
81
  return this.wallet.settle({
48
- inputs: boardingUtxos,
82
+ inputs: filteredBoardingUtxos,
49
83
  outputs,
50
84
  }, eventCallback);
51
85
  }
@@ -62,20 +96,71 @@ export class Ramps {
62
96
  withRecoverable: true,
63
97
  withUnrolled: false,
64
98
  });
65
- const totalAmount = vtxos.reduce((acc, coin) => acc + BigInt(coin.value), 0n);
99
+ // Calculate input fees and filter out vtxos where fee >= value
100
+ const estimator = new Estimator(feeInfo?.intentFee ?? {});
101
+ const filteredVtxos = [];
102
+ let totalAmount = 0n;
103
+ for (const vtxo of vtxos) {
104
+ const inputFee = estimator.evalOffchainInput({
105
+ amount: BigInt(vtxo.value),
106
+ type: vtxo.virtualStatus.state === "swept"
107
+ ? "recoverable"
108
+ : "vtxo",
109
+ weight: 0,
110
+ birth: vtxo.createdAt,
111
+ expiry: vtxo.virtualStatus.batchExpiry
112
+ ? new Date(vtxo.virtualStatus.batchExpiry * 1000)
113
+ : undefined,
114
+ });
115
+ if (inputFee.satoshis >= vtxo.value) {
116
+ // skip if fees are greater than or equal to the vtxo value
117
+ continue;
118
+ }
119
+ filteredVtxos.push(vtxo);
120
+ totalAmount += BigInt(vtxo.value) - BigInt(inputFee.satoshis);
121
+ }
122
+ if (filteredVtxos.length === 0) {
123
+ throw new Error("No vtxos available after deducting fees");
124
+ }
66
125
  let change = 0n;
67
126
  if (amount) {
68
127
  if (amount > totalAmount) {
69
- throw new Error("Amount is greater than total amount of vtxos");
128
+ throw new Error("Amount is greater than total amount of vtxos after fees");
70
129
  }
71
130
  change = totalAmount - amount;
72
131
  }
73
132
  amount = amount ?? totalAmount;
74
- const fees = feeInfo.intentFee.onchainOutput;
75
- if (fees > amount) {
76
- throw new Error(`can't deduct fees from offboard amount (${fees} > ${amount})`);
133
+ const networkNames = [
134
+ "bitcoin",
135
+ "regtest",
136
+ "testnet",
137
+ "signet",
138
+ "mutinynet",
139
+ ];
140
+ let destinationScript;
141
+ for (const networkName of networkNames) {
142
+ try {
143
+ const network = networks[networkName];
144
+ const addr = Address(network).decode(destinationAddress);
145
+ destinationScript = OutScript.encode(addr);
146
+ break;
147
+ }
148
+ catch {
149
+ // Try next network
150
+ continue;
151
+ }
152
+ }
153
+ if (!destinationScript) {
154
+ throw new Error(`Failed to decode destination address: ${destinationAddress}`);
155
+ }
156
+ const outputFee = estimator.evalOnchainOutput({
157
+ amount,
158
+ script: hex.encode(destinationScript),
159
+ });
160
+ if (BigInt(outputFee.satoshis) > amount) {
161
+ throw new Error(`can't deduct fees from offboard amount (${outputFee.satoshis} > ${amount})`);
77
162
  }
78
- amount -= fees;
163
+ amount -= BigInt(outputFee.satoshis);
79
164
  const outputs = [
80
165
  {
81
166
  address: destinationAddress,
@@ -90,7 +175,7 @@ export class Ramps {
90
175
  });
91
176
  }
92
177
  return this.wallet.settle({
93
- inputs: vtxos,
178
+ inputs: filteredVtxos,
94
179
  outputs,
95
180
  }, eventCallback);
96
181
  }
@@ -5,7 +5,6 @@ import { ReadonlyWallet, Wallet } from '../wallet.js';
5
5
  import { Request } from './request.js';
6
6
  import { Response } from './response.js';
7
7
  import { RestArkProvider } from '../../providers/ark.js';
8
- import { vtxosToTxs } from '../../utils/transactionHistory.js';
9
8
  import { RestIndexerProvider } from '../../providers/indexer.js';
10
9
  import { hex } from "@scure/base";
11
10
  import { IndexedDBStorageAdapter } from '../../storage/indexedDB.js';
@@ -40,8 +39,8 @@ class ReadonlyHandler {
40
39
  getBoardingAddress() {
41
40
  return this.wallet.getBoardingAddress();
42
41
  }
43
- getBoardingTxs() {
44
- return this.wallet.getBoardingTxs();
42
+ getTransactionHistory() {
43
+ return this.wallet.getTransactionHistory();
45
44
  }
46
45
  async handleReload(_) {
47
46
  const pending = await this.wallet.fetchPendingTxs();
@@ -127,32 +126,6 @@ export class Worker {
127
126
  const address = await this.handler.getBoardingAddress();
128
127
  return await this.walletRepository.getUtxos(address);
129
128
  }
130
- async getTransactionHistory() {
131
- if (!this.handler)
132
- return [];
133
- let txs = [];
134
- try {
135
- const { boardingTxs, commitmentsToIgnore: roundsToIgnore } = await this.handler.getBoardingTxs();
136
- const { spendable, spent } = await this.getAllVtxos();
137
- // convert VTXOs to offchain transactions
138
- const offchainTxs = vtxosToTxs(spendable, spent, roundsToIgnore);
139
- txs = [...boardingTxs, ...offchainTxs];
140
- // sort transactions by creation time in descending order (newest first)
141
- txs.sort(
142
- // place createdAt = 0 (unconfirmed txs) first, then descending
143
- (a, b) => {
144
- if (a.createdAt === 0)
145
- return -1;
146
- if (b.createdAt === 0)
147
- return 1;
148
- return b.createdAt - a.createdAt;
149
- });
150
- }
151
- catch (error) {
152
- console.error("Error getting transaction history:", error);
153
- }
154
- return txs;
155
- }
156
129
  async start(withServiceWorkerUpdate = true) {
157
130
  self.addEventListener("message", async (event) => {
158
131
  await this.handleMessage(event);
@@ -212,7 +185,7 @@ export class Worker {
212
185
  const coins = await this.handler.onchainProvider.getCoins(boardingAddress);
213
186
  await this.walletRepository.saveUtxos(boardingAddress, coins.map((utxo) => extendCoin(this.handler, utxo)));
214
187
  // Get transaction history to cache boarding txs
215
- const txs = await this.getTransactionHistory();
188
+ const txs = await this.handler.getTransactionHistory();
216
189
  if (txs)
217
190
  await this.walletRepository.saveTransactions(address, txs);
218
191
  // unsubscribe previous subscription if any
@@ -561,7 +534,7 @@ export class Worker {
561
534
  return;
562
535
  }
563
536
  try {
564
- const txs = await this.getTransactionHistory();
537
+ const txs = await this.handler.getTransactionHistory();
565
538
  event.source?.postMessage(Response.transactionHistory(message.id, txs));
566
539
  }
567
540
  catch (error) {
@@ -3,7 +3,6 @@ import * as bip68 from "bip68";
3
3
  import { tapLeafHash } from "@scure/btc-signer/payment.js";
4
4
  import { SigHash, Transaction, Address, OutScript } from "@scure/btc-signer";
5
5
  import { sha256 } from "@scure/btc-signer/utils.js";
6
- import { vtxosToTxs } from '../utils/transactionHistory.js';
7
6
  import { ArkAddress } from '../script/address.js';
8
7
  import { DefaultVtxo } from '../script/default.js';
9
8
  import { getNetwork } from '../networks.js';
@@ -26,6 +25,8 @@ import { ContractRepositoryImpl, } from '../repositories/contractRepository.js';
26
25
  import { extendCoin, extendVirtualCoin } from './utils.js';
27
26
  import { ArkError } from '../providers/errors.js';
28
27
  import { Batch } from './batch.js';
28
+ import { Estimator } from '../arkfee/index.js';
29
+ import { buildTransactionHistory } from '../utils/transactionHistory.js';
29
30
  /**
30
31
  * Type guard function to check if an identity has a toReadonly method.
31
32
  */
@@ -229,30 +230,7 @@ export class ReadonlyWallet {
229
230
  scripts: [hex.encode(this.offchainTapscript.pkScript)],
230
231
  });
231
232
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
232
- const spendableVtxos = [];
233
- const spentVtxos = [];
234
- for (const vtxo of response.vtxos) {
235
- if (isSpendable(vtxo)) {
236
- spendableVtxos.push(vtxo);
237
- }
238
- else {
239
- spentVtxos.push(vtxo);
240
- }
241
- }
242
- // convert VTXOs to offchain transactions
243
- const offchainTxs = vtxosToTxs(spendableVtxos, spentVtxos, commitmentsToIgnore);
244
- const txs = [...boardingTxs, ...offchainTxs];
245
- // sort transactions by creation time in descending order (newest first)
246
- txs.sort(
247
- // place createdAt = 0 (unconfirmed txs) first, then descending
248
- (a, b) => {
249
- if (a.createdAt === 0)
250
- return -1;
251
- if (b.createdAt === 0)
252
- return 1;
253
- return b.createdAt - a.createdAt;
254
- });
255
- return txs;
233
+ return buildTransactionHistory(response.vtxos, boardingTxs, commitmentsToIgnore);
256
234
  }
257
235
  async getBoardingTxs() {
258
236
  const utxos = [];
@@ -511,7 +489,23 @@ export class Wallet extends ReadonlyWallet {
511
489
  const virtualCoins = await this.getVirtualCoins({
512
490
  withRecoverable: false,
513
491
  });
514
- const selected = selectVirtualCoins(virtualCoins, params.amount);
492
+ let selected;
493
+ if (params.selectedVtxos) {
494
+ const selectedVtxoSum = params.selectedVtxos
495
+ .map((v) => v.value)
496
+ .reduce((a, b) => a + b, 0);
497
+ if (selectedVtxoSum < params.amount) {
498
+ throw new Error("Selected VTXOs do not cover specified amount");
499
+ }
500
+ const changeAmount = selectedVtxoSum - params.amount;
501
+ selected = {
502
+ inputs: params.selectedVtxos,
503
+ changeAmount: BigInt(changeAmount),
504
+ };
505
+ }
506
+ else {
507
+ selected = selectVirtualCoins(virtualCoins, params.amount);
508
+ }
515
509
  const selectedLeaf = this.offchainTapscript.forfeit();
516
510
  if (!selectedLeaf) {
517
511
  throw new Error("Selected leaf not found");
@@ -641,25 +635,64 @@ export class Wallet extends ReadonlyWallet {
641
635
  // if no params are provided, use all non expired boarding utxos and offchain vtxos as inputs
642
636
  // and send all to the offchain address
643
637
  if (!params) {
638
+ const { fees } = await this.arkProvider.getInfo();
639
+ const estimator = new Estimator(fees.intentFee);
644
640
  let amount = 0;
645
641
  const exitScript = CSVMultisigTapscript.decode(hex.decode(this.boardingTapscript.exitScript));
646
642
  const boardingTimelock = exitScript.params.timelock;
647
643
  const boardingUtxos = (await this.getBoardingUtxos()).filter((utxo) => !hasBoardingTxExpired(utxo, boardingTimelock));
648
- amount += boardingUtxos.reduce((sum, input) => sum + input.value, 0);
644
+ const filteredBoardingUtxos = [];
645
+ for (const utxo of boardingUtxos) {
646
+ const inputFee = estimator.evalOnchainInput({
647
+ amount: BigInt(utxo.value),
648
+ });
649
+ if (inputFee.value >= utxo.value) {
650
+ // skip if fees are greater than the utxo value
651
+ continue;
652
+ }
653
+ filteredBoardingUtxos.push(utxo);
654
+ amount += utxo.value - inputFee.satoshis;
655
+ }
649
656
  const vtxos = await this.getVtxos({ withRecoverable: true });
650
- amount += vtxos.reduce((sum, input) => sum + input.value, 0);
651
- const inputs = [...boardingUtxos, ...vtxos];
657
+ const filteredVtxos = [];
658
+ for (const vtxo of vtxos) {
659
+ const inputFee = estimator.evalOffchainInput({
660
+ amount: BigInt(vtxo.value),
661
+ type: vtxo.virtualStatus.state === "swept"
662
+ ? "recoverable"
663
+ : "vtxo",
664
+ weight: 0,
665
+ birth: vtxo.createdAt,
666
+ expiry: vtxo.virtualStatus.batchExpiry
667
+ ? new Date(vtxo.virtualStatus.batchExpiry * 1000)
668
+ : new Date(),
669
+ });
670
+ if (inputFee.value >= vtxo.value) {
671
+ // skip if fees are greater than the vtxo value
672
+ continue;
673
+ }
674
+ filteredVtxos.push(vtxo);
675
+ amount += vtxo.value - inputFee.satoshis;
676
+ }
677
+ const inputs = [...filteredBoardingUtxos, ...filteredVtxos];
652
678
  if (inputs.length === 0) {
653
679
  throw new Error("No inputs found");
654
680
  }
681
+ const output = {
682
+ address: await this.getAddress(),
683
+ amount: BigInt(amount),
684
+ };
685
+ const outputFee = estimator.evalOffchainOutput({
686
+ amount: output.amount,
687
+ script: hex.encode(ArkAddress.decode(output.address).pkScript),
688
+ });
689
+ output.amount -= BigInt(outputFee.satoshis);
690
+ if (output.amount <= this.dustAmount) {
691
+ throw new Error("Output amount is below dust limit");
692
+ }
655
693
  params = {
656
694
  inputs,
657
- outputs: [
658
- {
659
- address: await this.getAddress(),
660
- amount: BigInt(amount),
661
- },
662
- ],
695
+ outputs: [output],
663
696
  };
664
697
  }
665
698
  const onchainOutputIndexes = [];