@exodus/bitcoin-api 1.0.0-alpha.4 → 1.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/bitcoin-api",
3
- "version": "1.0.0-alpha.4",
3
+ "version": "1.0.0",
4
4
  "description": "Exodus bitcoin-api",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -21,7 +21,7 @@
21
21
  "dependencies": {
22
22
  "@exodus/asset-lib": "^3.7.1",
23
23
  "@exodus/bitcoinjs-lib": "6.0.2-beta.4",
24
- "@exodus/keychain": "^2.0.0",
24
+ "@exodus/keychain": "^3.0.0",
25
25
  "@exodus/models": "^8.10.4",
26
26
  "@exodus/secp256k1": "4.0.2-exodus.0",
27
27
  "@exodus/simple-retry": "0.0.6",
@@ -33,5 +33,7 @@
33
33
  "socket.io-client": "2.1.1",
34
34
  "url-join": "4.0.0"
35
35
  },
36
- "gitHead": "ba3e6b48c4f717a74e59fc832e306b60ab4df06d"
36
+ "devDependencies": {
37
+ "@noble/secp256k1": "~1.5.3"
38
+ }
37
39
  }
@@ -0,0 +1,44 @@
1
+ import secp256k1 from '@exodus/secp256k1'
2
+
3
+ /**
4
+ * Common ecc functions between mobile and desktop. Once mobile accepts @noble/secp256k1, we can unify both
5
+ */
6
+ export const common = {
7
+ // These methods have been addded in order to comply with the public interface
8
+ // In practice the async version will be used
9
+ sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array =>
10
+ secp256k1.ecdsaSign(h, d, { data: e }).signature,
11
+ verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean =>
12
+ secp256k1.ecdsaVerify(signature, h, Q),
13
+
14
+ privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
15
+ // cloning input. secp256k1 modifies it and it cannot be reused/cached
16
+ secp256k1.privateKeyTweakAdd(Buffer.from(d), tweak),
17
+
18
+ privateNegate: (d: Uint8Array): Uint8Array =>
19
+ // cloning input. secp256k1 modifies it and it cannot be reused/cached
20
+ secp256k1.privateKeyNegate(Buffer.from(d)),
21
+
22
+ xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array) => {
23
+ try {
24
+ const t = secp256k1.publicKeyTweakAdd(toPubKey(p), tweak)
25
+ return {
26
+ parity: t[0] === 0x02 ? 0 : 1,
27
+ xOnlyPubkey: t.slice(1, 33),
28
+ }
29
+ } catch (err) {
30
+ return null
31
+ }
32
+ },
33
+
34
+ isPrivate: (d: Uint8Array): boolean => secp256k1.privateKeyVerify(d),
35
+ pointFromScalar: (d: Uint8Array, compressed?: boolean): Uint8Array | null =>
36
+ secp256k1.publicKeyCreate(d, compressed),
37
+ }
38
+
39
+ export const toPubKey = (xOnly: Uint8Array) => {
40
+ const p = new Uint8Array(33)
41
+ p.set([0x02])
42
+ p.set(xOnly, 1)
43
+ return p
44
+ }
@@ -0,0 +1,49 @@
1
+ import { TinySecp256k1Interface } from '@exodus/bitcoinjs-lib'
2
+ import { Point, schnorr, sign } from '@noble/secp256k1'
3
+ import { common, toPubKey } from './common'
4
+
5
+ /**
6
+ * Wrapper around `secp256k1` in order to follow the bitcoinjs-lib `TinySecp256k1Interface`
7
+ * Schnorr signatures are offered by @noble/secp256k1
8
+ */
9
+ export const desktopEcc: TinySecp256k1Interface = {
10
+ ...common,
11
+
12
+ signAsync: async (h: Uint8Array, d: Uint8Array, extraEntropy?: Uint8Array): Uint8Array =>
13
+ sign(h, d, {
14
+ extraEntropy,
15
+ canonical: true,
16
+ der: false,
17
+ }),
18
+
19
+ signSchnorrAsync: async (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array =>
20
+ schnorr.sign(h, d, e),
21
+ verifySchnorrAsync: async (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean =>
22
+ schnorr.verify(signature, h, Q),
23
+
24
+ // The underlying library does not expose sync functions for Schnorr sign and verify.
25
+ // These function are explicitly defined here as `null` for documentation purposes.
26
+ signSchnorr: null,
27
+ verifySchnorr: null,
28
+
29
+ isPoint: (p: Uint8Array): boolean => {
30
+ try {
31
+ Point.fromHex(p).assertValidity()
32
+ return true
33
+ } catch {
34
+ return false
35
+ }
36
+ },
37
+
38
+ isXOnlyPoint: (p: Uint8Array): boolean => {
39
+ try {
40
+ Point.fromHex(toPubKey(p)).assertValidity()
41
+ return true
42
+ } catch {
43
+ return false
44
+ }
45
+ },
46
+
47
+ pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array =>
48
+ Point.fromHex(p).toRawBytes(compressed),
49
+ }
@@ -1,81 +1,2 @@
1
- import { TinySecp256k1Interface } from '@exodus/bitcoinjs-lib'
2
- // TODO: temp import until '@noble/secp256k1' can be used
3
- import tinySecp256k1 from 'tiny-secp256k1'
4
- import secp256k1 from '@exodus/secp256k1'
5
-
6
- // TODO: migrate library
7
- // import { sign, schnorr, Point } from '@noble/secp256k1'
8
-
9
- /**
10
- * Wrapper around `secp256k1` in order to follow the bitcoinjs-lib `TinySecp256k1Interface`
11
- * Schnorr signatures are offered by @noble/secp256k1
12
- */
13
- export const ecc: TinySecp256k1Interface = {
14
- // These methods have been addded in order to comply with the public interface
15
- // In practice the async version will be used
16
- sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array =>
17
- secp256k1.ecdsaSign(h, d, { data: e }).signature,
18
- verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean =>
19
- secp256k1.ecdsaVerify(signature, h, Q),
20
-
21
- signAsync: async (h: Uint8Array, d: Uint8Array, extraEntropy?: Uint8Array): Uint8Array =>
22
- secp256k1.ecdsaSign(h, d, { data: extraEntropy }).signature,
23
-
24
- // TODO: waiting for the '@noble/secp256k1' lib
25
- // signSchnorrAsync: async (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array =>
26
- // schnorr.sign(h, d, e),
27
- // verifySchnorrAsync: async (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean =>
28
- // schnorr.verify(signature, h, Q),
29
-
30
- // The underlying library does not expose sync functions for Schnorr sign and verify.
31
- // These function are explicitly defined here as `null` for documentation purposes.
32
- signSchnorr: null,
33
- verifySchnorr: null,
34
-
35
- isPoint: (p: Uint8Array): boolean => {
36
- try {
37
- // temp solution secp256k1 does not actually verify the value range, only the data length
38
- return tinySecp256k1.isPoint(Buffer.from(p))
39
- } catch {
40
- return false
41
- }
42
- },
43
-
44
- isXOnlyPoint: (p: Uint8Array): boolean => {
45
- try {
46
- // temp solution secp256k1 does not actually verify the value range, only the data length
47
- return tinySecp256k1.isPoint(Buffer.from(toPubKey(p)))
48
- } catch (err) {
49
- return false
50
- }
51
- },
52
- xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array) => {
53
- try {
54
- const t = secp256k1.publicKeyTweakAdd(toPubKey(p), tweak)
55
- return {
56
- parity: t[0] === 0x02 ? 0 : 1,
57
- xOnlyPubkey: t.slice(1, 33),
58
- }
59
- } catch (err) {
60
- return null
61
- }
62
- },
63
-
64
- privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null =>
65
- secp256k1.privateKeyTweakAdd(d, tweak),
66
-
67
- privateNegate: (d: Uint8Array): Uint8Array => secp256k1.privateKeyNegate(d),
68
-
69
- pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array =>
70
- secp256k1.publicKeyConvert(p, compressed),
71
- isPrivate: (d: Uint8Array): boolean => secp256k1.privateKeyVerify(d),
72
- pointFromScalar: (d: Uint8Array, compressed?: boolean): Uint8Array | null =>
73
- secp256k1.publicKeyCreate(d, compressed),
74
- }
75
-
76
- const toPubKey = (xOnly: Uint8Array) => {
77
- const p = new Uint8Array(33)
78
- p.set([0x02])
79
- p.set(xOnly, 1)
80
- return p
81
- }
1
+ export const eccFactory = (useDesktopEcc) =>
2
+ useDesktopEcc ? require('./desktop').desktopEcc : require('./mobile').mobileEcc
@@ -0,0 +1,48 @@
1
+ import { TinySecp256k1Interface } from '@exodus/bitcoinjs-lib'
2
+ import secp256k1 from '@exodus/secp256k1'
3
+ // TODO: temp import until '@noble/secp256k1' can be used
4
+ import tinySecp256k1 from 'tiny-secp256k1'
5
+ import { common, toPubKey } from './common'
6
+
7
+ /**
8
+ * Wrapper around `secp256k1` in order to follow the bitcoinjs-lib `TinySecp256k1Interface`
9
+ * Schnorr signatures are offered by @noble/secp256k1
10
+ */
11
+ export const mobileEcc: TinySecp256k1Interface = {
12
+ ...common,
13
+
14
+ signAsync: async (h: Uint8Array, d: Uint8Array, extraEntropy?: Uint8Array): Uint8Array =>
15
+ secp256k1.ecdsaSign(h, d, { data: extraEntropy }).signature,
16
+
17
+ // TODO: waiting for the '@noble/secp256k1' lib
18
+ // signSchnorrAsync: async (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array =>
19
+ // schnorr.sign(h, d, e),
20
+ // verifySchnorrAsync: async (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean =>
21
+ // schnorr.verify(signature, h, Q),
22
+
23
+ // The underlying library does not expose sync functions for Schnorr sign and verify.
24
+ // These function are explicitly defined here as `null` for documentation purposes.
25
+ signSchnorr: null,
26
+ verifySchnorr: null,
27
+
28
+ isPoint: (p: Uint8Array): boolean => {
29
+ try {
30
+ // temp solution secp256k1 does not actually verify the value range, only the data length
31
+ return tinySecp256k1.isPoint(Buffer.from(p))
32
+ } catch {
33
+ return false
34
+ }
35
+ },
36
+
37
+ isXOnlyPoint: (p: Uint8Array): boolean => {
38
+ try {
39
+ // temp solution secp256k1 does not actually verify the value range, only the data length
40
+ return tinySecp256k1.isPoint(Buffer.from(toPubKey(p)))
41
+ } catch (err) {
42
+ return false
43
+ }
44
+ },
45
+
46
+ pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array =>
47
+ secp256k1.publicKeyConvert(p, compressed),
48
+ }
@@ -1,4 +1,2 @@
1
- import { ecc } from './ecc'
2
- import { scriptClassify } from './script-classify'
3
-
4
- export { ecc, scriptClassify }
1
+ export * from './ecc'
2
+ export { scriptClassify } from './script-classify'
@@ -1,5 +1,5 @@
1
1
  import { payments } from '@exodus/bitcoinjs-lib'
2
- import { ecc } from '../ecc'
2
+ import assert from 'minimalistic-assert'
3
3
 
4
4
  function isPaymentFactory(payment: any): (script: Buffer, eccLib?: any) => boolean {
5
5
  return (script: Buffer, eccLib?: any): boolean => {
@@ -31,7 +31,8 @@ const types = {
31
31
  NONSTANDARD: 'nonstandard',
32
32
  }
33
33
 
34
- const clasifyOutput = (script: Buffer) => {
34
+ const outputFactory = ({ ecc }) => (script: Buffer) => {
35
+ assert(ecc, 'ecc is required')
35
36
  if (isP2WPKH(script)) return types.P2WPKH
36
37
  if (isP2TR(script, ecc)) return types.P2TR
37
38
  if (isP2PKH(script)) return types.P2PKH
@@ -45,5 +46,5 @@ const clasifyOutput = (script: Buffer) => {
45
46
 
46
47
  export const scriptClassify = {
47
48
  types,
48
- output: clasifyOutput,
49
+ outputFactory,
49
50
  }
@@ -2,19 +2,19 @@ import bs58check from 'bs58check'
2
2
  import bech32 from 'bech32'
3
3
  import assert from 'minimalistic-assert'
4
4
  import { identity, pickBy } from 'lodash'
5
- import { ecc as defaultEcc } from './bitcoinjs-lib'
6
5
 
7
6
  export const createBtcLikeAddress = ({
8
7
  versions,
9
8
  coinInfo,
10
9
  bitcoinjsLib,
11
- ecc = defaultEcc,
10
+ ecc,
12
11
  useBip86 = false,
13
12
  validateFunctions = {},
14
13
  extraFunctions = {},
15
14
  }) => {
16
15
  assert(versions, 'versions is required')
17
16
  assert(coinInfo, 'coinInfo is required')
17
+ assert(ecc, 'ecc is required')
18
18
 
19
19
  const bs58validateFactory = (version) =>
20
20
  version === undefined
@@ -1,22 +1,22 @@
1
1
  import bs58check from 'bs58check'
2
- import secp256k1 from 'tiny-secp256k1'
3
2
  import wif from 'wif'
4
3
  import createHash from 'create-hash'
5
4
  import bech32 from 'bech32'
6
5
  import assert from 'minimalistic-assert'
7
6
  import { identity, pickBy } from 'lodash'
8
7
  import * as defaultBitcoinjsLib from '@exodus/bitcoinjs-lib'
9
- import { ecc } from './bitcoinjs-lib'
10
8
 
11
9
  export const createBtcLikeKeys = ({
12
10
  coinInfo,
13
11
  versions,
12
+ ecc,
14
13
  useBip86 = false,
15
14
  bitcoinjsLib = defaultBitcoinjsLib,
16
15
  extraFunctions = {},
17
16
  }) => {
18
17
  assert(coinInfo, 'coinInfo is required')
19
18
  assert(versions, 'versions is required')
19
+ assert(ecc, 'ecc is required')
20
20
  const {
21
21
  encodePrivate: encodePrivateCustom,
22
22
  encodePublic: encodePublicCustom,
@@ -48,7 +48,7 @@ export const createBtcLikeKeys = ({
48
48
  encodePublicFromWIFCustom ||
49
49
  ((privateKeyWIF) => {
50
50
  const { privateKey, compressed } = wif.decode(privateKeyWIF, coinInfo.versions.private)
51
- const publicKey = secp256k1.pointFromScalar(privateKey, compressed)
51
+ const publicKey = ecc.pointFromScalar(privateKey, compressed)
52
52
  return encodePublicPurpose44(publicKey)
53
53
  })
54
54
  const encodePublicBech32 =
@@ -74,7 +74,7 @@ export const createBtcLikeKeys = ({
74
74
  // NOTE: No password support here
75
75
  const { versions } = coinInfo
76
76
  const { privateKey, compressed } = wif.decode(privateKeyWIF, versions.private)
77
- const publicKey = secp256k1.pointFromScalar(privateKey, compressed)
77
+ const publicKey = ecc.pointFromScalar(privateKey, compressed)
78
78
  return encodePublicBech32(publicKey)
79
79
  }
80
80
  : undefined
@@ -50,21 +50,21 @@ const _canBumpTx = ({
50
50
  }
51
51
  }
52
52
 
53
- const allUtxos = getUtxos({ accountState, asset })
53
+ const utxos = getUtxos({ accountState, asset })
54
54
 
55
- const utxos = getSpendableUtxos({
55
+ const spendableUtxos = getSpendableUtxos({
56
56
  asset,
57
- utxos: allUtxos,
57
+ utxos,
58
58
  feeData,
59
59
  txSet,
60
60
  taprootEnabled,
61
61
  })
62
- if (!utxos) return { errorMessage: 'insufficient funds' }
62
+ if (!spendableUtxos) return { errorMessage: 'insufficient funds' }
63
63
 
64
64
  const { txId } = tx
65
65
  const replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
66
66
  const bumpTx = replaceableTxs.find((tx) => tx.txId === txId)
67
- const changeUtxos = utxos.getTxIdUtxos(txId)
67
+ const changeUtxos = spendableUtxos.getTxIdUtxos(txId)
68
68
 
69
69
  // Can't bump a non-rbf tx with no change
70
70
  if (!bumpTx && changeUtxos.size === 0) return { errorMessage: 'no change' }
@@ -80,7 +80,7 @@ const _canBumpTx = ({
80
80
  if (bumpTx) {
81
81
  const { replaceTx } = selectUtxos({
82
82
  asset,
83
- utxos,
83
+ spendableUtxos,
84
84
  replaceableTxs: [bumpTx],
85
85
  feeRate,
86
86
  receiveAddress: null,
@@ -91,7 +91,7 @@ const _canBumpTx = ({
91
91
 
92
92
  const { fee } = selectUtxos({
93
93
  asset,
94
- utxos,
94
+ spendableUtxos,
95
95
  feeRate,
96
96
  receiveAddress: 'P2WPKH',
97
97
  getFeeEstimator,
@@ -53,17 +53,19 @@ const scriptPubKeyLengths = {
53
53
  // 10 = version: 4, locktime: 4, inputs and outputs count: 1
54
54
  // 148 = txId: 32, vout: 4, count: 1, script: 107 (max), sequence: 4
55
55
  // 34 = value: 8, count: 1, scriptPubKey: 25 (P2PKH) and 23 (P2SH)
56
- export function getSize(
56
+ export const getSizeFactory = ({ ecc }) => (
57
57
  asset: Object,
58
58
  inputs: Array | UtxoCollection,
59
59
  outputs: Array,
60
60
  { compressed = true } = {}
61
- ) {
61
+ ) => {
62
+ assert(ecc, 'ecc is required')
62
63
  if (inputs instanceof UtxoCollection) {
63
64
  inputs = Array.from(inputs).map((utxo) => utxo.script || null)
64
65
  }
65
66
 
66
67
  // other bitcoin-like assets
68
+ const classifyOutput = scriptClassify.outputFactory({ ecc })
67
69
  const baseSize =
68
70
  4 + // n_version
69
71
  4 + // n_locktime
@@ -74,7 +76,7 @@ export function getSize(
74
76
  assert(isHex(script), 'script must be hex string')
75
77
 
76
78
  const scriptBuffer = Buffer.from(script, 'hex')
77
- const scriptType = scriptClassify.output(scriptBuffer)
79
+ const scriptType = classifyOutput(scriptBuffer)
78
80
 
79
81
  const supportedTypes = supportedInputTypes[asset.name] || supportedInputTypes.default
80
82
  assert(
@@ -103,7 +105,7 @@ export function getSize(
103
105
  else if (asset.address.isP2TR && asset.address.isP2TR(output)) scriptType = P2TR
104
106
  else if (asset.address.isP2WSH && asset.address.isP2WSH(output)) scriptType = P2WSH
105
107
  else {
106
- scriptType = scriptClassify.output(asset.address.toScriptPubKey(output))
108
+ scriptType = classifyOutput(asset.address.toScriptPubKey(output))
107
109
  }
108
110
  }
109
111
  assert(
@@ -121,7 +123,7 @@ export function getSize(
121
123
  // witnesses
122
124
  inputs.reduce((t, script) => {
123
125
  if (!script) return t + 1
124
- const utxoScriptType = scriptClassify.output(Buffer.from(script, 'hex'))
126
+ const utxoScriptType = classifyOutput(Buffer.from(script, 'hex'))
125
127
  if ([P2SH, P2WPKH].includes(utxoScriptType)) {
126
128
  const pubKeyLength = 33
127
129
  const signatureLength = 73 // maximum possible length
@@ -158,4 +160,5 @@ export function getSize(
158
160
  return Math.ceil(weight / 4)
159
161
  }
160
162
 
161
- export default createDefaultFeeEstimator(getSize)
163
+ const getFeeEstimatorFactory = ({ ecc }) => createDefaultFeeEstimator(getSizeFactory({ ecc }))
164
+ export default getFeeEstimatorFactory
@@ -1,8 +1,7 @@
1
1
  import assert from 'minimalistic-assert'
2
- import { getAvailableBalance, getFee } from './utxo-selector'
2
+ import { getUtxosData } from './utxo-selector'
3
3
  import { findUnconfirmedSentRbfTxs } from '../tx-utils'
4
4
  import { getSpendableUtxos, getUtxos } from '../utxos-utils'
5
- import { getExtraFee } from './fee-utils'
6
5
  import { canBumpTx } from './can-bump-tx'
7
6
 
8
7
  export class GetFeeResolver {
@@ -16,13 +15,32 @@ export class GetFeeResolver {
16
15
  }
17
16
 
18
17
  getFee = ({ asset, accountState, txSet, feeData, amount, customFee, isSendAll }) => {
19
- assert(asset, 'asset must be provided')
20
- assert(feeData, 'feeData must be provided')
21
- assert(accountState, 'accountState must be provided')
22
- assert(txSet, 'txSet must be provided')
18
+ const { resolvedFee, extraFee } = this.#getUtxosData({
19
+ asset,
20
+ accountState,
21
+ txSet,
22
+ feeData,
23
+ amount,
24
+ customFee,
25
+ isSendAll,
26
+ })
27
+ return { fee: resolvedFee, extraFee }
28
+ }
23
29
 
24
- const utxos = getUtxos({ accountState, asset })
30
+ getAvailableBalance = ({ asset, accountState, txSet, feeData, amount, customFee, isSendAll }) => {
31
+ return this.#getUtxosData({
32
+ asset,
33
+ accountState,
34
+ txSet,
35
+ feeData,
36
+ customFee,
37
+ isSendAll,
38
+ amount,
39
+ }).availableBalance
40
+ }
25
41
 
42
+ getSpendableBalance = ({ asset, accountState, txSet, feeData }) => {
43
+ const utxos = getUtxos({ accountState, asset })
26
44
  const spendableUtxos = getSpendableUtxos({
27
45
  asset,
28
46
  utxos,
@@ -30,36 +48,17 @@ export class GetFeeResolver {
30
48
  txSet,
31
49
  taprootEnabled: this.#taprootEnabled,
32
50
  })
33
- const replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
34
-
35
- const receiveAddress = ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name)
36
- ? 'P2WSH'
37
- : 'P2PKH'
38
-
39
- const feePerKB = customFee || feeData.feePerKB
40
- const fee = getFee({
41
- asset,
42
- utxos: spendableUtxos,
43
- replaceableTxs,
44
- amount,
45
- feeRate: feePerKB,
46
- receiveAddress,
47
- isSendAll,
48
- getFeeEstimator: this.#getFeeEstimator,
49
- })
50
- const extraFee = asset.currency.baseUnit(
51
- getExtraFee({ asset, inputs: spendableUtxos, feePerKB })
52
- )
53
- return { fee, extraFee }
51
+ return spendableUtxos.value
54
52
  }
55
53
 
56
- getAvailableBalance = ({ asset, accountState, txSet, feeData, customFee }) => {
54
+ #getUtxosData = ({ asset, accountState, txSet, feeData, amount, customFee, isSendAll }) => {
57
55
  assert(asset, 'asset must be provided')
58
56
  assert(feeData, 'feeData must be provided')
59
57
  assert(accountState, 'accountState must be provided')
60
58
  assert(txSet, 'txSet must be provided')
61
59
 
62
60
  const utxos = getUtxos({ accountState, asset })
61
+
63
62
  const spendableUtxos = getSpendableUtxos({
64
63
  asset,
65
64
  utxos,
@@ -72,12 +71,16 @@ export class GetFeeResolver {
72
71
  const receiveAddress = ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name)
73
72
  ? 'P2WSH'
74
73
  : 'P2PKH'
75
- return getAvailableBalance({
74
+
75
+ const feePerKB = customFee || feeData.feePerKB
76
+ return getUtxosData({
76
77
  asset,
77
- utxos: spendableUtxos,
78
+ spendableUtxos,
78
79
  replaceableTxs,
79
- feeRate: customFee || feeData.feePerKB,
80
+ amount,
81
+ feeRate: feePerKB,
80
82
  receiveAddress,
83
+ isSendAll: isSendAll,
81
84
  getFeeEstimator: this.#getFeeEstimator,
82
85
  })
83
86
  }
package/src/fee/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from './get-fee-resolver'
2
- export { default as getFeeEstimator } from './fee-estimator'
2
+ export { default as getFeeEstimatorFactory } from './fee-estimator'
@@ -1,12 +1,13 @@
1
1
  import { UtxoCollection } from '@exodus/models'
2
2
  import NumberUnit from '@exodus/currency'
3
+ import assert from 'minimalistic-assert'
4
+ import { getExtraFee } from './fee-utils'
3
5
 
4
6
  const MIN_RELAY_FEE = 1000
5
- const AVG_TX_SIZE = 192
6
7
 
7
8
  export const selectUtxos = ({
8
9
  asset,
9
- utxos: spendableUtxos,
10
+ spendableUtxos,
10
11
  replaceableTxs,
11
12
  amount,
12
13
  feeRate,
@@ -16,6 +17,14 @@ export const selectUtxos = ({
16
17
  disableReplacement = false,
17
18
  mustSpendUtxos,
18
19
  }) => {
20
+ assert(asset, 'asset is required')
21
+ assert(spendableUtxos, 'spendableUtxos is required')
22
+ assert(getFeeEstimator, 'getFeeEstimator is required')
23
+
24
+ const changeAddressType = ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name)
25
+ ? 'P2WPKH'
26
+ : 'P2PKH'
27
+
19
28
  const feeEstimator = getFeeEstimator(asset, { feePerKB: feeRate })
20
29
  const { currency } = asset
21
30
  if (!amount) amount = currency.ZERO
@@ -49,7 +58,7 @@ export const selectUtxos = ({
49
58
  ? tx.data.sent.map(({ address }) => address)
50
59
  : [
51
60
  ...tx.data.sent.map(({ address }) => address),
52
- tx.data.changeAddress ? tx.data.changeAddress.address : 'P2WPKH',
61
+ tx.data.changeAddress?.address || changeAddressType,
53
62
  ]
54
63
  if (receiveAddress) {
55
64
  outputs.push(receiveAddress)
@@ -86,9 +95,7 @@ export const selectUtxos = ({
86
95
  }
87
96
  }
88
97
  if (isSendAll || replaceTxAmount.add(additionalUtxos.value).gte(amount.add(fee))) {
89
- const chainOutputs = isSendAll
90
- ? [receiveAddress]
91
- : [receiveAddress, asset.name === 'bitcoin' ? 'P2WPKH' : 'P2PKH']
98
+ const chainOutputs = isSendAll ? [receiveAddress] : [receiveAddress, changeAddressType]
92
99
  const chainFee = feeEstimator({
93
100
  inputs: changeUtxos.union(additionalUtxos),
94
101
  outputs: chainOutputs,
@@ -98,7 +105,7 @@ export const selectUtxos = ({
98
105
  if ((!amount.isZero || tx.data.changeAddress) && fee.sub(tx.feeAmount).gte(chainFee)) {
99
106
  continue
100
107
  }
101
- return { utxos: additionalUtxos, fee, replaceTx: tx }
108
+ return { selectedUtxos: additionalUtxos, fee, replaceTx: tx }
102
109
  }
103
110
  }
104
111
  }
@@ -116,17 +123,14 @@ export const selectUtxos = ({
116
123
  }
117
124
 
118
125
  const utxosArray = spendableUtxos.union(ourRbfUtxos).toPriorityOrderedArray()
119
- if (utxosArray.length === 0) return {}
120
-
121
- const outputs =
122
- isSendAll || amount.isZero
123
- ? [receiveAddress]
124
- : [receiveAddress, asset.name === 'bitcoin' ? 'P2WPKH' : 'P2PKH']
125
126
 
126
127
  if (isSendAll) {
127
128
  const selectedUtxos = UtxoCollection.fromArray(utxosArray, { currency })
128
- const fee = feeEstimator({ inputs: selectedUtxos, outputs })
129
- return { utxos: selectedUtxos, fee }
129
+ const fee = feeEstimator({ inputs: selectedUtxos, outputs: [receiveAddress] })
130
+ if (selectedUtxos.value.lt(amount.add(fee))) {
131
+ return { fee }
132
+ }
133
+ return { selectedUtxos, fee }
130
134
  }
131
135
 
132
136
  // quickly add utxos to get to amount before starting to figure out fees, the minimum place to start is as much as the amount
@@ -149,6 +153,8 @@ export const selectUtxos = ({
149
153
  let selectedUtxos = UtxoCollection.fromArray(selectedUtxosArray, { currency })
150
154
 
151
155
  // start figuring out fees
156
+ const outputs = amount.isZero ? [changeAddressType] : [receiveAddress, changeAddressType]
157
+
152
158
  let fee = feeEstimator({ inputs: selectedUtxos, outputs })
153
159
 
154
160
  while (selectedUtxos.value.lt(amount.add(fee))) {
@@ -156,9 +162,6 @@ export const selectUtxos = ({
156
162
  if (remainingUtxosArray.length === 0) {
157
163
  // Try fee with no change
158
164
  fee = feeEstimator({ inputs: selectedUtxos, outputs: [receiveAddress] })
159
- if (selectedUtxos.value.lt(amount.add(fee))) {
160
- return {}
161
- }
162
165
  break
163
166
  }
164
167
 
@@ -166,43 +169,15 @@ export const selectUtxos = ({
166
169
  selectedUtxos = selectedUtxos.addUtxo(remainingUtxosArray.shift())
167
170
  fee = feeEstimator({ inputs: selectedUtxos, outputs })
168
171
  }
169
-
170
- return { utxos: selectedUtxos, fee }
171
- }
172
-
173
- export const getAvailableBalance = ({
174
- asset,
175
- utxos,
176
- replaceableTxs,
177
- feeRate,
178
- receiveAddress,
179
- getFeeEstimator,
180
- disableReplacement,
181
- }) => {
182
- const { utxos: selectedUtxos, replaceTx, fee } = selectUtxos({
183
- asset,
184
- utxos,
185
- replaceableTxs,
186
- feeRate,
187
- receiveAddress,
188
- isSendAll: true,
189
- getFeeEstimator,
190
- disableReplacement,
191
- })
192
- if (!selectedUtxos) return asset.currency.ZERO
193
- if (replaceTx) {
194
- return utxos
195
- .getTxIdUtxos(replaceTx.txId)
196
- .union(selectedUtxos)
197
- .value.sub(fee)
198
- .add(replaceTx.feeAmount)
172
+ if (selectedUtxos.value.lt(amount.add(fee))) {
173
+ return { fee }
199
174
  }
200
- return selectedUtxos.value.sub(fee)
175
+ return { selectedUtxos, fee }
201
176
  }
202
177
 
203
- export const getFee = ({
178
+ export const getUtxosData = ({
204
179
  asset,
205
- utxos,
180
+ spendableUtxos,
206
181
  replaceableTxs,
207
182
  amount,
208
183
  feeRate,
@@ -210,10 +185,11 @@ export const getFee = ({
210
185
  isSendAll,
211
186
  getFeeEstimator,
212
187
  disableReplacement,
188
+ mustSpendUtxos,
213
189
  }) => {
214
- const { replaceTx, fee } = selectUtxos({
190
+ const { selectedUtxos, replaceTx, fee } = selectUtxos({
215
191
  asset,
216
- utxos,
192
+ spendableUtxos,
217
193
  replaceableTxs,
218
194
  amount,
219
195
  feeRate,
@@ -221,8 +197,25 @@ export const getFee = ({
221
197
  isSendAll,
222
198
  getFeeEstimator,
223
199
  disableReplacement,
200
+ mustSpendUtxos,
224
201
  })
225
- if (!fee) return asset.currency.baseUnit(Math.ceil((feeRate.toBaseNumber() * AVG_TX_SIZE) / 1000))
226
- if (!replaceTx) return fee
227
- return fee.sub(replaceTx.feeAmount)
202
+
203
+ const resolvedFee = replaceTx ? fee.sub(replaceTx.feeAmount) : fee
204
+
205
+ const spendableBalance = spendableUtxos.value
206
+
207
+ const extraFee = selectedUtxos
208
+ ? asset.currency.baseUnit(getExtraFee({ asset, inputs: selectedUtxos, feePerKB: feeRate }))
209
+ : asset.currency.ZERO
210
+
211
+ const availableBalance = spendableBalance.sub(resolvedFee).clampLowerZero()
212
+ return {
213
+ spendableBalance,
214
+ availableBalance,
215
+ selectedUtxos,
216
+ fee,
217
+ resolvedFee,
218
+ replaceTx,
219
+ extraFee,
220
+ }
228
221
  }
package/src/index.js CHANGED
@@ -7,9 +7,11 @@ export { default as InsightAPIClient } from './insight-api-client'
7
7
  export { default as InsightWSClient } from './insight-api-client/ws'
8
8
  export { default as bip44Constants } from './constants/bip44'
9
9
  export { default as createGetKeyIdentifier } from './key-identifier'
10
-
10
+ export * from './tx-send'
11
+ export * from './tx-sign'
11
12
  export * from './fee'
12
13
  export * from './utxos-utils'
13
14
  export * from './tx-log'
14
15
  export * from './unconfirmed-ancestor-data'
16
+ export * from './parse-unsigned-tx'
15
17
  export * from './insight-api-client/util'
@@ -7,6 +7,7 @@ import { updateUnconfirmedAncestorData } from '../unconfirmed-ancestor-data'
7
7
  import { BitcoinMonitorScanner } from './bitcoin-monitor-scanner'
8
8
  import { normalizeInsightConfig, toWSUrl } from '../insight-api-client/util'
9
9
  import ms from 'ms'
10
+ import delay from 'delay'
10
11
 
11
12
  // NOTE: this is a frankenstein mashup of Exodus desktop
12
13
  // assets-refresh/insight action + Neo monitor
@@ -58,6 +59,9 @@ export class Monitor extends BaseMonitor {
58
59
  })
59
60
 
60
61
  this.addHook('after-tick-multiple-wallet-accounts', () => this.#subscribeToNewAddresses())
62
+ this.addHook('after-stop', async () =>
63
+ Promise.all(Object.keys(this.#runningByWalletAccount).map(this.#waitForWalletToFinish))
64
+ )
61
65
  }
62
66
 
63
67
  setServer(assetConfig = {}) {
@@ -180,10 +184,22 @@ export class Monitor extends BaseMonitor {
180
184
 
181
185
  tick = async ({ walletAccount, refresh }) => {
182
186
  assert(walletAccount, 'walletAccount is expected')
183
- if (this.#runningByWalletAccount[walletAccount]) return
184
- this.#runningByWalletAccount[walletAccount] = true
187
+ // 1) if no tick is running, any tick runs
188
+ // 2) if a regular tick is running, a refresh tick waits and runs
189
+ // 3) if a regular tick is running, a regular tick does not wait and does not run
190
+ // 4) if a refresh tick is running, a refresh tick does not wait and does not run
191
+ // 5) if a refresh tick is running, a regular tick does not wait and does not run
192
+ if (refresh && this.#runningByWalletAccount[walletAccount]?.refresh !== refresh) {
193
+ await this.#waitForWalletToFinish(walletAccount)
194
+ }
195
+ if (this.#runningByWalletAccount[walletAccount]) {
196
+ this.logger.debug(`Skipping ${walletAccount} tick as previous tick is still running`)
197
+ return
198
+ }
199
+ const promise = this.#syncWalletAccount({ walletAccount, refresh })
200
+ this.#runningByWalletAccount[walletAccount] = { refresh, promise }
185
201
  try {
186
- await this.#syncWalletAccount({ walletAccount, refresh })
202
+ await promise
187
203
  } finally {
188
204
  delete this.#runningByWalletAccount[walletAccount]
189
205
  }
@@ -242,6 +258,20 @@ export class Monitor extends BaseMonitor {
242
258
  #logWsStatus = (message, ...args) => {
243
259
  // console.debug('btc-like monitor', this.asset.name, message, ...args)
244
260
  }
261
+
262
+ #waitForWalletToFinish = async (walletAccount) => {
263
+ const tickState = this.#runningByWalletAccount[walletAccount]
264
+ if (!tickState) return
265
+
266
+ const finished = await Promise.race([
267
+ tickState.promise.then(() => true),
268
+ delay(ms('10s')).then(() => false),
269
+ ])
270
+
271
+ if (!finished) {
272
+ this.logger.warn(`Tick for ${walletAccount} did not finish on time`)
273
+ }
274
+ }
245
275
  }
246
276
 
247
277
  export const createBitcoinMonitor = (args) => new Monitor(args)
@@ -1,5 +1,6 @@
1
1
  import assert from 'minimalistic-assert'
2
- import lodash from 'lodash'
2
+ // Using this notation so it can be mocked by jest
3
+ import shuffle from 'lodash/shuffle'
3
4
 
4
5
  import { UtxoCollection, Address } from '@exodus/models'
5
6
  import { retry } from '@exodus/simple-retry'
@@ -12,11 +13,13 @@ import {
12
13
  createInputs as dogecoinCreateInputs,
13
14
  createOutput as dogecoinCreateOutput,
14
15
  } from './dogecoin'
15
- import { findUnconfirmedSentTxs } from '../tx-utils'
16
+ import { findUnconfirmedSentRbfTxs } from '../tx-utils'
16
17
  import { getSpendableUtxos, getUtxos } from '../utxos-utils'
17
18
 
18
19
  const ASSETS_SUPPORTED_BIP_174 = [
19
20
  'bitcoin',
21
+ 'bitcoinregtest',
22
+ 'bitcointestnet',
20
23
  'litecoin',
21
24
  'dash',
22
25
  'ravencoin',
@@ -86,16 +89,12 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
86
89
  const {
87
90
  multipleAddressesEnabled,
88
91
  feePerKB,
89
- hdkey44,
90
- hdkey84,
91
- hdkey86,
92
92
  customFee,
93
93
  isSendAll,
94
94
  isExchange,
95
95
  isBip70,
96
96
  bumpTxId,
97
97
  isRbfAllowed = true,
98
- preBroadcastHook,
99
98
  } = options
100
99
 
101
100
  assert(assetClientInterface, `assetClientInterface must be supplied in sendTx for ${asset.name}`)
@@ -121,7 +120,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
121
120
  const insightClient = asset.baseAsset.insightClient
122
121
  const currency = asset.currency
123
122
  const feeData = await assetClientInterface.getFeeConfig({ assetName })
124
- const utxos = getSpendableUtxos({
123
+ const spendableUtxos = getSpendableUtxos({
125
124
  asset,
126
125
  utxos: getUtxos({ accountState, asset }),
127
126
  feeData,
@@ -129,7 +128,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
129
128
  taprootEnabled,
130
129
  })
131
130
 
132
- let replaceableTxs = findUnconfirmedSentTxs(txSet)
131
+ let replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
133
132
 
134
133
  if (assetName === 'bcash') {
135
134
  address = asset.address.toLegacyAddress(address)
@@ -146,7 +145,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
146
145
  if (bumpTxId) {
147
146
  const bumpTx = replaceableTxs.find(({ txId }) => txId === bumpTxId)
148
147
  if (!bumpTx) {
149
- utxosToBump = utxos.getTxIdUtxos(bumpTxId)
148
+ utxosToBump = spendableUtxos.getTxIdUtxos(bumpTxId)
150
149
  if (utxosToBump.size === 0) {
151
150
  throw new Error(`Cannot bump transaction ${bumpTxId}`)
152
151
  }
@@ -160,9 +159,9 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
160
159
  let receiveAddress = bumpTxId ? (replaceableTxs.length > 0 ? null : 'P2WPKH') : address
161
160
  const feeRate = feeData.feePerKB
162
161
 
163
- let { utxos: selected, fee, replaceTx } = selectUtxos({
162
+ let { selectedUtxos, fee, replaceTx } = selectUtxos({
164
163
  asset,
165
- utxos,
164
+ spendableUtxos,
166
165
  replaceableTxs,
167
166
  amount: sendAmount,
168
167
  feeRate: customFee || feeRate,
@@ -172,14 +171,14 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
172
171
  mustSpendUtxos: utxosToBump,
173
172
  })
174
173
 
175
- if (!selected && !replaceTx) throw new Error('Not enough funds.')
174
+ if (!selectedUtxos && !replaceTx) throw new Error('Not enough funds.')
176
175
 
177
176
  // When bumping a tx, we can either replace the tx with RBF or spend its selected change.
178
177
  // If there is no selected UTXO or the tx to replace is not the tx we want to bump,
179
178
  // then something is wrong because we can't actually bump the tx.
180
179
  // This shouldn't happen but might due to either the tx confirming before accelerate was
181
180
  // pressed, or if the change was already spent from another wallet.
182
- if (bumpTxId && (!selected || (replaceTx && replaceTx.txId !== bumpTxId))) {
181
+ if (bumpTxId && (!selectedUtxos || (replaceTx && replaceTx.txId !== bumpTxId))) {
183
182
  throw new Error(`Unable to bump ${bumpTxId}`)
184
183
  }
185
184
 
@@ -189,13 +188,13 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
189
188
  replaceTx.data.sent = replaceTx.data.sent.map((to) => {
190
189
  return { ...to, amount: parseCurrency(to.amount, asset.currency) }
191
190
  })
192
- selected = selected.union(
191
+ selectedUtxos = selectedUtxos.union(
193
192
  UtxoCollection.fromJSON(replaceTx.data.inputs, { currency: asset.currency })
194
193
  )
195
194
  }
196
195
 
197
196
  // transform UTXO object to raw
198
- const inputs = lodash.shuffle(createInputs(assetName, selected.toArray(), rbfEnabled))
197
+ const inputs = shuffle(createInputs(assetName, selectedUtxos.toArray(), rbfEnabled))
199
198
 
200
199
  let outputs
201
200
  if (replaceTx) {
@@ -213,7 +212,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
213
212
  ? replaceTx.data.sent.reduce((total, { amount }) => total.add(amount), sendAmount)
214
213
  : sendAmount
215
214
 
216
- const change = selected.value.sub(totalAmount).sub(fee)
215
+ const change = selectedUtxos.value.sub(totalAmount).sub(fee)
217
216
  const dust = getDustValue(asset)
218
217
  let ourAddress = replaceTx?.data?.changeAddress || changeAddress
219
218
  if (['bcash'].includes(assetName)) {
@@ -233,7 +232,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
233
232
  fee = fee.add(change)
234
233
  }
235
234
 
236
- outputs = replaceTx ? outputs : lodash.shuffle(outputs)
235
+ outputs = replaceTx ? outputs : shuffle(outputs)
237
236
  const blockHeight = ['zcash', 'bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)
238
237
  ? await insightClient.fetchBlockHeight()
239
238
  : 0
@@ -245,24 +244,19 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
245
244
  outputs,
246
245
  },
247
246
  txMeta: {
248
- addressPathsMap: selected.getAddressPathsMap(),
247
+ addressPathsMap: selectedUtxos.getAddressPathsMap(),
249
248
  blockHeight,
250
249
  },
251
250
  }
252
251
 
253
- const nonWitnessTxs = await getNonWitnessTxs(asset, selected, insightClient)
252
+ const nonWitnessTxs = await getNonWitnessTxs(asset, selectedUtxos, insightClient)
254
253
  Object.assign(unsignedTx.txMeta, { rawTxs: nonWitnessTxs })
255
- const { rawTx, txId, tx } = await asset.baseAsset.api.signUnsignedTx(unsignedTx, undefined, {
256
- asset,
257
- hdkey44,
258
- hdkey84,
259
- hdkey86,
254
+ const { rawTx, txId, tx } = await assetClientInterface.signTransaction({
255
+ assetName,
256
+ unsignedTx,
257
+ walletAccount,
260
258
  })
261
259
 
262
- if (preBroadcastHook) {
263
- await preBroadcastHook({ txId, unsignedTx })
264
- }
265
-
266
260
  const broadcastTxWithRetry = retry(
267
261
  async (rawTx) => {
268
262
  try {
@@ -293,7 +287,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
293
287
  err.txInfo = JSON.stringify({
294
288
  amount: sendAmount.toDefaultString({ unit: true }),
295
289
  fee: ((fee && fee.toDefaultString({ unit: true })) || 0).toString(), // todo why does 0 not have a unit? Is default unit ok here?
296
- allUtxos: utxos.toJSON(),
290
+ allUtxos: spendableUtxos.toJSON(),
297
291
  })
298
292
  throw err
299
293
  } else {
@@ -312,7 +306,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
312
306
  }
313
307
  }
314
308
 
315
- let remainingUtxos = utxos.difference(selected)
309
+ let remainingUtxos = spendableUtxos.difference(selectedUtxos)
316
310
  if (changeUtxoIndex !== -1) {
317
311
  const address = Address.create(ourAddress.address, ourAddress.meta)
318
312
  const changeUtxo = {
@@ -374,7 +368,7 @@ export const createAndBroadcastTXFactory = ({ getFeeEstimator, taprootEnabled })
374
368
  changeAddress: changeOutput ? ourAddress : undefined,
375
369
  blockHeight,
376
370
  blocksSeen: 0,
377
- inputs: selected.toJSON(),
371
+ inputs: selectedUtxos.toJSON(),
378
372
  replacedTxId: replaceTx ? replaceTx.txId : undefined,
379
373
  },
380
374
  },
@@ -3,7 +3,7 @@ import lodash from 'lodash'
3
3
  import ECPairFactory from 'ecpair'
4
4
  import { Psbt } from '@exodus/bitcoinjs-lib'
5
5
 
6
- import { ecc } from '../bitcoinjs-lib'
6
+ import { toAsyncSigner, tweakSigner } from './taproot'
7
7
 
8
8
  let ECPair
9
9
 
@@ -12,67 +12,88 @@ const _MAXIMUM_FEE_RATES = {
12
12
  ravencoin: 1000000,
13
13
  }
14
14
 
15
- export function signTx({ asset, unsignedTx, hdkey44, hdkey84, hdkey86 }): Object {
16
- assert(asset, 'asset is required')
17
- assert(unsignedTx, 'unsignedTx is required')
18
- const assetName = asset.name
19
- const { privateKeysAddressMap, addressPathsMap, rawTxs } = unsignedTx.txMeta
20
- const { inputs, outputs } = unsignedTx.txData
21
- const networkInfo = { ...asset.coinInfo.toBitcoinJS(), messagePrefix: '' }
15
+ export const signTxFactory = ({ assetName, resolvePurpose, keys, coinInfo, network, ecc }) => {
16
+ assert(assetName, 'assetName is required')
17
+ assert(resolvePurpose, 'resolvePurpose is required')
18
+ assert(keys, 'keys is required')
19
+ assert(coinInfo, 'coinInfo is required')
20
+ assert(ecc, 'ecc is required')
21
+ return async ({ unsignedTx, hdkeys }): Object => {
22
+ assert(unsignedTx, 'unsignedTx is required')
23
+ const { privateKeysAddressMap, addressPathsMap, rawTxs } = unsignedTx.txMeta
24
+ const { inputs, outputs } = unsignedTx.txData
25
+ const networkInfo = { ...coinInfo.toBitcoinJS(), messagePrefix: '' }
22
26
 
23
- // use harcoded max fee rates for specific assets
24
- // if undefined, will be set to default value by PSBT (2500)
25
- const maximumFeeRate = _MAXIMUM_FEE_RATES[assetName]
27
+ // use harcoded max fee rates for specific assets
28
+ // if undefined, will be set to default value by PSBT (2500)
29
+ const maximumFeeRate = _MAXIMUM_FEE_RATES[assetName]
26
30
 
27
- const psbt = new Psbt({ maximumFeeRate, eccLib: ecc, network: networkInfo })
31
+ const psbt = new Psbt({ maximumFeeRate, eccLib: ecc, network: networkInfo })
28
32
 
29
- if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
33
+ if (!['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(assetName)) psbt.setVersion(1)
30
34
 
31
- ECPair = ECPair || ECPairFactory(ecc)
35
+ ECPair = ECPair || ECPairFactory(ecc)
32
36
 
33
- const getKey = lodash.memoize((address) => {
34
- // TODO: Consider using privateKeysAddressMap for other assets
35
- if (privateKeysAddressMap) return ECPair.fromWIF(privateKeysAddressMap[address], networkInfo)
36
- const path = addressPathsMap[address]
37
- const isSegwitAddress = address.startsWith(asset.address.versions.segwit)
38
- const isTaprootAddress = address.startsWith(asset.address.versions.taproot)
39
- const _hdkey = isSegwitAddress ? hdkey84 : isTaprootAddress ? hdkey86 : hdkey44
40
- const privateEncoded = asset.keys.encodePrivate(_hdkey.derive(path).privateKey)
41
- return ECPair.fromWIF(privateEncoded, networkInfo)
42
- })
37
+ const getKeyAndPurpose = lodash.memoize((address) => {
38
+ // TODO: Consider using privateKeysAddressMap for other assets
39
+ if (privateKeysAddressMap) return ECPair.fromWIF(privateKeysAddressMap[address], networkInfo)
40
+ const path = addressPathsMap[address]
41
+ const purpose = resolvePurpose(address)
42
+ assert(hdkeys, 'hdkeys must be provided')
43
+ assert(purpose, `purpose for address ${address} could not be resolved`)
44
+ const hdkey = hdkeys[purpose]
45
+ assert(hdkey, `hdkey for purpose for ${purpose} and address ${address} could not be resolved`)
46
+ const derivedhdkey = hdkey.derive(path)
47
+ const privateEncoded = keys.encodePrivate(derivedhdkey.privateKey)
48
+ return { key: ECPair.fromWIF(privateEncoded, networkInfo), purpose }
49
+ })
43
50
 
44
- // Fill tx
45
- for (const { txId, vout, address, value, script, sequence } of inputs) {
46
- const isSegwitAddress = address.startsWith(asset.address.versions.segwit)
47
- const isTaprootAddress = address.startsWith(asset.address.versions.taproot)
48
- const txIn = { hash: txId, index: vout, sequence }
49
- if (isSegwitAddress || isTaprootAddress) {
50
- // witness outputs only require the value and the script, not the full transaction
51
- txIn.witnessUtxo = { value, script: Buffer.from(script, 'hex') }
52
- } else {
53
- // non-witness outptus require the full transaction
54
- const rawTx = (rawTxs || []).find((t) => t.txId === txId)
55
- assert(!!rawTx, 'Non-witness outputs require the full previous transaction.')
56
- txIn.nonWitnessUtxo = Buffer.from(rawTx.rawData, 'hex')
51
+ // Fill tx
52
+ for (const { txId, vout, address, value, script, sequence } of inputs) {
53
+ const { purpose } = getKeyAndPurpose(address)
54
+ const isSegwitAddress = purpose === 84
55
+ const isTaprootAddress = purpose === 86
56
+ const txIn = { hash: txId, index: vout, sequence }
57
+ if (isSegwitAddress || isTaprootAddress) {
58
+ // witness outputs only require the value and the script, not the full transaction
59
+ txIn.witnessUtxo = { value, script: Buffer.from(script, 'hex') }
60
+ } else {
61
+ // non-witness outptus require the full transaction
62
+ const rawTx = (rawTxs || []).find((t) => t.txId === txId)
63
+ assert(!!rawTx, 'Non-witness outputs require the full previous transaction.')
64
+ txIn.nonWitnessUtxo = Buffer.from(rawTx.rawData, 'hex')
65
+ }
66
+ psbt.addInput(txIn)
57
67
  }
58
- psbt.addInput(txIn)
59
- }
60
68
 
61
- for (const [address, amount] of outputs) {
62
- psbt.addOutput({ value: amount, address })
63
- }
69
+ for (const [address, amount] of outputs) {
70
+ psbt.addOutput({ value: amount, address })
71
+ }
64
72
 
65
- // Sign tx
66
- inputs.forEach(({ address }, index) => {
67
- const key = getKey(address)
68
- psbt.signInput(index, key)
69
- })
73
+ // The Taproot SIGHASH flag includes all previous outputs,
74
+ // so signing is only done AFTER all inputs have been updated
75
+ for (let index = 0; index < inputs.length; index++) {
76
+ const { address } = inputs[index]
77
+ const { key, purpose } = getKeyAndPurpose(address)
78
+ if (ecc.signSchnorrAsync) {
79
+ // desktop / BE signing
80
+ const isTaprootAddress = purpose === 86
81
+ const signingKey = isTaprootAddress
82
+ ? tweakSigner({ signer: key, ECPair, ecc, network })
83
+ : key
84
+ await psbt.signInputAsync(index, toAsyncSigner({ keyPair: signingKey, ecc }))
85
+ } else {
86
+ // mobile signing
87
+ psbt.signInput(index, key)
88
+ }
89
+ }
70
90
 
71
- // Serialize tx
72
- psbt.finalizeAllInputs()
73
- const tx = psbt.extractTransaction()
74
- const rawTx = tx.toBuffer()
75
- const txId = tx.getId()
91
+ // Serialize tx
92
+ psbt.finalizeAllInputs()
93
+ const tx = psbt.extractTransaction()
94
+ const rawTx = tx.toBuffer()
95
+ const txId = tx.getId()
76
96
 
77
- return { rawTx, txId, tx }
97
+ return { rawTx, txId, tx }
98
+ }
78
99
  }
@@ -1 +1 @@
1
- export { signTx } from './default-create-tx'
1
+ export { signTxFactory } from './default-create-tx'
@@ -0,0 +1,49 @@
1
+ import { crypto } from '@exodus/bitcoinjs-lib'
2
+ import assert from 'minimalistic-assert'
3
+
4
+ export function tweakSigner({ signer, ECPair, ecc, tweakHash, network }) {
5
+ assert(signer, 'signer is required')
6
+ assert(ECPair, 'ECPair is required')
7
+ assert(ecc, 'ecc is required')
8
+ let privateKey: Uint8Array | undefined = signer.privateKey
9
+ if (!privateKey) {
10
+ throw new Error('Private key is required for tweaking signer!')
11
+ }
12
+ if (signer.publicKey[0] === 3) {
13
+ privateKey = ecc.privateNegate(privateKey)
14
+ }
15
+
16
+ const tweakedPrivateKey = ecc.privateAdd(
17
+ privateKey,
18
+ tapTweakHash(signer.publicKey.slice(1, 33), tweakHash)
19
+ )
20
+ if (!tweakedPrivateKey) {
21
+ throw new Error('Invalid tweaked private key!')
22
+ }
23
+
24
+ return ECPair.fromPrivateKey(Buffer.from(tweakedPrivateKey), {
25
+ network,
26
+ })
27
+ }
28
+
29
+ function tapTweakHash(pubKey: Buffer, h: Buffer | undefined): Buffer {
30
+ return crypto.taggedHash('TapTweak', Buffer.concat(h ? [pubKey, h] : [pubKey]))
31
+ }
32
+
33
+ /**
34
+ * Take a sync signer and make it async.
35
+ */
36
+ export function toAsyncSigner({ keyPair, ecc }) {
37
+ assert(keyPair, 'keyPair is required')
38
+ assert(ecc, 'ecc is required')
39
+ keyPair.sign = async (h) => {
40
+ const sig = await ecc.signAsync(h, keyPair.privateKey)
41
+ return Buffer.from(sig)
42
+ }
43
+
44
+ keyPair.signSchnorr = async (h) => {
45
+ const sig = await ecc.signSchnorrAsync(h, keyPair.privateKey)
46
+ return Buffer.from(sig)
47
+ }
48
+ return keyPair
49
+ }
@@ -2,6 +2,7 @@
2
2
  import { UtxoCollection } from '@exodus/models'
3
3
  import { findLargeUnconfirmedTxs } from './tx-utils'
4
4
  import assert from 'minimalistic-assert'
5
+ import { mapValues } from '@exodus/basic-utils'
5
6
 
6
7
  export function getUtxos({ accountState, asset }) {
7
8
  return (
@@ -12,9 +13,23 @@ export function getUtxos({ accountState, asset }) {
12
13
  )
13
14
  }
14
15
 
15
- export function getBalances({ asset, accountState }) {
16
- const balance = getUtxos({ asset, accountState }).value
17
- return balance.isZero ? null : { balance }
16
+ export const getBalancesFactory = ({ taprootEnabled, feeData }) => {
17
+ assert(feeData, 'feeData is required')
18
+ return ({ asset, accountState, txLog }) => {
19
+ assert(asset, 'asset is required')
20
+ assert(accountState, 'accountState is required')
21
+ assert(txLog, 'txLog is required')
22
+ const utxos = getUtxos({ asset, accountState })
23
+ const balance = utxos.value
24
+ const spendableBalance = getSpendableUtxos({
25
+ asset,
26
+ utxos,
27
+ txSet: txLog,
28
+ feeData,
29
+ taprootEnabled,
30
+ }).value
31
+ return mapValues({ balance, spendableBalance }, (balance) => (balance.isZero ? null : balance))
32
+ }
18
33
  }
19
34
 
20
35
  const isTaprootUtxo = (asset, utxo) =>