@exodus/bitcoin-api 4.9.2 → 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 +28 -0
- package/package.json +2 -2
- package/src/fee/can-bump-tx.js +15 -5
- package/src/fee/utxo-selector.js +76 -17
- package/src/psbt-builder.js +5 -3
- package/src/tx-create/create-tx.js +18 -10
- package/src/tx-log/bitcoin-monitor-scanner.js +15 -0
- package/src/tx-send/batch-tx.js +1 -1
- package/src/tx-send/index.js +1 -0
- package/src/tx-send/update-state.js +141 -3
- package/src/tx-sign/create-sign-with-wallet.js +3 -2
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.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
|
+
|
|
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)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
* fix(bitcoin-api): Fix non-BTC nested‑segwit redeem script (#7357)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
6
34
|
## [4.9.2](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@4.9.1...@exodus/bitcoin-api@4.9.2) (2026-01-28)
|
|
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
|
+
"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": "
|
|
64
|
+
"gitHead": "b4d4b4bf36591dca5c82bfacfbac80db41805e02"
|
|
65
65
|
}
|
package/src/fee/can-bump-tx.js
CHANGED
|
@@ -80,8 +80,13 @@ const _canBumpTx = ({
|
|
|
80
80
|
return { errorMessage: 'already confirmed' }
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
// Can't bump
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
129
|
+
return selectedUtxos?.size
|
|
130
|
+
? { bumpType: BumpType.CPFP, bumpFee: fee }
|
|
131
|
+
: { errorMessage: 'insufficient funds' }
|
|
122
132
|
}
|
|
123
133
|
|
|
124
134
|
const validateIsNumber = (number, name) => {
|
package/src/fee/utxo-selector.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
!
|
|
73
|
+
!forceSpendUtxos &&
|
|
60
74
|
!disableReplacement &&
|
|
61
75
|
replaceableTxs &&
|
|
62
|
-
(
|
|
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
|
-
|
|
69
|
-
|
|
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 (
|
|
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
|
|
137
|
-
if (
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/psbt-builder.js
CHANGED
|
@@ -20,9 +20,9 @@ function canParseTx(rawTxBuffer, Transaction) {
|
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
function getWrappedSegwitRedeemScript({ publicKey, address, context = '' }) {
|
|
24
|
-
const p2wpkh = payments.p2wpkh({ pubkey: publicKey })
|
|
25
|
-
const p2sh = payments.p2sh({ redeem: p2wpkh })
|
|
23
|
+
function getWrappedSegwitRedeemScript({ publicKey, address, network, context = '' }) {
|
|
24
|
+
const p2wpkh = payments.p2wpkh({ pubkey: publicKey, network })
|
|
25
|
+
const p2sh = payments.p2sh({ redeem: p2wpkh, network })
|
|
26
26
|
|
|
27
27
|
if (address !== p2sh.address) {
|
|
28
28
|
throw new Error(`Expected P2SH script to be a nested p2wpkh${context ? ' for ' + context : ''}`)
|
|
@@ -105,6 +105,7 @@ function createPsbtInput({
|
|
|
105
105
|
psbtInput.redeemScript = getWrappedSegwitRedeemScript({
|
|
106
106
|
publicKey,
|
|
107
107
|
address: input.address,
|
|
108
|
+
network: asset.coinInfo.toBitcoinJS(),
|
|
108
109
|
context: `input address ${input.address}`,
|
|
109
110
|
})
|
|
110
111
|
}
|
|
@@ -170,6 +171,7 @@ function createPsbtOutput({ address, amount, asset, addressPathsMap, purposeXPub
|
|
|
170
171
|
psbtOutput.redeemScript = getWrappedSegwitRedeemScript({
|
|
171
172
|
publicKey,
|
|
172
173
|
address,
|
|
174
|
+
network: asset.coinInfo.toBitcoinJS(),
|
|
173
175
|
context: `output address ${address}`,
|
|
174
176
|
})
|
|
175
177
|
}
|
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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) => ({
|
package/src/tx-send/batch-tx.js
CHANGED
|
@@ -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
|
|
package/src/tx-send/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
:
|
|
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,
|
|
@@ -64,8 +64,9 @@ export function createSignWithWallet({
|
|
|
64
64
|
// P2WPKH. Exodus doesn't use P2SH addresses so we should only ever be
|
|
65
65
|
// signing a P2SH input if we are importing a private key
|
|
66
66
|
// BIP143: As a default policy, only compressed public keys are accepted in P2WPKH and P2WSH
|
|
67
|
-
const
|
|
68
|
-
const
|
|
67
|
+
const network = coinInfo.toBitcoinJS()
|
|
68
|
+
const p2wpkh = payments.p2wpkh({ pubkey: publicKey, network })
|
|
69
|
+
const p2sh = payments.p2sh({ redeem: p2wpkh, network })
|
|
69
70
|
if (address === p2sh.address) {
|
|
70
71
|
// Set the redeem script in the psbt in case it's missing.
|
|
71
72
|
if (!Buffer.isBuffer(input.redeemScript)) {
|