@exodus/bitcoin-api 4.9.3 → 4.9.4

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,24 @@
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.4](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.3...@exodus/bitcoin-api@4.9.4) (2026-02-10)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix(bitcoin-api): Fix empty UTXO selection in BTC sends (#7370)
13
+
14
+ * fix(bitcoin-api): Gate send-all RBF eligibility (#7373)
15
+
16
+ * fix(bitcoin-api): Refine bump UTXO selection controls (#7372)
17
+
18
+ * fix(bitcoin-api): Replace changeAddress with internalOutputs (#7378)
19
+
20
+ * fix(bitcoin-api): Tighten RBF send-all replacement eligibility (#7371)
21
+
22
+
23
+
6
24
  ## [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
25
 
8
26
 
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.4",
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": "b4d4b4bf36591dca5c82bfacfbac80db41805e02"
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
 
@@ -455,6 +455,7 @@ export class BitcoinMonitorScanner {
455
455
  feePerKB: txItem.fees ? (txItem.fees / txItem.vsize) * 1000 * 1e8 : null,
456
456
  rbfEnabled: txItem.rbf,
457
457
  blocksSeen: 0,
458
+ internalOutputs: [],
458
459
  ...(isCoinbase ? { isCoinbase: true } : undefined),
459
460
  },
460
461
  currencies: { [assetName]: currency },
@@ -565,10 +566,24 @@ export class BitcoinMonitorScanner {
565
566
  txLogItem.addresses.push(address)
566
567
  }
567
568
 
569
+ // keeping this for backwards compatibility to not risk breaking any unknown code that depends on the change address being present
568
570
  if (isSent && isChangeAddress(address)) {
569
571
  txLogItem.data.changeAddress = address
570
572
  }
571
573
 
574
+ // Internal outputs are any outputs that pay into this wallet.
575
+ // For sent txs, that includes change and self‑send outputs.
576
+ // For incoming txs, it includes normal receive outputs.
577
+ //
578
+ // We do not try to infer which internal output is the user’s intent
579
+ // vs wallet change, because it is ambiguous:
580
+ // - Users can self‑send to a change address and still get an extra change output.
581
+ // - In single‑address mode, receive and change outputs have same address.
582
+ txLogItem.data.internalOutputs.push({
583
+ address,
584
+ amount: currency.defaultUnit(vout.value).toBaseString(),
585
+ })
586
+
572
587
  if (this.#ordinalsEnabled && vout.inscriptions) {
573
588
  txLogItem.data.receivedInscriptions.push(
574
589
  ...vout.inscriptions.map((i) => ({
@@ -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,