@arkade-os/sdk 0.3.11 → 0.3.13

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.
package/README.md CHANGED
@@ -164,9 +164,8 @@ const boardingUtxos = await wallet.getBoardingUtxos()
164
164
  ```typescript
165
165
  // Send bitcoin via Ark
166
166
  const txid = await wallet.sendBitcoin({
167
- address: 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx',
168
- amount: 50000, // in satoshis
169
- feeRate: 1 // optional, in sats/vbyte
167
+ address: 'ark1qq4...', // ark address
168
+ amount: 50000, // in satoshis
170
169
  })
171
170
  ```
172
171
 
@@ -235,14 +235,14 @@ exports.EsploraProvider = EsploraProvider;
235
235
  function isValidBlocksTip(tip) {
236
236
  return (Array.isArray(tip) &&
237
237
  tip.every((t) => {
238
- t &&
238
+ return (t &&
239
239
  typeof t === "object" &&
240
240
  typeof t.id === "string" &&
241
241
  t.id.length > 0 &&
242
242
  typeof t.height === "number" &&
243
243
  t.height >= 0 &&
244
244
  typeof t.mediantime === "number" &&
245
- t.mediantime > 0;
245
+ t.mediantime > 0);
246
246
  }));
247
247
  }
248
248
  const isExplorerTransaction = (tx) => {
@@ -16,7 +16,7 @@ const txKey = {
16
16
  * @param {Set<string>} commitmentsToIgnore - A set of commitment IDs that should be excluded from processing.
17
17
  * @return {ExtendedArkTransaction[]} A sorted array of extended Ark transactions, representing the transaction history.
18
18
  */
19
- function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgnore) {
19
+ async function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgnore, getTxCreatedAt) {
20
20
  const fromOldestVtxo = [...vtxos].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
21
21
  const sent = [];
22
22
  let received = [];
@@ -59,21 +59,23 @@ function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgnore) {
59
59
  if (vtxo.arkTxId &&
60
60
  !sent.some((s) => s.key.arkTxid === vtxo.arkTxId)) {
61
61
  const changes = fromOldestVtxo.filter((_) => _.txid === vtxo.arkTxId);
62
+ // We want to find all the other VTXOs spent by the same transaction to
63
+ // calculate the full amount of the change.
64
+ const allSpent = fromOldestVtxo.filter((v) => v.arkTxId === vtxo.arkTxId);
65
+ const spentAmount = allSpent.reduce((acc, v) => acc + v.value, 0);
62
66
  let txAmount = 0;
63
67
  let txTime = 0;
64
68
  if (changes.length > 0) {
65
69
  const changeAmount = changes.reduce((acc, v) => acc + v.value, 0);
66
- // We want to find all the other VTXOs spent by the same transaction to
67
- // calculate the full amount of the change.
68
- const allSpent = fromOldestVtxo.filter((v) => v.arkTxId === vtxo.arkTxId);
69
- const spentAmount = allSpent.reduce((acc, v) => acc + v.value, 0);
70
70
  txAmount = spentAmount - changeAmount;
71
71
  txTime = changes[0].createdAt.getTime();
72
72
  }
73
73
  else {
74
- txAmount = vtxo.value;
74
+ txAmount = spentAmount;
75
75
  // TODO: fetch the vtxo with /v1/indexer/vtxos?outpoints=<vtxo.arkTxid:0> to know when the tx was made
76
- txTime = vtxo.createdAt.getTime() + 1;
76
+ txTime = getTxCreatedAt
77
+ ? await getTxCreatedAt(vtxo.arkTxId)
78
+ : vtxo.createdAt.getTime() + 1;
77
79
  }
78
80
  sent.push({
79
81
  key: { ...txKey, arkTxid: vtxo.arkTxId },
@@ -1,6 +1,23 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TxWeightEstimator = void 0;
4
+ const btc_signer_1 = require("@scure/btc-signer");
5
+ /**
6
+ * Calculates the byte size required to store a variable-length integer (VarInt).
7
+ * Bitcoin uses VarInts to compact integer data (like array lengths).
8
+ *
9
+ * @param n - The integer value to check
10
+ * @returns The size in bytes (1, 3, 5, or 9)
11
+ */
12
+ const getVarIntSize = (n) => {
13
+ if (n < 0xfd)
14
+ return 1;
15
+ if (n <= 0xffff)
16
+ return 3;
17
+ if (n <= 0xffffffff)
18
+ return 5;
19
+ return 9;
20
+ };
4
21
  class TxWeightEstimator {
5
22
  constructor(hasWitness, inputCount, outputCount, inputSize, inputWitnessSize, outputSize) {
6
23
  this.hasWitness = hasWitness;
@@ -41,16 +58,16 @@ class TxWeightEstimator {
41
58
  1 +
42
59
  leafControlBlockSize;
43
60
  this.inputCount++;
44
- this.inputWitnessSize += leafWitnessSize + controlBlockWitnessSize;
61
+ this.inputWitnessSize += leafWitnessSize + 1 + controlBlockWitnessSize;
45
62
  this.inputSize += TxWeightEstimator.INPUT_SIZE;
46
63
  this.hasWitness = true;
47
- this.inputCount++;
48
64
  return this;
49
65
  }
50
- addP2WKHOutput() {
66
+ addP2WPKHOutput() {
51
67
  this.outputCount++;
52
68
  this.outputSize +=
53
- TxWeightEstimator.OUTPUT_SIZE + TxWeightEstimator.P2WKH_OUTPUT_SIZE;
69
+ TxWeightEstimator.OUTPUT_SIZE +
70
+ TxWeightEstimator.P2WPKH_OUTPUT_SIZE;
54
71
  return this;
55
72
  }
56
73
  addP2TROutput() {
@@ -59,16 +76,24 @@ class TxWeightEstimator {
59
76
  TxWeightEstimator.OUTPUT_SIZE + TxWeightEstimator.P2TR_OUTPUT_SIZE;
60
77
  return this;
61
78
  }
79
+ /**
80
+ * Adds an output given a raw script.
81
+ * Cost = 8 bytes (amount) + varint(scriptLen) + scriptLen
82
+ */
83
+ addOutputScript(script) {
84
+ this.outputCount++;
85
+ this.outputSize += 8 + getVarIntSize(script.length) + script.length;
86
+ return this;
87
+ }
88
+ /**
89
+ * Adds an output by decoding the address to get the exact script size.
90
+ */
91
+ addOutputAddress(address, network) {
92
+ const payment = (0, btc_signer_1.Address)(network).decode(address);
93
+ const script = btc_signer_1.OutScript.encode(payment);
94
+ return this.addOutputScript(script);
95
+ }
62
96
  vsize() {
63
- const getVarIntSize = (n) => {
64
- if (n < 0xfd)
65
- return 1;
66
- if (n < 0xffff)
67
- return 3;
68
- if (n < 0xffffffff)
69
- return 5;
70
- return 9;
71
- };
72
97
  const inputCount = getVarIntSize(this.inputCount);
73
98
  const outputCount = getVarIntSize(this.outputCount);
74
99
  // Calculate the size of the transaction without witness data
@@ -93,7 +118,7 @@ TxWeightEstimator.P2PKH_SCRIPT_SIG_SIZE = 1 + 73 + 1 + 33;
93
118
  TxWeightEstimator.INPUT_SIZE = 32 + 4 + 1 + 4;
94
119
  TxWeightEstimator.BASE_CONTROL_BLOCK_SIZE = 1 + 32;
95
120
  TxWeightEstimator.OUTPUT_SIZE = 8 + 1;
96
- TxWeightEstimator.P2WKH_OUTPUT_SIZE = 1 + 1 + 20;
121
+ TxWeightEstimator.P2WPKH_OUTPUT_SIZE = 1 + 1 + 20;
97
122
  TxWeightEstimator.BASE_TX_SIZE = 8 + 2; // Version + LockTime
98
123
  TxWeightEstimator.WITNESS_HEADER_SIZE = 2; // Flag + Marker
99
124
  TxWeightEstimator.WITNESS_SCALE_FACTOR = 4;
@@ -8,6 +8,7 @@ const onchain_1 = require("../providers/onchain");
8
8
  const anchor_1 = require("../utils/anchor");
9
9
  const txSizeEstimator_1 = require("../utils/txSizeEstimator");
10
10
  const transaction_1 = require("../utils/transaction");
11
+ const utils_1 = require("./utils");
11
12
  /**
12
13
  * Onchain Bitcoin wallet implementation for traditional Bitcoin transactions.
13
14
  *
@@ -59,11 +60,58 @@ class OnchainWallet {
59
60
  const onchainTotal = onchainConfirmed + onchainUnconfirmed;
60
61
  return onchainTotal;
61
62
  }
63
+ /**
64
+ * Iteratively selects coins and estimates transaction fees until convergence.
65
+ *
66
+ * This method handles the circular dependency between coin selection and fee
67
+ * estimation: the fee depends on transaction size, which depends on the number
68
+ * of inputs (selected coins) and whether a change output is needed.
69
+ *
70
+ * The algorithm iterates up to 10 times, refining the fee estimate based on
71
+ * the actual transaction structure. It resolves dust oscillation loops that
72
+ * occur when the change amount hovers near the dust threshold—adding/removing
73
+ * the change output causes the fee to fluctuate, preventing convergence.
74
+ * When a lower fee is computed (indicating the change output was dropped),
75
+ * the function accepts this state to guarantee termination.
76
+ *
77
+ * @param coins - Available coins to select from
78
+ * @param amount - Target send amount in satoshis
79
+ * @param feeRate - Fee rate in sat/vbyte
80
+ * @param recipientAddress - Destination address for size estimation
81
+ * @returns Selected inputs, change amount, and calculated fee
82
+ * @throws Error if fee estimation fails to converge within max iterations
83
+ */
84
+ estimateFeesAndSelectCoins(coins, amount, feeRate, recipientAddress) {
85
+ const MAX_ITERATIONS = 10;
86
+ let fee = 0;
87
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
88
+ const totalNeeded = amount + fee;
89
+ const selected = selectCoins(coins, totalNeeded);
90
+ const estimator = txSizeEstimator_1.TxWeightEstimator.create();
91
+ for (const _ of selected.inputs) {
92
+ estimator.addKeySpendInput();
93
+ }
94
+ estimator.addOutputAddress(recipientAddress, this.network);
95
+ if (selected.changeAmount >= BigInt(utils_1.DUST_AMOUNT)) {
96
+ estimator.addOutputAddress(this.address, this.network);
97
+ }
98
+ const newFee = Number(estimator.vsize().value) * feeRate;
99
+ const roundedNewFee = Math.ceil(newFee);
100
+ // Prevent oscillation loops when change falls just below the dust limit.
101
+ // If removing the change output reduces the fee below our budget,
102
+ // we accept the valid transaction state to guarantee convergence.
103
+ if (roundedNewFee <= fee) {
104
+ return { ...selected, fee: roundedNewFee };
105
+ }
106
+ fee = roundedNewFee;
107
+ }
108
+ throw new Error("Fee estimation failed: could not converge");
109
+ }
62
110
  async send(params) {
63
111
  if (params.amount <= 0) {
64
112
  throw new Error("Amount must be positive");
65
113
  }
66
- if (params.amount < OnchainWallet.DUST_AMOUNT) {
114
+ if (params.amount < utils_1.DUST_AMOUNT) {
67
115
  throw new Error("Amount is below dust limit");
68
116
  }
69
117
  const coins = await this.getCoins();
@@ -74,15 +122,14 @@ class OnchainWallet {
74
122
  if (!feeRate || feeRate < OnchainWallet.MIN_FEE_RATE) {
75
123
  feeRate = OnchainWallet.MIN_FEE_RATE;
76
124
  }
77
- // Ensure fee is an integer by rounding up
78
- const estimatedFee = Math.ceil(174 * feeRate);
79
- const totalNeeded = params.amount + estimatedFee;
80
- // Select coins
81
- const selected = selectCoins(coins, totalNeeded);
125
+ const { inputs, changeAmount } = this.estimateFeesAndSelectCoins(coins, params.amount, feeRate, params.address);
126
+ if (!inputs) {
127
+ throw new Error("Fee estimation failed");
128
+ }
82
129
  // Create transaction
83
130
  let tx = new transaction_1.Transaction();
84
131
  // Add inputs
85
- for (const input of selected.inputs) {
132
+ for (const input of inputs) {
86
133
  tx.addInput({
87
134
  txid: input.txid,
88
135
  index: input.vout,
@@ -95,9 +142,8 @@ class OnchainWallet {
95
142
  }
96
143
  // Add payment output
97
144
  tx.addOutputAddress(params.address, BigInt(params.amount), this.network);
98
- // Add change output if needed
99
- if (selected.changeAmount > 0n) {
100
- tx.addOutputAddress(this.address, selected.changeAmount, this.network);
145
+ if (changeAmount >= BigInt(utils_1.DUST_AMOUNT)) {
146
+ tx.addOutputAddress(this.address, changeAmount, this.network);
101
147
  }
102
148
  // Sign inputs and Finalize
103
149
  tx = await this.identity.sign(tx);
@@ -116,7 +162,7 @@ class OnchainWallet {
116
162
  const childVsize = txSizeEstimator_1.TxWeightEstimator.create()
117
163
  .addKeySpendInput(true)
118
164
  .addP2AInput()
119
- .addP2TROutput()
165
+ .addOutputAddress(this.address, this.network)
120
166
  .vsize().value;
121
167
  const packageVSize = parentVsize + Number(childVsize);
122
168
  let feeRate = await this.provider.getFeeRate();
@@ -160,7 +206,6 @@ class OnchainWallet {
160
206
  }
161
207
  exports.OnchainWallet = OnchainWallet;
162
208
  OnchainWallet.MIN_FEE_RATE = 1; // sat/vbyte
163
- OnchainWallet.DUST_AMOUNT = 546; // sats
164
209
  /**
165
210
  * Select coins to reach a target amount, prioritizing those closer to expiry
166
211
  * @param coins List of coins to select from
@@ -8,6 +8,7 @@ const base_2 = require("../script/base");
8
8
  const txSizeEstimator_1 = require("../utils/txSizeEstimator");
9
9
  const wallet_1 = require("./wallet");
10
10
  const transaction_1 = require("../utils/transaction");
11
+ const utils_1 = require("./utils");
11
12
  var Unroll;
12
13
  (function (Unroll) {
13
14
  let StepType;
@@ -203,7 +204,7 @@ var Unroll;
203
204
  for (const input of inputs) {
204
205
  tx.addInput(input);
205
206
  }
206
- txWeightEstimator.addP2TROutput();
207
+ txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
207
208
  let feeRate = await wallet.onchainProvider.getFeeRate();
208
209
  if (!feeRate || feeRate < wallet_1.Wallet.MIN_FEE_RATE) {
209
210
  feeRate = wallet_1.Wallet.MIN_FEE_RATE;
@@ -212,7 +213,11 @@ var Unroll;
212
213
  if (feeAmount > totalAmount) {
213
214
  throw new Error("fee amount is greater than the total amount");
214
215
  }
215
- tx.addOutputAddress(outputAddress, totalAmount - feeAmount);
216
+ const sendAmount = totalAmount - feeAmount;
217
+ if (sendAmount < BigInt(utils_1.DUST_AMOUNT)) {
218
+ throw new Error("send amount is less than dust amount");
219
+ }
220
+ tx.addOutputAddress(outputAddress, sendAmount);
216
221
  const signedTx = await wallet.identity.sign(tx);
217
222
  signedTx.finalize();
218
223
  await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DUST_AMOUNT = void 0;
3
4
  exports.extendVirtualCoin = extendVirtualCoin;
4
5
  exports.extendCoin = extendCoin;
6
+ exports.DUST_AMOUNT = 546; // sats
5
7
  function extendVirtualCoin(wallet, vtxo) {
6
8
  return {
7
9
  ...vtxo,
@@ -268,7 +268,10 @@ class ReadonlyWallet {
268
268
  scripts: [base_1.hex.encode(this.offchainTapscript.pkScript)],
269
269
  });
270
270
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
271
- return (0, transactionHistory_1.buildTransactionHistory)(response.vtxos, boardingTxs, commitmentsToIgnore);
271
+ const getTxCreatedAt = (txid) => this.indexerProvider
272
+ .getVtxos({ outpoints: [{ txid, vout: 0 }] })
273
+ .then((res) => res.vtxos[0]?.createdAt.getTime() || 0);
274
+ return (0, transactionHistory_1.buildTransactionHistory)(response.vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
272
275
  }
273
276
  async getBoardingTxs() {
274
277
  const utxos = [];
@@ -231,14 +231,14 @@ export class EsploraProvider {
231
231
  function isValidBlocksTip(tip) {
232
232
  return (Array.isArray(tip) &&
233
233
  tip.every((t) => {
234
- t &&
234
+ return (t &&
235
235
  typeof t === "object" &&
236
236
  typeof t.id === "string" &&
237
237
  t.id.length > 0 &&
238
238
  typeof t.height === "number" &&
239
239
  t.height >= 0 &&
240
240
  typeof t.mediantime === "number" &&
241
- t.mediantime > 0;
241
+ t.mediantime > 0);
242
242
  }));
243
243
  }
244
244
  const isExplorerTransaction = (tx) => {
@@ -13,7 +13,7 @@ const txKey = {
13
13
  * @param {Set<string>} commitmentsToIgnore - A set of commitment IDs that should be excluded from processing.
14
14
  * @return {ExtendedArkTransaction[]} A sorted array of extended Ark transactions, representing the transaction history.
15
15
  */
16
- export function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgnore) {
16
+ export async function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgnore, getTxCreatedAt) {
17
17
  const fromOldestVtxo = [...vtxos].sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
18
18
  const sent = [];
19
19
  let received = [];
@@ -56,21 +56,23 @@ export function buildTransactionHistory(vtxos, allBoardingTxs, commitmentsToIgno
56
56
  if (vtxo.arkTxId &&
57
57
  !sent.some((s) => s.key.arkTxid === vtxo.arkTxId)) {
58
58
  const changes = fromOldestVtxo.filter((_) => _.txid === vtxo.arkTxId);
59
+ // We want to find all the other VTXOs spent by the same transaction to
60
+ // calculate the full amount of the change.
61
+ const allSpent = fromOldestVtxo.filter((v) => v.arkTxId === vtxo.arkTxId);
62
+ const spentAmount = allSpent.reduce((acc, v) => acc + v.value, 0);
59
63
  let txAmount = 0;
60
64
  let txTime = 0;
61
65
  if (changes.length > 0) {
62
66
  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
67
  txAmount = spentAmount - changeAmount;
68
68
  txTime = changes[0].createdAt.getTime();
69
69
  }
70
70
  else {
71
- txAmount = vtxo.value;
71
+ txAmount = spentAmount;
72
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;
73
+ txTime = getTxCreatedAt
74
+ ? await getTxCreatedAt(vtxo.arkTxId)
75
+ : vtxo.createdAt.getTime() + 1;
74
76
  }
75
77
  sent.push({
76
78
  key: { ...txKey, arkTxid: vtxo.arkTxId },
@@ -1,3 +1,20 @@
1
+ import { Address, OutScript } from "@scure/btc-signer";
2
+ /**
3
+ * Calculates the byte size required to store a variable-length integer (VarInt).
4
+ * Bitcoin uses VarInts to compact integer data (like array lengths).
5
+ *
6
+ * @param n - The integer value to check
7
+ * @returns The size in bytes (1, 3, 5, or 9)
8
+ */
9
+ const getVarIntSize = (n) => {
10
+ if (n < 0xfd)
11
+ return 1;
12
+ if (n <= 0xffff)
13
+ return 3;
14
+ if (n <= 0xffffffff)
15
+ return 5;
16
+ return 9;
17
+ };
1
18
  export class TxWeightEstimator {
2
19
  constructor(hasWitness, inputCount, outputCount, inputSize, inputWitnessSize, outputSize) {
3
20
  this.hasWitness = hasWitness;
@@ -38,16 +55,16 @@ export class TxWeightEstimator {
38
55
  1 +
39
56
  leafControlBlockSize;
40
57
  this.inputCount++;
41
- this.inputWitnessSize += leafWitnessSize + controlBlockWitnessSize;
58
+ this.inputWitnessSize += leafWitnessSize + 1 + controlBlockWitnessSize;
42
59
  this.inputSize += TxWeightEstimator.INPUT_SIZE;
43
60
  this.hasWitness = true;
44
- this.inputCount++;
45
61
  return this;
46
62
  }
47
- addP2WKHOutput() {
63
+ addP2WPKHOutput() {
48
64
  this.outputCount++;
49
65
  this.outputSize +=
50
- TxWeightEstimator.OUTPUT_SIZE + TxWeightEstimator.P2WKH_OUTPUT_SIZE;
66
+ TxWeightEstimator.OUTPUT_SIZE +
67
+ TxWeightEstimator.P2WPKH_OUTPUT_SIZE;
51
68
  return this;
52
69
  }
53
70
  addP2TROutput() {
@@ -56,16 +73,24 @@ export class TxWeightEstimator {
56
73
  TxWeightEstimator.OUTPUT_SIZE + TxWeightEstimator.P2TR_OUTPUT_SIZE;
57
74
  return this;
58
75
  }
76
+ /**
77
+ * Adds an output given a raw script.
78
+ * Cost = 8 bytes (amount) + varint(scriptLen) + scriptLen
79
+ */
80
+ addOutputScript(script) {
81
+ this.outputCount++;
82
+ this.outputSize += 8 + getVarIntSize(script.length) + script.length;
83
+ return this;
84
+ }
85
+ /**
86
+ * Adds an output by decoding the address to get the exact script size.
87
+ */
88
+ addOutputAddress(address, network) {
89
+ const payment = Address(network).decode(address);
90
+ const script = OutScript.encode(payment);
91
+ return this.addOutputScript(script);
92
+ }
59
93
  vsize() {
60
- const getVarIntSize = (n) => {
61
- if (n < 0xfd)
62
- return 1;
63
- if (n < 0xffff)
64
- return 3;
65
- if (n < 0xffffffff)
66
- return 5;
67
- return 9;
68
- };
69
94
  const inputCount = getVarIntSize(this.inputCount);
70
95
  const outputCount = getVarIntSize(this.outputCount);
71
96
  // Calculate the size of the transaction without witness data
@@ -89,7 +114,7 @@ TxWeightEstimator.P2PKH_SCRIPT_SIG_SIZE = 1 + 73 + 1 + 33;
89
114
  TxWeightEstimator.INPUT_SIZE = 32 + 4 + 1 + 4;
90
115
  TxWeightEstimator.BASE_CONTROL_BLOCK_SIZE = 1 + 32;
91
116
  TxWeightEstimator.OUTPUT_SIZE = 8 + 1;
92
- TxWeightEstimator.P2WKH_OUTPUT_SIZE = 1 + 1 + 20;
117
+ TxWeightEstimator.P2WPKH_OUTPUT_SIZE = 1 + 1 + 20;
93
118
  TxWeightEstimator.BASE_TX_SIZE = 8 + 2; // Version + LockTime
94
119
  TxWeightEstimator.WITNESS_HEADER_SIZE = 2; // Flag + Marker
95
120
  TxWeightEstimator.WITNESS_SCALE_FACTOR = 4;
@@ -4,6 +4,7 @@ import { ESPLORA_URL, EsploraProvider, } from '../providers/onchain.js';
4
4
  import { findP2AOutput, P2A } from '../utils/anchor.js';
5
5
  import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
6
6
  import { Transaction } from '../utils/transaction.js';
7
+ import { DUST_AMOUNT } from './utils.js';
7
8
  /**
8
9
  * Onchain Bitcoin wallet implementation for traditional Bitcoin transactions.
9
10
  *
@@ -55,11 +56,58 @@ export class OnchainWallet {
55
56
  const onchainTotal = onchainConfirmed + onchainUnconfirmed;
56
57
  return onchainTotal;
57
58
  }
59
+ /**
60
+ * Iteratively selects coins and estimates transaction fees until convergence.
61
+ *
62
+ * This method handles the circular dependency between coin selection and fee
63
+ * estimation: the fee depends on transaction size, which depends on the number
64
+ * of inputs (selected coins) and whether a change output is needed.
65
+ *
66
+ * The algorithm iterates up to 10 times, refining the fee estimate based on
67
+ * the actual transaction structure. It resolves dust oscillation loops that
68
+ * occur when the change amount hovers near the dust threshold—adding/removing
69
+ * the change output causes the fee to fluctuate, preventing convergence.
70
+ * When a lower fee is computed (indicating the change output was dropped),
71
+ * the function accepts this state to guarantee termination.
72
+ *
73
+ * @param coins - Available coins to select from
74
+ * @param amount - Target send amount in satoshis
75
+ * @param feeRate - Fee rate in sat/vbyte
76
+ * @param recipientAddress - Destination address for size estimation
77
+ * @returns Selected inputs, change amount, and calculated fee
78
+ * @throws Error if fee estimation fails to converge within max iterations
79
+ */
80
+ estimateFeesAndSelectCoins(coins, amount, feeRate, recipientAddress) {
81
+ const MAX_ITERATIONS = 10;
82
+ let fee = 0;
83
+ for (let i = 0; i < MAX_ITERATIONS; i++) {
84
+ const totalNeeded = amount + fee;
85
+ const selected = selectCoins(coins, totalNeeded);
86
+ const estimator = TxWeightEstimator.create();
87
+ for (const _ of selected.inputs) {
88
+ estimator.addKeySpendInput();
89
+ }
90
+ estimator.addOutputAddress(recipientAddress, this.network);
91
+ if (selected.changeAmount >= BigInt(DUST_AMOUNT)) {
92
+ estimator.addOutputAddress(this.address, this.network);
93
+ }
94
+ const newFee = Number(estimator.vsize().value) * feeRate;
95
+ const roundedNewFee = Math.ceil(newFee);
96
+ // Prevent oscillation loops when change falls just below the dust limit.
97
+ // If removing the change output reduces the fee below our budget,
98
+ // we accept the valid transaction state to guarantee convergence.
99
+ if (roundedNewFee <= fee) {
100
+ return { ...selected, fee: roundedNewFee };
101
+ }
102
+ fee = roundedNewFee;
103
+ }
104
+ throw new Error("Fee estimation failed: could not converge");
105
+ }
58
106
  async send(params) {
59
107
  if (params.amount <= 0) {
60
108
  throw new Error("Amount must be positive");
61
109
  }
62
- if (params.amount < OnchainWallet.DUST_AMOUNT) {
110
+ if (params.amount < DUST_AMOUNT) {
63
111
  throw new Error("Amount is below dust limit");
64
112
  }
65
113
  const coins = await this.getCoins();
@@ -70,15 +118,14 @@ export class OnchainWallet {
70
118
  if (!feeRate || feeRate < OnchainWallet.MIN_FEE_RATE) {
71
119
  feeRate = OnchainWallet.MIN_FEE_RATE;
72
120
  }
73
- // Ensure fee is an integer by rounding up
74
- const estimatedFee = Math.ceil(174 * feeRate);
75
- const totalNeeded = params.amount + estimatedFee;
76
- // Select coins
77
- const selected = selectCoins(coins, totalNeeded);
121
+ const { inputs, changeAmount } = this.estimateFeesAndSelectCoins(coins, params.amount, feeRate, params.address);
122
+ if (!inputs) {
123
+ throw new Error("Fee estimation failed");
124
+ }
78
125
  // Create transaction
79
126
  let tx = new Transaction();
80
127
  // Add inputs
81
- for (const input of selected.inputs) {
128
+ for (const input of inputs) {
82
129
  tx.addInput({
83
130
  txid: input.txid,
84
131
  index: input.vout,
@@ -91,9 +138,8 @@ export class OnchainWallet {
91
138
  }
92
139
  // Add payment output
93
140
  tx.addOutputAddress(params.address, BigInt(params.amount), this.network);
94
- // Add change output if needed
95
- if (selected.changeAmount > 0n) {
96
- tx.addOutputAddress(this.address, selected.changeAmount, this.network);
141
+ if (changeAmount >= BigInt(DUST_AMOUNT)) {
142
+ tx.addOutputAddress(this.address, changeAmount, this.network);
97
143
  }
98
144
  // Sign inputs and Finalize
99
145
  tx = await this.identity.sign(tx);
@@ -112,7 +158,7 @@ export class OnchainWallet {
112
158
  const childVsize = TxWeightEstimator.create()
113
159
  .addKeySpendInput(true)
114
160
  .addP2AInput()
115
- .addP2TROutput()
161
+ .addOutputAddress(this.address, this.network)
116
162
  .vsize().value;
117
163
  const packageVSize = parentVsize + Number(childVsize);
118
164
  let feeRate = await this.provider.getFeeRate();
@@ -155,7 +201,6 @@ export class OnchainWallet {
155
201
  }
156
202
  }
157
203
  OnchainWallet.MIN_FEE_RATE = 1; // sat/vbyte
158
- OnchainWallet.DUST_AMOUNT = 546; // sats
159
204
  /**
160
205
  * Select coins to reach a target amount, prioritizing those closer to expiry
161
206
  * @param coins List of coins to select from
@@ -5,6 +5,7 @@ import { VtxoScript } from '../script/base.js';
5
5
  import { TxWeightEstimator } from '../utils/txSizeEstimator.js';
6
6
  import { Wallet } from './wallet.js';
7
7
  import { Transaction } from '../utils/transaction.js';
8
+ import { DUST_AMOUNT } from './utils.js';
8
9
  export var Unroll;
9
10
  (function (Unroll) {
10
11
  let StepType;
@@ -200,7 +201,7 @@ export var Unroll;
200
201
  for (const input of inputs) {
201
202
  tx.addInput(input);
202
203
  }
203
- txWeightEstimator.addP2TROutput();
204
+ txWeightEstimator.addOutputAddress(outputAddress, wallet.network);
204
205
  let feeRate = await wallet.onchainProvider.getFeeRate();
205
206
  if (!feeRate || feeRate < Wallet.MIN_FEE_RATE) {
206
207
  feeRate = Wallet.MIN_FEE_RATE;
@@ -209,7 +210,11 @@ export var Unroll;
209
210
  if (feeAmount > totalAmount) {
210
211
  throw new Error("fee amount is greater than the total amount");
211
212
  }
212
- tx.addOutputAddress(outputAddress, totalAmount - feeAmount);
213
+ const sendAmount = totalAmount - feeAmount;
214
+ if (sendAmount < BigInt(DUST_AMOUNT)) {
215
+ throw new Error("send amount is less than dust amount");
216
+ }
217
+ tx.addOutputAddress(outputAddress, sendAmount);
213
218
  const signedTx = await wallet.identity.sign(tx);
214
219
  signedTx.finalize();
215
220
  await wallet.onchainProvider.broadcastTransaction(signedTx.hex);
@@ -1,3 +1,4 @@
1
+ export const DUST_AMOUNT = 546; // sats
1
2
  export function extendVirtualCoin(wallet, vtxo) {
2
3
  return {
3
4
  ...vtxo,
@@ -230,7 +230,10 @@ export class ReadonlyWallet {
230
230
  scripts: [hex.encode(this.offchainTapscript.pkScript)],
231
231
  });
232
232
  const { boardingTxs, commitmentsToIgnore } = await this.getBoardingTxs();
233
- return buildTransactionHistory(response.vtxos, boardingTxs, commitmentsToIgnore);
233
+ const getTxCreatedAt = (txid) => this.indexerProvider
234
+ .getVtxos({ outpoints: [{ txid, vout: 0 }] })
235
+ .then((res) => res.vtxos[0]?.createdAt.getTime() || 0);
236
+ return buildTransactionHistory(response.vtxos, boardingTxs, commitmentsToIgnore, getTxCreatedAt);
234
237
  }
235
238
  async getBoardingTxs() {
236
239
  const utxos = [];
@@ -11,5 +11,5 @@ type ExtendedArkTransaction = ArkTransaction & {
11
11
  * @param {Set<string>} commitmentsToIgnore - A set of commitment IDs that should be excluded from processing.
12
12
  * @return {ExtendedArkTransaction[]} A sorted array of extended Ark transactions, representing the transaction history.
13
13
  */
14
- export declare function buildTransactionHistory(vtxos: VirtualCoin[], allBoardingTxs: ArkTransaction[], commitmentsToIgnore: Set<string>): ExtendedArkTransaction[];
14
+ export declare function buildTransactionHistory(vtxos: VirtualCoin[], allBoardingTxs: ArkTransaction[], commitmentsToIgnore: Set<string>, getTxCreatedAt?: (txid: string) => Promise<number>): Promise<ExtendedArkTransaction[]>;
15
15
  export {};
@@ -1,3 +1,4 @@
1
+ import { Network } from "../networks";
1
2
  export type VSize = {
2
3
  value: bigint;
3
4
  fee(feeRate: bigint): bigint;
@@ -7,7 +8,7 @@ export declare class TxWeightEstimator {
7
8
  static readonly INPUT_SIZE: number;
8
9
  static readonly BASE_CONTROL_BLOCK_SIZE: number;
9
10
  static readonly OUTPUT_SIZE: number;
10
- static readonly P2WKH_OUTPUT_SIZE: number;
11
+ static readonly P2WPKH_OUTPUT_SIZE: number;
11
12
  static readonly BASE_TX_SIZE: number;
12
13
  static readonly WITNESS_HEADER_SIZE = 2;
13
14
  static readonly WITNESS_SCALE_FACTOR = 4;
@@ -24,7 +25,16 @@ export declare class TxWeightEstimator {
24
25
  addKeySpendInput(isDefault?: boolean): TxWeightEstimator;
25
26
  addP2PKHInput(): TxWeightEstimator;
26
27
  addTapscriptInput(leafWitnessSize: number, leafScriptSize: number, leafControlBlockSize: number): TxWeightEstimator;
27
- addP2WKHOutput(): TxWeightEstimator;
28
+ addP2WPKHOutput(): TxWeightEstimator;
28
29
  addP2TROutput(): TxWeightEstimator;
30
+ /**
31
+ * Adds an output given a raw script.
32
+ * Cost = 8 bytes (amount) + varint(scriptLen) + scriptLen
33
+ */
34
+ addOutputScript(script: Uint8Array): TxWeightEstimator;
35
+ /**
36
+ * Adds an output by decoding the address to get the exact script size.
37
+ */
38
+ addOutputAddress(address: string, network: Network): TxWeightEstimator;
29
39
  vsize(): VSize;
30
40
  }
@@ -25,7 +25,6 @@ import { Transaction } from "../utils/transaction";
25
25
  export declare class OnchainWallet implements AnchorBumper {
26
26
  private identity;
27
27
  static MIN_FEE_RATE: number;
28
- static DUST_AMOUNT: number;
29
28
  readonly onchainP2TR: P2TR;
30
29
  readonly provider: OnchainProvider;
31
30
  readonly network: Network;
@@ -34,6 +33,28 @@ export declare class OnchainWallet implements AnchorBumper {
34
33
  get address(): string;
35
34
  getCoins(): Promise<Coin[]>;
36
35
  getBalance(): Promise<number>;
36
+ /**
37
+ * Iteratively selects coins and estimates transaction fees until convergence.
38
+ *
39
+ * This method handles the circular dependency between coin selection and fee
40
+ * estimation: the fee depends on transaction size, which depends on the number
41
+ * of inputs (selected coins) and whether a change output is needed.
42
+ *
43
+ * The algorithm iterates up to 10 times, refining the fee estimate based on
44
+ * the actual transaction structure. It resolves dust oscillation loops that
45
+ * occur when the change amount hovers near the dust threshold—adding/removing
46
+ * the change output causes the fee to fluctuate, preventing convergence.
47
+ * When a lower fee is computed (indicating the change output was dropped),
48
+ * the function accepts this state to guarantee termination.
49
+ *
50
+ * @param coins - Available coins to select from
51
+ * @param amount - Target send amount in satoshis
52
+ * @param feeRate - Fee rate in sat/vbyte
53
+ * @param recipientAddress - Destination address for size estimation
54
+ * @returns Selected inputs, change amount, and calculated fee
55
+ * @throws Error if fee estimation fails to converge within max iterations
56
+ */
57
+ private estimateFeesAndSelectCoins;
37
58
  send(params: SendBitcoinParams): Promise<string>;
38
59
  bumpP2A(parent: Transaction): Promise<[string, string]>;
39
60
  }
@@ -1,5 +1,6 @@
1
1
  import type { Coin, ExtendedCoin, ExtendedVirtualCoin, VirtualCoin } from "..";
2
2
  import { ReadonlyWallet } from "./wallet";
3
+ export declare const DUST_AMOUNT = 546;
3
4
  export declare function extendVirtualCoin(wallet: {
4
5
  offchainTapscript: ReadonlyWallet["offchainTapscript"];
5
6
  }, vtxo: VirtualCoin): ExtendedVirtualCoin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arkade-os/sdk",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Bitcoin wallet SDK with Taproot and Ark integration",
5
5
  "type": "module",
6
6
  "main": "./dist/cjs/index.js",
@@ -53,24 +53,24 @@
53
53
  "registry": "https://registry.npmjs.org/"
54
54
  },
55
55
  "dependencies": {
56
+ "@marcbachmann/cel-js": "7.3.1",
56
57
  "@noble/curves": "2.0.0",
57
58
  "@noble/secp256k1": "3.0.0",
58
59
  "@scure/base": "2.0.0",
59
60
  "@scure/btc-signer": "2.0.1",
60
- "bip68": "1.0.4",
61
- "@marcbachmann/cel-js": "7.0.0"
61
+ "bip68": "1.0.4"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@types/node": "24.3.1",
65
65
  "@vitest/coverage-v8": "3.2.4",
66
- "esbuild": "^0.25.9",
67
- "expo": "~52.0.47",
66
+ "esbuild": "^0.27.3",
68
67
  "eventsource": "4.0.0",
69
- "glob": "11.0.3",
68
+ "expo": "~52.0.49",
69
+ "glob": "11.1.0",
70
70
  "husky": "9.1.7",
71
71
  "prettier": "3.6.2",
72
- "rimraf": "^6.0.1",
73
- "typedoc": "^0.28.12",
72
+ "rimraf": "^6.1.2",
73
+ "typedoc": "^0.28.16",
74
74
  "typescript": "5.9.2",
75
75
  "vitest": "3.2.4"
76
76
  },