@exodus/bitcoin-api 2.20.1 → 2.21.0

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,20 @@
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
+ ## [2.21.0](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@2.20.1...@exodus/bitcoin-api@2.21.0) (2024-07-19)
7
+
8
+
9
+ ### Features
10
+
11
+ * **BTC:** create batch tx from array of recipients ([#2881](https://github.com/ExodusMovement/assets/issues/2881)) ([440d5d5](https://github.com/ExodusMovement/assets/commit/440d5d5879c902ef10ff7644c92a5092bb868872))
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **bitcoin-api:** stop ws connection on monitor stop ([#2851](https://github.com/ExodusMovement/assets/issues/2851)) ([e52f4c2](https://github.com/ExodusMovement/assets/commit/e52f4c23e7635cc3116c876ad35fbbab4a70c32b))
17
+
18
+
19
+
6
20
  ## [2.20.1](https://github.com/ExodusMovement/assets/compare/@exodus/bitcoin-api@2.20.0...@exodus/bitcoin-api@2.20.1) (2024-07-15)
7
21
 
8
22
  **Note:** Version bump only for package @exodus/bitcoin-api
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "2.20.1",
3
+ "version": "2.21.0",
4
4
  "description": "Exodus bitcoin-api",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -14,7 +14,7 @@
14
14
  "access": "restricted"
15
15
  },
16
16
  "scripts": {
17
- "test": "run -T jest",
17
+ "test": "run -T exodus-test --jest --esbuild",
18
18
  "lint": "run -T eslint .",
19
19
  "lint:fix": "yarn lint --fix"
20
20
  },
@@ -66,5 +66,5 @@
66
66
  "type": "git",
67
67
  "url": "git+https://github.com/ExodusMovement/assets.git"
68
68
  },
69
- "gitHead": "e1354557f99e81651fad1b25a4f72183587324b6"
69
+ "gitHead": "ba7439e1b8ae182a2a95ffd0ea09b8bc3330f2f6"
70
70
  }
@@ -27,6 +27,7 @@ export const selectUtxos = ({
27
27
  amount,
28
28
  feeRate,
29
29
  receiveAddress, // it could be null
30
+ receiveAddresses = [],
30
31
  isSendAll,
31
32
  getFeeEstimator,
32
33
  disableReplacement = false,
@@ -44,10 +45,9 @@ export const selectUtxos = ({
44
45
  inscriptionIds,
45
46
  })
46
47
 
47
- const receiveAddresses = []
48
48
  if (inscriptionIds) {
49
49
  receiveAddresses.push(...inscriptionIds.map(() => resolvedReceiveAddresses))
50
- } else {
50
+ } else if (receiveAddresses.length === 0) {
51
51
  receiveAddresses.push(resolvedReceiveAddresses)
52
52
  }
53
53
 
package/src/index.js CHANGED
@@ -14,6 +14,8 @@ export * from './utxos-utils'
14
14
  export * from './tx-log'
15
15
  export * from './unconfirmed-ancestor-data'
16
16
  export * from './parse-unsigned-tx'
17
+ export { getCreateBatchTransaction } from './tx-send/batch-tx'
18
+ export { createPsbtToUnsignedTx } from './psbt-utils'
17
19
  export * from './insight-api-client/util'
18
20
  export * from './move-funds'
19
21
  export { createEncodeMultisigContract } from './multisig-address'
@@ -0,0 +1,61 @@
1
+ import BipPath from 'bip32-path'
2
+ import lodash from 'lodash'
3
+
4
+ export const createPsbtToUnsignedTx =
5
+ ({ assetClientInterface, assetName }) =>
6
+ async ({ psbt, walletAccount, purpose = 86 }) => {
7
+ const addressPathsMap = {}
8
+ const inputsToSign = []
9
+
10
+ const addressOpts = {
11
+ walletAccount: walletAccount.toString(),
12
+ assetName,
13
+ purpose,
14
+ chainIndex: 0,
15
+ addressIndex: 0,
16
+ }
17
+
18
+ // Need to have all input derivations
19
+ for (const i of lodash.range(psbt.inputCount)) {
20
+ const input = psbt.data.inputs[i]
21
+
22
+ const derivation = input.tapBip32Derivation
23
+ if (!derivation) {
24
+ throw new Error('Invalid input in psbt, no derivation for input found')
25
+ }
26
+
27
+ const [chainIndex, addressIndex] = BipPath.fromString(derivation[0].path).toPathArray()
28
+
29
+ addressOpts.chainIndex = chainIndex
30
+ addressOpts.addressIndex = addressIndex
31
+
32
+ const address = await assetClientInterface.getAddress(addressOpts)
33
+
34
+ addressPathsMap[address.toString()] = derivation[0].path
35
+ inputsToSign.push({ address: address.toString() })
36
+ }
37
+
38
+ // If we have output derivations then it's our change
39
+ for (const i of lodash.range(psbt.txOutputs.length)) {
40
+ const output = psbt.data.outputs[i]
41
+
42
+ const derivation = output.tapBip32Derivation
43
+ if (!derivation) continue
44
+ const [chainIndex, addressIndex] = BipPath.fromString(derivation[0].path).toPathArray()
45
+
46
+ addressOpts.chainIndex = chainIndex
47
+ addressOpts.addressIndex = addressIndex
48
+ const address = await assetClientInterface.getAddress(addressOpts)
49
+
50
+ addressPathsMap[address.toString()] = derivation[0].path
51
+ }
52
+
53
+ return {
54
+ txData: { psbtBuffer: psbt.toBuffer() },
55
+ txMeta: {
56
+ addressPathsMap,
57
+ inputsToSign,
58
+ accountIndex: walletAccount.index,
59
+ },
60
+ }
61
+ }
@@ -62,6 +62,12 @@ export class Monitor extends BaseMonitor {
62
62
  })
63
63
 
64
64
  this.addHook('after-tick-multiple-wallet-accounts', () => this.#subscribeToNewAddresses())
65
+ this.addHook('before-stop', () => {
66
+ if (this.#ws) {
67
+ this.#ws.close()
68
+ this.#ws = null
69
+ }
70
+ })
65
71
  this.addHook('after-stop', async () =>
66
72
  Promise.all(Object.keys(this.#runningByWalletAccount).map(this.#waitForWalletToFinish))
67
73
  )
@@ -0,0 +1,206 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import BIP32 from '@exodus/bip32'
3
+ import { Psbt } from '@exodus/bitcoinjs-lib'
4
+ import BipPath from 'bip32-path'
5
+ // Using this notation so it can be mocked by jest
6
+ import doShuffle from 'lodash/shuffle'
7
+ import assert from 'minimalistic-assert'
8
+
9
+ import { selectUtxos } from '../fee/utxo-selector'
10
+ import { getUnconfirmedTxAncestorMap } from '../unconfirmed-ancestor-data'
11
+ import { getUsableUtxos, getUtxos } from '../utxos-utils'
12
+
13
+ const DUST_VALUES = {
14
+ P2WPKH: 294,
15
+ P2TR: 330,
16
+ }
17
+
18
+ export const getCreateBatchTransaction = ({
19
+ getFeeEstimator,
20
+ assetClientInterface,
21
+ changeAddressType,
22
+ }) => {
23
+ assert(assetClientInterface, `assetClientInterface must be supplied in sendTx`)
24
+
25
+ return async ({ assetName, walletAccount, recipients, options = {} }) => {
26
+ const stuff = await assetClientInterface.getAssetsForNetwork({ baseAssetName: assetName })
27
+ const asset = stuff[assetName]
28
+
29
+ const {
30
+ feeData = await assetClientInterface.getFeeConfig({ assetName }),
31
+ taprootInputWitnessSize,
32
+ } = options
33
+
34
+ const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
35
+
36
+ const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
37
+ const unconfirmedTxAncestor = getUnconfirmedTxAncestorMap({ accountState })
38
+ const usableUtxos = getUsableUtxos({
39
+ asset,
40
+ utxos: getUtxos({ accountState, asset }),
41
+ feeData,
42
+ txSet,
43
+ unconfirmedTxAncestor,
44
+ })
45
+
46
+ const amount = recipients.reduce((acc, curr) => acc.add(curr.amount), asset.currency.ZERO)
47
+ const receiveAddresses = recipients.map((recipient) => recipient.address)
48
+
49
+ const { selectedUtxos, fee } = selectUtxos({
50
+ asset,
51
+ usableUtxos,
52
+ amount,
53
+ feeRate: feeData.feePerKB,
54
+ receiveAddresses,
55
+ getFeeEstimator: (asset, { feePerKB, ...options }) =>
56
+ getFeeEstimator(asset, feePerKB, options),
57
+ unconfirmedTxAncestor,
58
+ taprootInputWitnessSize,
59
+ changeAddressType,
60
+ allowUnconfirmedRbfEnabledUtxos: false,
61
+ })
62
+
63
+ if (!selectedUtxos) throw new Error('Not enough funds.')
64
+
65
+ const addressPathsMap = selectedUtxos.getAddressPathsMap()
66
+
67
+ const psbt = new Psbt({ network: asset.coinInfo.toBitcoinJS() })
68
+
69
+ for (const utxo of doShuffle(selectedUtxos.toArray())) {
70
+ const path = addressPathsMap[utxo.address]
71
+ if (!path) {
72
+ throw new Error(`Path missing for input address ${utxo.address}`)
73
+ }
74
+
75
+ const [chainIndex, addressIndex] = BipPath.fromString(path).toPathArray()
76
+ const addressOpts = {
77
+ walletAccount,
78
+ assetName,
79
+ chainIndex: 0,
80
+ addressIndex: 0,
81
+ }
82
+ addressOpts.chainIndex = chainIndex
83
+ addressOpts.addressIndex = addressIndex
84
+ addressOpts.purpose = utxo.address.meta.purpose
85
+
86
+ const [address, xpub] = await Promise.all([
87
+ assetClientInterface.getAddress(addressOpts),
88
+ assetClientInterface.getExtendedPublicKey(addressOpts),
89
+ ])
90
+ assert(String(address) === String(utxo.address))
91
+
92
+ const hdkey = BIP32.fromXPub(xpub)
93
+ const masterFingerprint = Buffer.alloc(4)
94
+ masterFingerprint.writeUint32BE(hdkey.fingerprint)
95
+
96
+ const input = {
97
+ hash: utxo.txId,
98
+ index: utxo.vout,
99
+ witnessUtxo: {
100
+ value: parseInt(utxo.value.toBaseString(), 10),
101
+ script: Buffer.from(utxo.script, 'hex'),
102
+ },
103
+ }
104
+
105
+ if (address.meta.spendingInfo) {
106
+ const { witness, redeem } = address.meta.spendingInfo
107
+ input.tapLeafScript = [
108
+ {
109
+ leafVersion: redeem.redeemVersion,
110
+ script: redeem.output,
111
+ controlBlock: witness[witness.length - 1],
112
+ },
113
+ ]
114
+ }
115
+
116
+ psbt.addInput(input)
117
+
118
+ const pubkey = hdkey.derive(path).publicKey.slice(1)
119
+ const index = psbt.data.inputs.length - 1
120
+ psbt.data.inputs[index].tapBip32Derivation = [
121
+ {
122
+ path,
123
+ leafHashes: [],
124
+ masterFingerprint,
125
+ pubkey,
126
+ },
127
+ ]
128
+ }
129
+
130
+ const change = selectedUtxos.value.sub(amount).sub(fee)
131
+ if (change.gte(asset.currency.baseUnit(DUST_VALUES[changeAddressType]))) {
132
+ const changeAddress = await assetClientInterface.getNextChangeAddress({
133
+ assetName,
134
+ walletAccount,
135
+ })
136
+
137
+ const output = { address: String(changeAddress), amount: change }
138
+
139
+ const path = changeAddress.meta.path
140
+
141
+ const xpub = await assetClientInterface.getExtendedPublicKey({ walletAccount, assetName })
142
+ const hdkey = BIP32.fromXPub(xpub)
143
+ const masterFingerprint = Buffer.alloc(4)
144
+ masterFingerprint.writeUint32BE(hdkey.fingerprint)
145
+
146
+ const pubkey = hdkey.derive(path).publicKey.slice(1)
147
+ output.tapBip32Derivation = [
148
+ {
149
+ path,
150
+ leafHashes: [],
151
+ masterFingerprint,
152
+ pubkey,
153
+ },
154
+ ]
155
+
156
+ recipients.push(output)
157
+ }
158
+
159
+ for (const recipient of doShuffle(recipients)) {
160
+ psbt.addOutput({
161
+ address: recipient.address,
162
+ value: parseInt(recipient.amount.toBaseString(), 10),
163
+ unknownKeyVals: [],
164
+ })
165
+
166
+ const index = psbt.data.outputs.length - 1
167
+ if (recipient.tapBip32Derivation) {
168
+ psbt.data.outputs[index].tapBip32Derivation = recipient.tapBip32Derivation
169
+ }
170
+
171
+ if (recipient.name) {
172
+ psbt.data.outputs[index].unknownKeyVals.push({
173
+ key: Buffer.from('name', 'utf8'),
174
+ value: Buffer.from(recipient.name, 'utf8'),
175
+ })
176
+ }
177
+
178
+ if (recipient.email) {
179
+ psbt.data.outputs[index].unknownKeyVals.push({
180
+ key: Buffer.from('email', 'utf8'),
181
+ value: Buffer.from(recipient.email, 'utf8'),
182
+ })
183
+ }
184
+
185
+ if (recipient.description) {
186
+ psbt.data.outputs[index].unknownKeyVals.push({
187
+ key: Buffer.from('description', 'utf8'),
188
+ value: Buffer.from(recipient.description, 'utf8'),
189
+ })
190
+ }
191
+
192
+ if (recipient.fiatAmount) {
193
+ psbt.data.outputs[index].unknownKeyVals.push({
194
+ key: Buffer.from('fiatAmount', 'utf8'),
195
+ value: Buffer.from(recipient.fiatAmount.toDefaultString(), 'utf8'),
196
+ })
197
+ }
198
+ }
199
+
200
+ const blockHeight = await asset.baseAsset.insightClient.fetchBlockHeight()
201
+
202
+ psbt.setLocktime(blockHeight)
203
+
204
+ return psbt
205
+ }
206
+ }
@@ -1,6 +1,5 @@
1
1
  import assert from 'minimalistic-assert'
2
- // Using this notation so it can be mocked by jest
3
- import doShuffle from 'lodash/shuffle'
2
+ import lodash from 'lodash'
4
3
 
5
4
  import { UtxoCollection, Address } from '@exodus/models'
6
5
  import { retry } from '@exodus/simple-retry'
@@ -268,7 +267,8 @@ export const getPrepareSendTransaction =
268
267
  )
269
268
 
270
269
  const shuffle = (list) => {
271
- return inscriptionIds ? list : doShuffle(list) // don't shuffle when sending ordinal!!!!
270
+ // Using full lodash.shuffle notation so it can be mocked with spyOn in tests
271
+ return inscriptionIds ? list : lodash.shuffle(list) // don't shuffle when sending ordinal!!!!
272
272
  }
273
273
 
274
274
  assert(
@@ -335,7 +335,11 @@ export const getPrepareSendTransaction =
335
335
  }
336
336
 
337
337
  const sendAmount = bumpTxId || transferOrdinalsUtxos ? asset.currency.ZERO : amount
338
- const receiveAddress = bumpTxId ? (replaceableTxs.length > 0 ? null : 'P2WPKH') : address
338
+ const receiveAddress = bumpTxId
339
+ ? replaceableTxs.length > 0
340
+ ? null
341
+ : changeAddressType
342
+ : address
339
343
  const feeRate = feeData.feePerKB
340
344
  const resolvedIsSendAll = (!rbfEnabled && feePerKB) || transferOrdinalsUtxos ? false : isSendAll
341
345
 
@@ -0,0 +1,6 @@
1
+ // extension point so tests can assert signatures deterministically
2
+ // this has to be CJS to still be mockable once we migrate to ESM world
3
+
4
+ function getSchnorrEntropy() {}
5
+
6
+ module.exports = { getSchnorrEntropy }
@@ -1,7 +1,7 @@
1
1
  import { crypto } from '@exodus/bitcoinjs-lib'
2
2
  import assert from 'minimalistic-assert'
3
3
 
4
- import { getSchnorrEntropy } from './default-entropy'
4
+ import defaultEntropy from './default-entropy.cjs'
5
5
  import { ecc } from '../bitcoinjs-lib/ecc'
6
6
  import { getECPair } from '../bitcoinjs-lib'
7
7
 
@@ -59,7 +59,8 @@ export function toAsyncSigner({ keyPair, isTaprootKeySpend, network }) {
59
59
 
60
60
  // eslint-disable-next-line @exodus/mutable/no-param-reassign-prop-only
61
61
  keyPair.signSchnorr = async (h) => {
62
- const sig = ecc.signSchnorr(h, keyPair.privateKey, getSchnorrEntropy())
62
+ // defaultEntropy.getSchnorrEntropy() is mockable with jest.spyOn
63
+ const sig = ecc.signSchnorr(h, keyPair.privateKey, defaultEntropy.getSchnorrEntropy())
63
64
  return Buffer.from(sig)
64
65
  }
65
66
 
@@ -94,7 +95,8 @@ export async function toAsyncBufferSigner({ signer, purpose, keyId, isTaprootKey
94
95
  keyId,
95
96
  signatureType: 'schnorr',
96
97
  tweak,
97
- extraEntropy: getSchnorrEntropy(),
98
+ // defaultEntropy.getSchnorrEntropy() is mockable with jest.spyOn
99
+ extraEntropy: defaultEntropy.getSchnorrEntropy(),
98
100
  })
99
101
  },
100
102
  publicKey,
@@ -1,2 +0,0 @@
1
- // extension point so tests can assert signatures deterministically
2
- export function getSchnorrEntropy() {}