@exodus/bitcoin-api 4.9.3 → 4.9.5

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/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [4.9.5](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.4...@exodus/bitcoin-api@4.9.5) (2026-02-14)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: Remove ordinals paths from bitcoin monitor scanner (#7430)
13
+
14
+
15
+
16
+ ## [4.9.4](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.3...@exodus/bitcoin-api@4.9.4) (2026-02-10)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+
22
+ * fix(bitcoin-api): Fix empty UTXO selection in BTC sends (#7370)
23
+
24
+ * fix(bitcoin-api): Gate send-all RBF eligibility (#7373)
25
+
26
+ * fix(bitcoin-api): Refine bump UTXO selection controls (#7372)
27
+
28
+ * fix(bitcoin-api): Replace changeAddress with internalOutputs (#7378)
29
+
30
+ * fix(bitcoin-api): Tighten RBF send-all replacement eligibility (#7371)
31
+
32
+
33
+
6
34
  ## [4.9.3](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.2...@exodus/bitcoin-api@4.9.3) (2026-01-30)
7
35
 
8
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "4.9.3",
3
+ "version": "4.9.5",
4
4
  "description": "Bitcoin transaction and fee monitors, RPC with the blockchain node, other networking code.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -61,5 +61,5 @@
61
61
  "type": "git",
62
62
  "url": "git+https://github.com/ExodusMovement/assets.git"
63
63
  },
64
- "gitHead": "536e8318e84aa9a812191ff81f3ddd70a518885e"
64
+ "gitHead": "8c4fcde522beb978df6a5099e0fa9e34bb4a7e04"
65
65
  }
@@ -80,8 +80,13 @@ const _canBumpTx = ({
80
80
  return { errorMessage: 'already confirmed' }
81
81
  }
82
82
 
83
- // Can't bump an rbf tx if change is already spent
84
- if (bumpTx && bumpTx.data.changeAddress && changeUtxos.size === 0) {
83
+ // Can't RBF-bump if any internal output is already spent.
84
+ // Replacing would conflict with the child tx that spent the original output.
85
+ if (
86
+ bumpTx &&
87
+ ((bumpTx.data.internalOutputs && bumpTx.data.internalOutputs.length > changeUtxos.size) ||
88
+ (bumpTx.data.changeAddress && changeUtxos.size === 0))
89
+ ) {
85
90
  return {
86
91
  errorMessage: 'already spent',
87
92
  }
@@ -100,25 +105,30 @@ const _canBumpTx = ({
100
105
  utxosDescendingOrder,
101
106
  taprootInputWitnessSize,
102
107
  changeAddressType,
108
+ isBumpTx: true,
103
109
  })
104
110
  if (replaceTx) return { bumpType: BumpType.RBF, bumpFee: fee.sub(replaceTx.feeAmount) }
105
111
  }
106
112
 
107
- const { fee } = selectUtxos({
113
+ const { selectedUtxos, fee } = selectUtxos({
108
114
  asset,
109
115
  usableUtxos,
110
116
  feeRate,
111
117
  receiveAddress: changeAddressType,
112
118
  getFeeEstimator,
113
- mustSpendUtxos: changeUtxos,
119
+ spendUtxos: changeUtxos,
120
+ forceSpendUtxos: true,
114
121
  allowUnconfirmedRbfEnabledUtxos,
115
122
  unconfirmedTxAncestor,
116
123
  utxosDescendingOrder,
117
124
  taprootInputWitnessSize,
118
125
  changeAddressType,
126
+ isBumpTx: true,
119
127
  })
120
128
 
121
- return fee ? { bumpType: BumpType.CPFP, bumpFee: fee } : { errorMessage: 'insufficient funds' }
129
+ return selectedUtxos?.size
130
+ ? { bumpType: BumpType.CPFP, bumpFee: fee }
131
+ : { errorMessage: 'insufficient funds' }
122
132
  }
123
133
 
124
134
  const validateIsNumber = (number, name) => {
@@ -18,6 +18,19 @@ const getBestReceiveAddresses = ({ asset, receiveAddress }) => {
18
18
  return ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name) ? 'P2WSH' : 'P2PKH'
19
19
  }
20
20
 
21
+ // Send-all replacement is limited to a single replaceable tx in this implementation.
22
+ // Multi-tx RBF is possible, but we don't implement it here due to complexity.
23
+ // We also require all unconfirmed UTXOs to belong to that tx to avoid leftover
24
+ // unconfirmed balance.
25
+ const canReplaceSendAll = ({ isSendAll, usableUtxos, confirmedUtxosArray, replaceableTxs }) => {
26
+ if (!isSendAll) return true
27
+ if (!replaceableTxs || replaceableTxs.length !== 1) return false
28
+
29
+ const unconfirmedUtxoCount = usableUtxos.size - confirmedUtxosArray.length
30
+ const replaceableTxUtxoCount = usableUtxos.getTxIdUtxos(replaceableTxs[0].txId).size
31
+ return unconfirmedUtxoCount === replaceableTxUtxoCount
32
+ }
33
+
21
34
  export const selectUtxos = ({
22
35
  asset,
23
36
  utxosDescendingOrder,
@@ -30,7 +43,9 @@ export const selectUtxos = ({
30
43
  isSendAll,
31
44
  getFeeEstimator,
32
45
  disableReplacement = false,
33
- mustSpendUtxos,
46
+ spendUtxos,
47
+ forceSpendUtxos = false,
48
+ isBumpTx = false,
34
49
  allowUnconfirmedRbfEnabledUtxos,
35
50
  unconfirmedTxAncestor,
36
51
  taprootInputWitnessSize,
@@ -53,20 +68,53 @@ export const selectUtxos = ({
53
68
  const { currency } = asset
54
69
  if (!amount) amount = currency.ZERO
55
70
 
56
- // We can only replace for a sendAll if only 1 replaceable tx and no unconfirmed utxos
57
71
  const confirmedUtxosArray = getConfirmedUtxos({ asset, utxos: usableUtxos }).toArray()
58
72
  const canReplace =
59
- !mustSpendUtxos &&
73
+ !forceSpendUtxos &&
60
74
  !disableReplacement &&
61
75
  replaceableTxs &&
62
- (!isSendAll ||
63
- (replaceableTxs.length === 1 && confirmedUtxosArray.length === usableUtxos.size - 1))
76
+ canReplaceSendAll({ isSendAll, usableUtxos, confirmedUtxosArray, replaceableTxs })
64
77
 
65
78
  if (canReplace) {
66
79
  for (const tx of replaceableTxs) {
80
+ // We do not RBF‑replace transactions with no external (sent) outputs (self‑sends).
81
+ //
82
+ // Reason: if the original replaceable tx was created outside this wallet,
83
+ // we do not have the original intent metadata that tells us which internal
84
+ // output was the user’s destination and which output was wallet‑generated
85
+ // change.
86
+ //
87
+ // This ambiguity is real:
88
+ // - A user can intentionally self‑send to a change address, and the wallet
89
+ // can still add another change output, resulting in multiple change outputs.
90
+ // - In single‑address mode, receive and change resolve to the same address,
91
+ // so outputs are indistinguishable.
92
+ //
93
+ // If we keep all internal outputs (change and self send outputs) during replacement, the tx grows, fees
94
+ // increase, and the user sees multiple internal outputs (confusing UX).
95
+ // If we treat any internal output as change via heuristics, we can violate user intent.
96
+ //
97
+ // Our solution:
98
+ // To avoid incorrect behavior and confusing UX, we do not RBF‑replace
99
+ // self‑send transactions. We instead attempt to fee‑bump them via chaining
100
+ // (CPFP) when possible.
101
+ //
102
+ // During replacement, internal outputs are removed and their output values are treated
103
+ // as inputs of the new transaction;
104
+ if (tx.data.sent.length === 0) continue
105
+
67
106
  const changeUtxos = usableUtxos.getTxIdUtxos(tx.txId)
68
- // Don't replace a tx that has already been spent
69
- if (tx.data.changeAddress && changeUtxos.size === 0) continue
107
+
108
+ // Do not replace a tx if any of its UTXOs were already spent by another tx.
109
+ // Replacing would conflict with the child tx that spent the original output.
110
+ // Use changeAddress as a legacy fallback for txs created before internalOutputs were introduced.
111
+ if (
112
+ (tx.data.internalOutputs && tx.data.internalOutputs.length > changeUtxos.size) ||
113
+ (tx.data.changeAddress && changeUtxos.size === 0)
114
+ ) {
115
+ continue
116
+ }
117
+
70
118
  const feePerKB =
71
119
  tx.data.feePerKB + MIN_RELAY_FEE > feeRate.toBaseNumber()
72
120
  ? currency.baseUnit(tx.data.feePerKB + MIN_RELAY_FEE)
@@ -125,7 +173,7 @@ export const selectUtxos = ({
125
173
  }
126
174
  }
127
175
 
128
- if (isSendAll || replaceTxAmount.add(additionalUtxos.value).gte(amount.add(fee))) {
176
+ if (replaceTxAmount.add(additionalUtxos.value).gte(amount.add(fee))) {
129
177
  const chainOutputs = isSendAll ? receiveAddresses : [...receiveAddresses, changeAddressType]
130
178
  const chainFee = feeEstimator({
131
179
  inputs: changeUtxos.union(additionalUtxos),
@@ -133,8 +181,11 @@ export const selectUtxos = ({
133
181
  taprootInputWitnessSize,
134
182
  })
135
183
  // If batching is same or more expensive than chaining, don't replace
136
- // Unless we are accelerating a changeless tx, then we must replace because it can't be chained
137
- if ((!amount.isZero || tx.data.changeAddress) && fee.sub(tx.feeAmount).gte(chainFee)) {
184
+ // Unless we are accelerating a tx with no internal outputs, then we must replace because it can't be chained
185
+ if (
186
+ (!isBumpTx || tx.data.internalOutputs?.length || tx.data.changeAddress) &&
187
+ fee.sub(tx.feeAmount).gte(chainFee)
188
+ ) {
138
189
  continue
139
190
  }
140
191
 
@@ -143,13 +194,19 @@ export const selectUtxos = ({
143
194
  }
144
195
  }
145
196
 
146
- if (!mustSpendUtxos) mustSpendUtxos = UtxoCollection.createEmpty({ currency })
197
+ // For bumping: if RBF is not possible, the only valid path is CPFP,
198
+ // and only if spendUtxos are available.
199
+ if (isBumpTx && !spendUtxos?.size) {
200
+ return { selectedUtxos: undefined, fee: undefined, replaceTx: undefined }
201
+ }
202
+
203
+ if (!spendUtxos) spendUtxos = UtxoCollection.createEmpty({ currency })
147
204
 
148
205
  // We can still spend our rbf utxos, but put them last
149
206
  let ourRbfUtxos = UtxoCollection.createEmpty({ currency })
150
207
  if (replaceableTxs) {
151
208
  for (const tx of replaceableTxs) {
152
- if (!tx.data.changeAddress) continue
209
+ if (!tx.data.internalOutputs?.length && !tx.data.changeAddress) continue
153
210
  const changeUtxos = usableUtxos.getTxIdUtxos(tx.txId)
154
211
  ourRbfUtxos = ourRbfUtxos.union(changeUtxos)
155
212
  }
@@ -181,14 +238,14 @@ export const selectUtxos = ({
181
238
  }
182
239
 
183
240
  // quickly add utxos to get to amount before starting to figure out fees, the minimum place to start is as much as the amount
184
- const selectedUtxosArray = mustSpendUtxos.toArray()
241
+ const selectedUtxosArray = spendUtxos.toArray()
185
242
  let selectedUtxosValue = selectedUtxosArray.reduce(
186
243
  (total, utxo) => total.add(utxo.value),
187
244
  currency.ZERO
188
245
  )
189
246
  const remainingUtxosArray = _toPriorityOrderedArray({
190
247
  utxosDescendingOrder,
191
- utxos: spendableUtxos.union(ourRbfUtxos).difference(mustSpendUtxos),
248
+ utxos: spendableUtxos.union(ourRbfUtxos).difference(spendUtxos),
192
249
  })
193
250
 
194
251
  while (selectedUtxosValue.lte(amount) && remainingUtxosArray.length > 0) {
@@ -238,7 +295,8 @@ export const getUtxosData = ({
238
295
  isSendAll,
239
296
  getFeeEstimator,
240
297
  disableReplacement,
241
- mustSpendUtxos,
298
+ spendUtxos,
299
+ forceSpendUtxos,
242
300
  allowUnconfirmedRbfEnabledUtxos,
243
301
  unconfirmedTxAncestor,
244
302
  utxosDescendingOrder,
@@ -259,7 +317,8 @@ export const getUtxosData = ({
259
317
  isSendAll,
260
318
  getFeeEstimator,
261
319
  disableReplacement,
262
- mustSpendUtxos,
320
+ spendUtxos,
321
+ forceSpendUtxos,
263
322
  allowUnconfirmedRbfEnabledUtxos,
264
323
  unconfirmedTxAncestor,
265
324
  utxosDescendingOrder,
@@ -280,7 +339,7 @@ export const getUtxosData = ({
280
339
 
281
340
  const spendableBalance = spendableUtxos.value
282
341
 
283
- const extraFeeCpfp = selectedUtxos
342
+ const extraFeeCpfp = selectedUtxos?.size
284
343
  ? asset.currency.baseUnit(
285
344
  getExtraFee({
286
345
  asset,
@@ -90,24 +90,26 @@ function processAddress({ asset, toAddress }) {
90
90
  // Determine strategy for transaction bumping (RBF vs CPFP)
91
91
  function determineBumpStrategy({ bumpTxId, replaceableTxs, usableUtxos }) {
92
92
  if (!bumpTxId) {
93
- return { replaceableTxs, utxosToBump: undefined }
93
+ return { replaceableTxs, utxosToBump: undefined, forceUtxosToBump: false }
94
94
  }
95
95
 
96
+ const utxosToBump = usableUtxos.getTxIdUtxos(bumpTxId)
97
+
96
98
  // Check if we can use RBF (Replace-By-Fee)
97
99
  const bumpTx = replaceableTxs.find(({ txId }) => txId === bumpTxId)
98
100
 
99
101
  if (bumpTx) {
100
- // Use RBF: replace the transaction directly
101
- return { replaceableTxs: [bumpTx], utxosToBump: undefined }
102
+ // Use RBF: replace the transaction directly.
103
+ // If CPFP is cheaper, then we will use that instead of RBF.
104
+ return { replaceableTxs: [bumpTx], utxosToBump, forceUtxosToBump: false }
102
105
  }
103
106
 
104
- // Otherwise try CPFP (Child-Pays-For-Parent) by spending the transaction's outputs
105
- const utxosToBump = usableUtxos.getTxIdUtxos(bumpTxId)
107
+ // Otherwise only option is to try CPFP (Child-Pays-For-Parent) by spending the transaction's outputs
106
108
  if (utxosToBump.size === 0) {
107
109
  throw new Error(`Cannot bump transaction ${bumpTxId}`)
108
110
  }
109
111
 
110
- return { replaceableTxs: [], utxosToBump }
112
+ return { replaceableTxs: [], utxosToBump, forceUtxosToBump: true }
111
113
  }
112
114
 
113
115
  // Prepare parameters for UTXO selection
@@ -387,7 +389,11 @@ const transferHandler = {
387
389
  let replaceableTxs = findUnconfirmedSentRbfTxs(context.txSet)
388
390
 
389
391
  // Determine bumping strategy (RBF or CPFP)
390
- const { replaceableTxs: updatedReplaceableTxs, utxosToBump } = determineBumpStrategy({
392
+ const {
393
+ replaceableTxs: updatedReplaceableTxs,
394
+ utxosToBump,
395
+ forceUtxosToBump,
396
+ } = determineBumpStrategy({
391
397
  bumpTxId,
392
398
  replaceableTxs,
393
399
  usableUtxos: context.usableUtxos,
@@ -418,7 +424,9 @@ const transferHandler = {
418
424
  isSendAll: utxoParams.resolvedIsSendAll,
419
425
  getFeeEstimator: (asset, { feePerKB, ...options }) =>
420
426
  getFeeEstimator(asset, feePerKB, options),
421
- mustSpendUtxos: utxosToBump,
427
+ spendUtxos: utxosToBump,
428
+ forceSpendUtxos: forceUtxosToBump,
429
+ isBumpTx: !!bumpTxId,
422
430
  allowUnconfirmedRbfEnabledUtxos,
423
431
  unconfirmedTxAncestor: context.unconfirmedTxAncestor,
424
432
  utxosDescendingOrder,
@@ -426,7 +434,7 @@ const transferHandler = {
426
434
  changeAddressType,
427
435
  })
428
436
 
429
- if (!selectedUtxos && !replaceTx) {
437
+ if (!selectedUtxos?.size && !replaceTx) {
430
438
  throw new Error('Not enough funds.')
431
439
  }
432
440
 
@@ -435,7 +443,7 @@ const transferHandler = {
435
443
  // then something is wrong because we can't actually bump the tx.
436
444
  // This shouldn't happen but might due to either the tx confirming before accelerate was
437
445
  // pressed, or if the change was already spent from another wallet.
438
- if (bumpTxId && (!selectedUtxos || (replaceTx && replaceTx.txId !== bumpTxId))) {
446
+ if (bumpTxId && (!selectedUtxos?.size || (replaceTx && replaceTx.txId !== bumpTxId))) {
439
447
  throw new Error(`Unable to bump ${bumpTxId}`)
440
448
  }
441
449
 
@@ -5,9 +5,7 @@ import ms from 'ms'
5
5
 
6
6
  import { isChangeAddress, isReceiveAddress } from '../address-utils.js'
7
7
  import { orderTxs } from '../insight-api-client/util.js'
8
- import { getOrdinalAddress } from '../ordinals-utils.js'
9
- import { getOrdinalsUtxos, getUtxos, partitionUtxos } from '../utxos-utils.js'
10
- import { indexOrdinalUnconfirmedTx } from './ordinals-indexer-utils.js'
8
+ import { getUtxos } from '../utxos-utils.js'
11
9
 
12
10
  const { compact, isEqual, uniq } = lodash
13
11
 
@@ -23,8 +21,6 @@ export class BitcoinMonitorScanner {
23
21
  #txFetchLimitResolver
24
22
  #shouldExcludeVoutUtxo
25
23
  #yieldToUI
26
- #ordinalsEnabled
27
- #ordinalChainIndex
28
24
  #extraChainIndexEnabled
29
25
  #extraChainIndex
30
26
  #gapLimit
@@ -36,8 +32,6 @@ export class BitcoinMonitorScanner {
36
32
  yieldToUI = () => {},
37
33
  shouldExcludeVoutUtxo = () => false,
38
34
  txFetchLimitResolver = ({ refresh }) => (refresh ? 50 : 10),
39
- ordinalsEnabled,
40
- ordinalChainIndex,
41
35
  extraChainIndexEnabled,
42
36
  extraChainIndex,
43
37
  gapLimit = 10,
@@ -57,8 +51,6 @@ export class BitcoinMonitorScanner {
57
51
  this.#assetClientInterface = assetClientInterface
58
52
  this.#txFetchLimitResolver = txFetchLimitResolver
59
53
  this.#shouldExcludeVoutUtxo = shouldExcludeVoutUtxo
60
- this.#ordinalsEnabled = ordinalsEnabled
61
- this.#ordinalChainIndex = ordinalChainIndex
62
54
  this.#extraChainIndexEnabled = extraChainIndexEnabled
63
55
  this.#extraChainIndex = extraChainIndex
64
56
  this.#gapLimit = gapLimit
@@ -92,9 +84,6 @@ export class BitcoinMonitorScanner {
92
84
  const multiAddressMode = assetConfig.multiAddressMode ?? true
93
85
 
94
86
  const storedUtxos = getUtxos({ asset, accountState })
95
- const storedOrdinalUtxos = getOrdinalsUtxos({ asset, accountState })
96
-
97
- const currentStoredUtxos = storedUtxos.union(storedOrdinalUtxos)
98
87
 
99
88
  const currentTime = Date.now()
100
89
  const unconfirmedTxsToCheck = [...currentTxs].reduce((txs, tx) => {
@@ -116,9 +105,7 @@ export class BitcoinMonitorScanner {
116
105
  : (txs) =>
117
106
  txs.filter((tx) => {
118
107
  const txItem = currentTxs.get(tx.txid)
119
- const confirmed = txItem && txItem.confirmed
120
- const inscriptionsIndexed = txItem?.data?.inscriptionsIndexed
121
- return confirmed && (!this.#ordinalsEnabled || inscriptionsIndexed)
108
+ return txItem && txItem.confirmed
122
109
  }).length >= txs.length
123
110
 
124
111
  const unusedAddressIndexes = await assetClientInterface.getUnusedAddressIndexes({
@@ -254,28 +241,7 @@ export class BitcoinMonitorScanner {
254
241
  }
255
242
 
256
243
  const listOfTxs = await Promise.all(promises)
257
- const insightTxs = listOfTxs.flat()
258
- if (!this.#ordinalsEnabled) {
259
- return insightTxs
260
- }
261
-
262
- return Promise.all(
263
- insightTxs.map((tx) => {
264
- try {
265
- return indexOrdinalUnconfirmedTx({
266
- tx,
267
- currency: this.#asset.currency,
268
- insightClient,
269
- })
270
- } catch (e) {
271
- console.warn(
272
- `Could not index ${asset.name} ordinal tx ${tx.txid} for wallet account ${walletAccount}. message: ${e.message}`,
273
- e
274
- )
275
- return tx
276
- }
277
- })
278
- )
244
+ return listOfTxs.flat()
279
245
  }
280
246
 
281
247
  const gapSearchParameters = newChains.map(({ purpose, chain }) => {
@@ -305,21 +271,6 @@ export class BitcoinMonitorScanner {
305
271
  }
306
272
  )
307
273
 
308
- if (
309
- fetchCount === 0 &&
310
- this.#ordinalsEnabled &&
311
- this.#ordinalChainIndex > 1 &&
312
- purposes.includes(86)
313
- ) {
314
- // this is the ordinal address
315
- chainObjects.push({
316
- purpose: 86,
317
- chainIndex: this.#ordinalChainIndex,
318
- startAddressIndex: 0,
319
- endAddressIndex: 1,
320
- })
321
- }
322
-
323
274
  if (
324
275
  fetchCount === 0 &&
325
276
  this.#extraChainIndexEnabled &&
@@ -373,10 +324,7 @@ export class BitcoinMonitorScanner {
373
324
  const addressString = String(address)
374
325
  const purposeToUpdate = purposeMap[addressString]
375
326
 
376
- if (
377
- metaAddressIndex === undefined ||
378
- (metaChainIndex === this.#ordinalChainIndex && this.#ordinalChainIndex > 1)
379
- ) {
327
+ if (metaAddressIndex === undefined) {
380
328
  return
381
329
  }
382
330
 
@@ -455,20 +403,12 @@ export class BitcoinMonitorScanner {
455
403
  feePerKB: txItem.fees ? (txItem.fees / txItem.vsize) * 1000 * 1e8 : null,
456
404
  rbfEnabled: txItem.rbf,
457
405
  blocksSeen: 0,
406
+ internalOutputs: [],
458
407
  ...(isCoinbase ? { isCoinbase: true } : undefined),
459
408
  },
460
409
  currencies: { [assetName]: currency },
461
410
  }
462
411
 
463
- if (this.#ordinalsEnabled) {
464
- txLogItem.data = {
465
- ...txLogItem.data,
466
- inscriptionsIndexed: txItem.inscriptionsIndexed,
467
- sentInscriptions: [],
468
- receivedInscriptions: [],
469
- }
470
- }
471
-
472
412
  let from = []
473
413
 
474
414
  // if txItem.vin has an address that matches ours, means we've spent this tx
@@ -494,14 +434,6 @@ export class BitcoinMonitorScanner {
494
434
  txLogItem.coinAmount = txLogItem.coinAmount.sub(currency.defaultUnit(vin.value))
495
435
  isSent = true
496
436
  txLogItem.data.sent = []
497
- if (this.#ordinalsEnabled && vin.inscriptions) {
498
- txLogItem.data.sentInscriptions.push(
499
- ...vin.inscriptions.map((i) => ({
500
- ...i,
501
- value: currency.defaultUnit(vin.value).toBaseNumber(),
502
- }))
503
- )
504
- }
505
437
 
506
438
  // this is only used to exclude the utxos in the reducer which is why we don't care about the other fields
507
439
  utxosToRemove.push({
@@ -565,18 +497,23 @@ export class BitcoinMonitorScanner {
565
497
  txLogItem.addresses.push(address)
566
498
  }
567
499
 
500
+ // keeping this for backwards compatibility to not risk breaking any unknown code that depends on the change address being present
568
501
  if (isSent && isChangeAddress(address)) {
569
502
  txLogItem.data.changeAddress = address
570
503
  }
571
504
 
572
- if (this.#ordinalsEnabled && vout.inscriptions) {
573
- txLogItem.data.receivedInscriptions.push(
574
- ...vout.inscriptions.map((i) => ({
575
- ...i,
576
- value: currency.defaultUnit(vout.value).toBaseNumber(),
577
- }))
578
- )
579
- }
505
+ // Internal outputs are any outputs that pay into this wallet.
506
+ // For sent txs, that includes change and self‑send outputs.
507
+ // For incoming txs, it includes normal receive outputs.
508
+ //
509
+ // We do not try to infer which internal output is the user’s intent
510
+ // vs wallet change, because it is ambiguous:
511
+ // - Users can self‑send to a change address and still get an extra change output.
512
+ // - In single‑address mode, receive and change outputs have same address.
513
+ txLogItem.data.internalOutputs.push({
514
+ address,
515
+ amount: currency.defaultUnit(vout.value).toBaseString(),
516
+ })
580
517
 
581
518
  // it was sent to us...
582
519
  const val = currency.defaultUnit(vout.value)
@@ -594,11 +531,6 @@ export class BitcoinMonitorScanner {
594
531
  ...(isCoinbase ? { isCoinbase: true } : undefined),
595
532
  }
596
533
 
597
- if (this.#ordinalsEnabled) {
598
- output.inscriptionsIndexed = txItem.inscriptionsIndexed
599
- output.inscriptions = vout.inscriptions || []
600
- }
601
-
602
534
  if (this.#shouldExcludeVoutUtxo({ asset, output, txItem, vout })) {
603
535
  utxosToRemove.push({
604
536
  address,
@@ -667,7 +599,7 @@ export class BitcoinMonitorScanner {
667
599
 
668
600
  const utxosToRemoveCol = UtxoCollection.fromArray(utxosToRemove, { currency })
669
601
  // Keep new utxos when they intersect with the stored utxos.
670
- utxoCol = utxoCol.union(currentStoredUtxos).difference(utxosToRemoveCol)
602
+ utxoCol = utxoCol.union(storedUtxos).difference(utxosToRemoveCol)
671
603
 
672
604
  const pendingDropCandidates = Object.values(unconfirmedTxsToCheck)
673
605
  const verificationResults = await Promise.all(
@@ -712,7 +644,7 @@ export class BitcoinMonitorScanner {
712
644
  }
713
645
 
714
646
  // no changes, ignore
715
- if (utxoCol.equals(currentStoredUtxos)) {
647
+ if (utxoCol.equals(storedUtxos)) {
716
648
  utxoCol = null
717
649
  }
718
650
 
@@ -722,28 +654,10 @@ export class BitcoinMonitorScanner {
722
654
  return !isEqual(chain, originalChain.chain)
723
655
  })
724
656
 
725
- const ordinalAddress = await this.getOrdinalAddress({ walletAccount })
726
-
727
- // latest account state, in case knownBalanceUtxoIds or mustAvoidUtxoIds gets updated in on another promise
728
- const latestAccountState = await assetClientInterface.getAccountState({
729
- assetName,
730
- walletAccount,
731
- })
732
- const utxosData = utxoCol
733
- ? partitionUtxos({
734
- allUtxos: utxoCol,
735
- ordinalsEnabled: this.#ordinalsEnabled,
736
- ordinalAddress,
737
- knownBalanceUtxoIds: latestAccountState.knownBalanceUtxoIds,
738
- mustAvoidUtxoIds: latestAccountState.mustAvoidUtxoIds,
739
- additionalInscriptions: latestAccountState.additionalInscriptions,
740
- })
741
- : {}
742
-
743
657
  return {
744
658
  txsToUpdate: existingTxs,
745
659
  txsToAdd: newTxs,
746
- ...utxosData,
660
+ ...(utxoCol ? { utxos: utxoCol } : {}),
747
661
  changedUnusedAddressIndexes,
748
662
  }
749
663
  }
@@ -756,8 +670,6 @@ export class BitcoinMonitorScanner {
756
670
  const accountState = await aci.getAccountState({ assetName, walletAccount })
757
671
 
758
672
  const storedUtxos = getUtxos({ accountState, asset })
759
- const storedOrdinalsUtxos = getOrdinalsUtxos({ accountState, asset })
760
- const allStoredUtxos = storedUtxos.union(storedOrdinalsUtxos)
761
673
 
762
674
  const currentTxs = [...(await aci.getTxLog({ assetName, walletAccount }))]
763
675
 
@@ -804,38 +716,18 @@ export class BitcoinMonitorScanner {
804
716
  })
805
717
  .filter((tx) => Object.keys(tx).length > 1)
806
718
 
807
- const txConfirmedUtxos = allStoredUtxos.updateConfirmations(confirmationsList)
808
-
809
- const ordinalAddress = await this.getOrdinalAddress({ walletAccount })
810
-
811
- // latest account state, in case knownBalanceUtxoIds or mustAvoidUtxoIds gets updated in on another promise
812
- const latestAccountState = await aci.getAccountState({
813
- assetName,
814
- walletAccount,
719
+ // Check before updateConfirmations since it mutates the shared utxo object references,
720
+ // which would cause storedUtxos.toJSON() to also reflect the updated confirmations.
721
+ const utxosChanged = storedUtxos.toArray().some((utxo) => {
722
+ const update = confirmationsList.find(({ txId }) => utxo.txId === txId)
723
+ return update && utxo.confirmations !== update.confirmations
815
724
  })
816
725
 
817
- const { utxos, ordinalsUtxos } = partitionUtxos({
818
- allUtxos: txConfirmedUtxos,
819
- ordinalsEnabled: this.#ordinalsEnabled,
820
- ordinalAddress,
821
- knownBalanceUtxoIds: latestAccountState.knownBalanceUtxoIds,
822
- mustAvoidUtxoIds: latestAccountState.mustAvoidUtxoIds,
823
- additionalInscriptions: latestAccountState.additionalInscriptions,
824
- })
726
+ const txConfirmedUtxos = storedUtxos.updateConfirmations(confirmationsList)
825
727
 
826
728
  return {
827
- utxos: utxos.equals(storedUtxos) ? null : utxos,
828
- ordinalsUtxos: ordinalsUtxos.equals(storedOrdinalsUtxos) ? null : ordinalsUtxos,
729
+ utxos: utxosChanged ? txConfirmedUtxos : null,
829
730
  txsToUpdate: updatedPropertiesTxs,
830
731
  }
831
732
  }
832
-
833
- getOrdinalAddress({ walletAccount }) {
834
- return getOrdinalAddress({
835
- asset: this.#asset,
836
- assetClientInterface: this.#assetClientInterface,
837
- walletAccount,
838
- ordinalChainIndex: this.#ordinalChainIndex,
839
- })
840
- }
841
733
  }
@@ -166,13 +166,12 @@ export class Monitor extends BaseMonitor {
166
166
  const assetName = asset.name
167
167
  const walletAccounts = await aci.getWalletAccounts({ assetName })
168
168
  for (const walletAccount of walletAccounts) {
169
- const { txsToUpdate, utxos, ordinalsUtxos } = await this.#scanner.rescanOnNewBlock({
169
+ const { txsToUpdate, utxos } = await this.#scanner.rescanOnNewBlock({
170
170
  walletAccount,
171
171
  })
172
172
 
173
173
  const newData = {}
174
174
  if (utxos) newData.utxos = utxos
175
- if (ordinalsUtxos) newData.ordinalsUtxos = ordinalsUtxos
176
175
 
177
176
  if (txsToUpdate.length > 0) {
178
177
  await this.updateTxLog({ assetName, walletAccount, logItems: txsToUpdate })
@@ -237,7 +236,7 @@ export class Monitor extends BaseMonitor {
237
236
  // wait for all wallet accounts to load
238
237
  await aci.getWalletAccounts({ assetName })
239
238
 
240
- const { txsToAdd, txsToUpdate, utxos, ordinalsUtxos, changedUnusedAddressIndexes } =
239
+ const { txsToAdd, txsToUpdate, utxos, changedUnusedAddressIndexes } =
241
240
  await this.#scanner.rescanBlockchainInsight({
242
241
  walletAccount,
243
242
  refresh,
@@ -245,7 +244,6 @@ export class Monitor extends BaseMonitor {
245
244
 
246
245
  const newData = {}
247
246
  if (utxos) newData.utxos = utxos
248
- if (ordinalsUtxos) newData.ordinalsUtxos = ordinalsUtxos
249
247
 
250
248
  if (!isEmpty(changedUnusedAddressIndexes)) {
251
249
  // Only for mobile atm, browser and hydra calculates from the latest txLogs
@@ -58,7 +58,7 @@ export const getCreateBatchTransaction = ({
58
58
  allowUnconfirmedRbfEnabledUtxos,
59
59
  })
60
60
 
61
- if (!selectedUtxos) throw new Error('Not enough funds.')
61
+ if (!selectedUtxos?.size) throw new Error('Not enough funds.')
62
62
 
63
63
  const addressPathsMap = selectedUtxos.getAddressPathsMap()
64
64
 
@@ -266,6 +266,7 @@ export const sendTxFactory = ({
266
266
  rbfEnabled,
267
267
  toAddress,
268
268
  sendAmount,
269
+ changeAmount,
269
270
  bumpTxId,
270
271
  size,
271
272
  })
@@ -2,6 +2,109 @@ import { Address } from '@exodus/models'
2
2
 
3
3
  import { serializeCurrency } from '../fee/fee-utils.js'
4
4
 
5
+ const normalizeAddressObject = (asset, value) => {
6
+ const address =
7
+ value instanceof Address
8
+ ? value
9
+ : Address.create(value.address ?? value, value.meta ?? Object.create(null))
10
+
11
+ return asset.address.toLegacyAddress
12
+ ? Address.create(asset.address.toLegacyAddress(String(address)), address.meta)
13
+ : address
14
+ }
15
+
16
+ // internalOutputs are deterministically derivable while scanning while change address is not
17
+ // internalOutput.address is an Address object, so platform can re-derive complete Address object from the tx log
18
+ const buildInternalOutputs = ({
19
+ asset,
20
+ walletAddressObjects,
21
+ internalAddressSet,
22
+ amountToSerialize,
23
+ toAddress,
24
+ changeOutput,
25
+ ourAddress,
26
+ changeAmount,
27
+ }) => {
28
+ const internalOutputs = []
29
+ if (amountToSerialize && toAddress) {
30
+ const normalizedToAddress = normalizeAddressObject(asset, toAddress)
31
+ const normalizedToAddressString = String(normalizedToAddress)
32
+ if (internalAddressSet.has(normalizedToAddressString)) {
33
+ const internalAddress =
34
+ walletAddressObjects.find((address) => String(address) === normalizedToAddressString) ??
35
+ normalizedToAddress
36
+ internalOutputs.push({
37
+ address: internalAddress,
38
+ amount: amountToSerialize.toBaseString(),
39
+ })
40
+ }
41
+ }
42
+
43
+ if (changeOutput && ourAddress && changeAmount) {
44
+ const changeAddressObject = normalizeAddressObject(asset, ourAddress)
45
+ internalOutputs.push({
46
+ address: changeAddressObject,
47
+ amount: changeAmount.toBaseString(),
48
+ })
49
+ }
50
+
51
+ return internalOutputs
52
+ }
53
+
54
+ const getWalletAddressObjects = async ({
55
+ asset,
56
+ assetClientInterface,
57
+ assetName,
58
+ walletAccount,
59
+ chainIndex,
60
+ multisigDataLength,
61
+ }) => {
62
+ const purposes = await assetClientInterface.getSupportedPurposes({
63
+ assetName,
64
+ walletAccount,
65
+ })
66
+
67
+ const unusedAddressIndexes = await assetClientInterface.getUnusedAddressIndexes({
68
+ assetName,
69
+ walletAccount,
70
+ highestUnusedIndexes: true,
71
+ })
72
+
73
+ const addresses = []
74
+ const addressSet = new Set()
75
+
76
+ for (const purpose of purposes) {
77
+ const chainIndexes = unusedAddressIndexes.find((entry) => entry.purpose === purpose)?.chain || [
78
+ 0, 0,
79
+ ]
80
+ // users can switch between multi address mode and single address mode, so we need to use the highest unused address index irrespective of current mode
81
+ const endAddressIndex = chainIndexes[chainIndex] ?? 0
82
+ const dataLength = multisigDataLength ?? 1
83
+
84
+ for (let addressIndex = 0; addressIndex <= endAddressIndex; addressIndex++) {
85
+ for (let multisigDataIndex = 0; multisigDataIndex < dataLength; multisigDataIndex++) {
86
+ const address = await assetClientInterface.getAddress({
87
+ assetName,
88
+ walletAccount,
89
+ purpose,
90
+ chainIndex,
91
+ addressIndex,
92
+ ...(multisigDataLength ? { multisigDataIndex } : Object.create(null)),
93
+ })
94
+
95
+ const addressObject = normalizeAddressObject(asset, address)
96
+ const addressKey = String(addressObject)
97
+ if (addressSet.has(addressKey)) continue
98
+
99
+ addressSet.add(addressKey)
100
+ addresses.push(addressObject)
101
+ }
102
+ }
103
+ }
104
+
105
+ return addresses
106
+ }
107
+
5
108
  export async function updateAccountState({
6
109
  assetClientInterface,
7
110
  assetName,
@@ -71,6 +174,7 @@ export async function updateTransactionLog({
71
174
  rbfEnabled,
72
175
  toAddress,
73
176
  sendAmount,
177
+ changeAmount,
74
178
  }) {
75
179
  const assetName = asset.name
76
180
 
@@ -80,22 +184,54 @@ export async function updateTransactionLog({
80
184
  walletAccount,
81
185
  })
82
186
 
83
- const walletAddressObjects = await assetClientInterface.getReceiveAddresses({
187
+ const multisigDataLength = config?.multisigDataLength
188
+
189
+ const walletReceiveAddressObjects = await getWalletAddressObjects({
190
+ asset,
191
+ assetClientInterface,
192
+ assetName,
84
193
  walletAccount,
194
+ chainIndex: 0,
195
+ multisigDataLength,
196
+ })
197
+ const walletChangeAddressObjects = await getWalletAddressObjects({
198
+ asset,
199
+ assetClientInterface,
85
200
  assetName,
86
- multiAddressMode: config?.multiAddressMode ?? true,
201
+ walletAccount,
202
+ chainIndex: 1,
203
+ multisigDataLength,
87
204
  })
88
205
 
206
+ const walletAddressObjects = [...walletReceiveAddressObjects, ...walletChangeAddressObjects]
207
+
208
+ const internalAddressSet = new Set(walletAddressObjects.map(String))
209
+ const normalizedToAddressString = toAddress
210
+ ? String(normalizeAddressObject(asset, toAddress))
211
+ : undefined
212
+
89
213
  // There are two cases of bumping, replacing or chaining a self-send.
90
214
  // If we have a bumpTxId, but we aren't replacing, then it is a self-send.
215
+ // If the user sent to a receive or change address of this wallet, it is a self-send.
91
216
  const selfSend = bumpTxId
92
217
  ? !replaceTx
93
- : walletAddressObjects.some((receiveAddress) => String(receiveAddress) === String(toAddress))
218
+ : Boolean(normalizedToAddressString && internalAddressSet.has(normalizedToAddressString))
94
219
 
95
220
  const displayReceiveAddress = asset.address.displayAddress?.(toAddress) || toAddress
96
221
 
97
222
  const amountToSerialize = sendAmount.isZero ? undefined : sendAmount
98
223
 
224
+ const internalOutputs = buildInternalOutputs({
225
+ asset,
226
+ walletAddressObjects,
227
+ internalAddressSet,
228
+ amountToSerialize,
229
+ toAddress,
230
+ changeOutput,
231
+ ourAddress,
232
+ changeAmount,
233
+ })
234
+
99
235
  // Build receivers list
100
236
  const receivers = bumpTxId
101
237
  ? replaceTx
@@ -134,8 +270,10 @@ export async function updateTransactionLog({
134
270
  selfSend,
135
271
  data: {
136
272
  sent: selfSend ? [] : receivers,
273
+ internalOutputs,
137
274
  rbfEnabled,
138
275
  feePerKB: size ? fee.div(size / 1000).toBaseNumber() : undefined,
276
+ // keeping this for backwards compatibility to not risk breaking any unknown code that depends on the change address being present
139
277
  changeAddress: changeOutput ? ourAddress : undefined,
140
278
  blockHeight,
141
279
  blocksSeen: 0,