@exodus/bitcoin-api 2.3.15 → 2.4.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": "2.3.15",
3
+ "version": "2.4.0",
4
4
  "description": "Exodus bitcoin-api",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -41,5 +41,5 @@
41
41
  "@exodus/bitcoin-meta": "^1.0.1",
42
42
  "jest-when": "^3.5.1"
43
43
  },
44
- "gitHead": "90be61265b73fc6f3238604aea72368cf684faa5"
44
+ "gitHead": "de21cbd727cb6cbe4fac07b58cc6e3fa8af318ff"
45
45
  }
@@ -1,11 +1,22 @@
1
1
  import { AccountState, UtxoCollection } from '@exodus/models'
2
2
 
3
- export function createAccountState({ asset }) {
3
+ export function createAccountState({ asset, ordinalsEnabled = false, brc20Enabled = false }) {
4
+ const empty = UtxoCollection.createEmpty({
5
+ currency: asset.currency,
6
+ })
7
+ const defaults = {
8
+ utxos: empty,
9
+ }
10
+
11
+ if (ordinalsEnabled) {
12
+ defaults.ordinalsUtxos = empty
13
+ }
14
+
15
+ if (brc20Enabled) {
16
+ defaults.brc20Balances = {}
17
+ }
18
+
4
19
  return class BitcoinAccountState extends AccountState {
5
- static defaults = {
6
- utxos: UtxoCollection.createEmpty({
7
- currency: asset.currency,
8
- }),
9
- }
20
+ static defaults = defaults
10
21
  }
11
22
  }
@@ -0,0 +1,24 @@
1
+ import assert from 'minimalistic-assert'
2
+
3
+ export function isOrdinalAddress(address, ordinalChainIndex) {
4
+ assert(typeof ordinalChainIndex === 'number', `ordinalChainIndex must be a number`)
5
+ return parsePath(address)[0] === ordinalChainIndex
6
+ }
7
+
8
+ export function isReceiveAddress(address): boolean {
9
+ return parsePath(address)[0] === 0
10
+ }
11
+
12
+ export function isChangeAddress(address): boolean {
13
+ return parsePath(address)[0] === 1
14
+ }
15
+
16
+ function parsePath(address) {
17
+ assert(
18
+ address.meta.path,
19
+ `address parameter ${address} does not have a meta.path. Is it a valid Address object?`
20
+ )
21
+ const path = address.meta.path
22
+ const p1 = path ? path.replace('m/', '').split('/') : ['0', '0']
23
+ return p1.map((i) => parseInt(i, 10))
24
+ }
@@ -14,7 +14,30 @@ export class GetFeeResolver {
14
14
  this.#allowUnconfirmedRbfEnabledUtxos = allowUnconfirmedRbfEnabledUtxos
15
15
  }
16
16
 
17
- getFee = ({ asset, accountState, txSet, feeData, amount, customFee, isSendAll }) => {
17
+ getFee = ({
18
+ asset,
19
+ accountState,
20
+ txSet,
21
+ feeData,
22
+ amount,
23
+ customFee,
24
+ isSendAll,
25
+ nft, // sending one nft
26
+ brc20, // sending multiple inscriptions ids (as in sending multiple transfer ordinals)
27
+ }) => {
28
+ if (nft) {
29
+ assert(!amount, 'amount must not be provided when nft is provided!!!')
30
+ assert(!isSendAll, 'isSendAll must not be provided when nft is provided!!!')
31
+ assert(!brc20, 'brc20 must not be provided when nft is provided!!!')
32
+ }
33
+ if (brc20) {
34
+ assert(!amount, 'amount must not be provided when brc20 is provided!!!')
35
+ assert(!isSendAll, 'isSendAll must not be provided when brc20 is provided!!!')
36
+ assert(!nft, 'nft must not be provided when brc20 is provided!!!')
37
+ }
38
+
39
+ const inscriptionIds = nft?.tokenId ? [nft?.tokenId] : brc20?.inscriptionIds
40
+
18
41
  const { resolvedFee, extraFee } = this.#getUtxosData({
19
42
  asset,
20
43
  accountState,
@@ -23,6 +46,7 @@ export class GetFeeResolver {
23
46
  amount,
24
47
  customFee,
25
48
  isSendAll,
49
+ inscriptionIds,
26
50
  })
27
51
  return { fee: resolvedFee, extraFee }
28
52
  }
@@ -49,7 +73,16 @@ export class GetFeeResolver {
49
73
  }).spendableBalance
50
74
  }
51
75
 
52
- #getUtxosData = ({ asset, accountState, txSet, feeData, amount, customFee, isSendAll }) => {
76
+ #getUtxosData = ({
77
+ asset,
78
+ accountState,
79
+ txSet,
80
+ feeData,
81
+ amount,
82
+ customFee,
83
+ isSendAll,
84
+ inscriptionIds,
85
+ }) => {
53
86
  assert(asset, 'asset must be provided')
54
87
  assert(feeData, 'feeData must be provided')
55
88
  assert(accountState, 'accountState must be provided')
@@ -65,10 +98,6 @@ export class GetFeeResolver {
65
98
  })
66
99
  const replaceableTxs = findUnconfirmedSentRbfTxs(txSet)
67
100
 
68
- const receiveAddress = ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name)
69
- ? 'P2WSH'
70
- : 'P2PKH'
71
-
72
101
  const feePerKB = customFee || feeData.feePerKB
73
102
  return getUtxosData({
74
103
  asset,
@@ -76,7 +105,7 @@ export class GetFeeResolver {
76
105
  replaceableTxs,
77
106
  amount,
78
107
  feeRate: feePerKB,
79
- receiveAddress,
108
+ inscriptionIds,
80
109
  isSendAll: isSendAll,
81
110
  getFeeEstimator: this.#getFeeEstimator,
82
111
  allowUnconfirmedRbfEnabledUtxos: this.#allowUnconfirmedRbfEnabledUtxos,
@@ -91,7 +120,6 @@ export class GetFeeResolver {
91
120
  accountState,
92
121
  feeData,
93
122
  getFeeEstimator: this.#getFeeEstimator,
94
-
95
123
  allowUnconfirmedRbfEnabledUtxos: this.#allowUnconfirmedRbfEnabledUtxos,
96
124
  })
97
125
  }
@@ -6,19 +6,44 @@ import { getConfirmedUtxos, getConfirmedOrRfbDisabledUtxos } from '../utxos-util
6
6
 
7
7
  const MIN_RELAY_FEE = 1000
8
8
 
9
+ const getBestReceiveAddresses = ({ asset, receiveAddress, inscriptionIds }) => {
10
+ if (inscriptionIds) {
11
+ return receiveAddress || 'P2TR'
12
+ }
13
+ if (receiveAddress === null) {
14
+ return null
15
+ }
16
+
17
+ return ['bitcoin', 'bitcoinregtest', 'bitcointestnet'].includes(asset.name) ? 'P2WSH' : 'P2PKH'
18
+ }
19
+
9
20
  export const selectUtxos = ({
10
21
  asset,
11
22
  usableUtxos,
12
23
  replaceableTxs,
13
24
  amount,
14
25
  feeRate,
15
- receiveAddress = 'P2WSH',
26
+ receiveAddress, // it could be null
16
27
  isSendAll,
17
28
  getFeeEstimator,
18
29
  disableReplacement = false,
19
30
  mustSpendUtxos,
20
31
  allowUnconfirmedRbfEnabledUtxos,
32
+ inscriptionIds, // for each inscription transfer, we need to calculate one more input and one more output
21
33
  }) => {
34
+ const resolvedReceiveAddresses = getBestReceiveAddresses({
35
+ asset,
36
+ receiveAddress,
37
+ inscriptionIds,
38
+ })
39
+
40
+ const receiveAddresses = []
41
+ if (inscriptionIds) {
42
+ receiveAddresses.push(...inscriptionIds.map(() => resolvedReceiveAddresses))
43
+ } else {
44
+ receiveAddresses.push(resolvedReceiveAddresses)
45
+ }
46
+
22
47
  assert(asset, 'asset is required')
23
48
  assert(usableUtxos, 'usableUtxos is required')
24
49
  assert(getFeeEstimator, 'getFeeEstimator is required')
@@ -34,6 +59,7 @@ export const selectUtxos = ({
34
59
  // We can only replace for a sendAll if only 1 replaceable tx and no unconfirmed utxos
35
60
  const confirmedUtxosArray = getConfirmedUtxos({ asset, utxos: usableUtxos }).toArray()
36
61
  const canReplace =
62
+ !inscriptionIds &&
37
63
  !mustSpendUtxos &&
38
64
  !disableReplacement &&
39
65
  replaceableTxs &&
@@ -53,6 +79,7 @@ export const selectUtxos = ({
53
79
  }
54
80
 
55
81
  const replaceFeeEstimator = getFeeEstimator(asset, { feePerKB })
82
+ // how to avoid replace tx inputs when inputs are ordinals? !!!!
56
83
  const inputs = UtxoCollection.fromJSON(tx.data.inputs, { currency })
57
84
  const outputs = isSendAll
58
85
  ? tx.data.sent.map(({ address }) => address)
@@ -60,9 +87,7 @@ export const selectUtxos = ({
60
87
  ...tx.data.sent.map(({ address }) => address),
61
88
  tx.data.changeAddress?.address || changeAddressType,
62
89
  ]
63
- if (receiveAddress) {
64
- outputs.push(receiveAddress)
65
- }
90
+ if (receiveAddresses.find(Boolean)) outputs.push(...receiveAddresses)
66
91
 
67
92
  let fee
68
93
  let additionalUtxos
@@ -78,9 +103,11 @@ export const selectUtxos = ({
78
103
  if (confirmedUtxosArray.length === 0) {
79
104
  // Try estimating fee with no change
80
105
  if (replaceTxAmount.add(additionalUtxos.value).lt(amount.add(fee))) {
81
- const noChangeOutputs = receiveAddress
82
- ? [...tx.data.sent.map(({ address }) => address), receiveAddress]
83
- : tx.data.sent.map(({ address }) => address)
106
+ const noChangeOutputs = [
107
+ ...tx.data.sent.map(({ address }) => address),
108
+ ...receiveAddresses,
109
+ ]
110
+
84
111
  fee = replaceFeeEstimator({
85
112
  inputs: inputs.union(additionalUtxos),
86
113
  outputs: noChangeOutputs,
@@ -95,7 +122,7 @@ export const selectUtxos = ({
95
122
  }
96
123
  }
97
124
  if (isSendAll || replaceTxAmount.add(additionalUtxos.value).gte(amount.add(fee))) {
98
- const chainOutputs = isSendAll ? [receiveAddress] : [receiveAddress, changeAddressType]
125
+ const chainOutputs = isSendAll ? receiveAddresses : [...receiveAddresses, changeAddressType]
99
126
  const chainFee = feeEstimator({
100
127
  inputs: changeUtxos.union(additionalUtxos),
101
128
  outputs: chainOutputs,
@@ -132,7 +159,7 @@ export const selectUtxos = ({
132
159
 
133
160
  if (isSendAll) {
134
161
  const selectedUtxos = UtxoCollection.fromArray(utxosArray, { currency })
135
- const fee = feeEstimator({ inputs: selectedUtxos, outputs: [receiveAddress] })
162
+ const fee = feeEstimator({ inputs: selectedUtxos, outputs: receiveAddresses })
136
163
  if (selectedUtxos.value.lt(amount.add(fee))) {
137
164
  return { fee }
138
165
  }
@@ -159,7 +186,10 @@ export const selectUtxos = ({
159
186
  let selectedUtxos = UtxoCollection.fromArray(selectedUtxosArray, { currency })
160
187
 
161
188
  // start figuring out fees
162
- const outputs = amount.isZero ? [changeAddressType] : [receiveAddress, changeAddressType]
189
+ const outputs =
190
+ amount.isZero && !inscriptionIds
191
+ ? [changeAddressType]
192
+ : [...receiveAddresses, changeAddressType]
163
193
 
164
194
  let fee = feeEstimator({ inputs: selectedUtxos, outputs })
165
195
 
@@ -167,7 +197,7 @@ export const selectUtxos = ({
167
197
  // We ran out of UTXOs, give up now
168
198
  if (remainingUtxosArray.length === 0) {
169
199
  // Try fee with no change
170
- fee = feeEstimator({ inputs: selectedUtxos, outputs: [receiveAddress] })
200
+ fee = feeEstimator({ inputs: selectedUtxos, outputs: receiveAddresses })
171
201
  break
172
202
  }
173
203
 
@@ -193,6 +223,7 @@ export const getUtxosData = ({
193
223
  disableReplacement,
194
224
  mustSpendUtxos,
195
225
  allowUnconfirmedRbfEnabledUtxos,
226
+ inscriptionIds,
196
227
  }) => {
197
228
  const { selectedUtxos, replaceTx, fee } = selectUtxos({
198
229
  asset,
@@ -206,6 +237,7 @@ export const getUtxosData = ({
206
237
  disableReplacement,
207
238
  mustSpendUtxos,
208
239
  allowUnconfirmedRbfEnabledUtxos,
240
+ inscriptionIds,
209
241
  })
210
242
 
211
243
  const resolvedFee = replaceTx ? fee.sub(replaceTx.feeAmount) : fee
@@ -3,26 +3,13 @@ import { orderTxs } from '../insight-api-client/util'
3
3
  import { Address, UtxoCollection } from '@exodus/models'
4
4
  import { isEqual, compact, uniq } from 'lodash'
5
5
  import ms from 'ms'
6
-
7
6
  import assert from 'minimalistic-assert'
8
- import { getUtxos } from '../utxos-utils'
7
+ import { isChangeAddress, isReceiveAddress } from '../address-utils'
8
+ import { getOrdinalsUtxos, getUtxos, partitionUtxos } from '../utxos-utils'
9
9
 
10
10
  // Time to check whether to drop a sent tx
11
11
  const SENT_TIME_TO_DROP = ms('2m')
12
12
 
13
- function isReceiveAddress(addr: Address): boolean {
14
- return parsePath(addr.meta.path)[0] === 0
15
- }
16
- function isChangeAddress(addr: Address): boolean {
17
- return parsePath(addr.meta.path)[0] === 1
18
- }
19
-
20
- function parsePath(path) {
21
- let p1 = path ? path.replace('m/', '').split('/') : ['0', '0']
22
- p1 = p1.map((i) => parseInt(i, 10))
23
- return p1
24
- }
25
-
26
13
  export class BitcoinMonitorScanner {
27
14
  #asset
28
15
  #insightClient
@@ -30,6 +17,8 @@ export class BitcoinMonitorScanner {
30
17
  #txFetchLimitResolver
31
18
  #shouldExcludeVoutUtxo
32
19
  #yieldToUI
20
+ #ordinalsEnabled
21
+ #ordinalChainIndex
33
22
 
34
23
  constructor({
35
24
  asset,
@@ -38,6 +27,8 @@ export class BitcoinMonitorScanner {
38
27
  yieldToUI = () => {},
39
28
  shouldExcludeVoutUtxo = () => false,
40
29
  txFetchLimitResolver = ({ refresh }) => (refresh ? 50 : 10),
30
+ ordinalsEnabled,
31
+ ordinalChainIndex,
41
32
  }) {
42
33
  assert(asset, 'asset is required!')
43
34
  assert(assetClientInterface, 'assetClientInterface is required!')
@@ -51,6 +42,8 @@ export class BitcoinMonitorScanner {
51
42
  this.#assetClientInterface = assetClientInterface
52
43
  this.#txFetchLimitResolver = txFetchLimitResolver
53
44
  this.#shouldExcludeVoutUtxo = shouldExcludeVoutUtxo
45
+ this.#ordinalsEnabled = ordinalsEnabled
46
+ this.#ordinalChainIndex = ordinalChainIndex
54
47
  }
55
48
 
56
49
  async rescanBlockchainInsight({ walletAccount, refresh }) {
@@ -64,7 +57,11 @@ export class BitcoinMonitorScanner {
64
57
  const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
65
58
  const currency = asset.currency
66
59
  const currentTxs = await assetClientInterface.getTxLog({ assetName, walletAccount })
67
- const currentUtxos = getUtxos({ asset, accountState })
60
+
61
+ const storedUtxos = getUtxos({ asset, accountState })
62
+ const storedOrdinalUtxos = getOrdinalsUtxos({ asset, accountState })
63
+
64
+ const currentStoredUtxos = storedUtxos.union(storedOrdinalUtxos)
68
65
 
69
66
  const currentTime = new Date().getTime()
70
67
  const unconfirmedTxsToCheck = Array.from(currentTxs).reduce((txs, tx) => {
@@ -221,6 +218,21 @@ export class BitcoinMonitorScanner {
221
218
  })
222
219
  .flat()
223
220
 
221
+ if (
222
+ fetchCount === 0 &&
223
+ this.#ordinalsEnabled &&
224
+ this.#ordinalChainIndex > 1 &&
225
+ purposes.includes(86)
226
+ ) {
227
+ // this is the ordinal address
228
+ chainObjects.push({
229
+ purpose: 86,
230
+ chainIndex: this.#ordinalChainIndex,
231
+ startAddressIndex: 0,
232
+ endAddressIndex: 1,
233
+ })
234
+ }
235
+
224
236
  const addresses = await aggregateAddresses(chainObjects)
225
237
 
226
238
  const txs = await fetchAllTxs(addresses)
@@ -243,6 +255,11 @@ export class BitcoinMonitorScanner {
243
255
  const metaAddressIndex = parseInt(pd[2])
244
256
  const addressString = String(address)
245
257
  const purposeToUpdate = purposeMap[addressString]
258
+
259
+ if (metaChainIndex === this.#ordinalChainIndex && this.#ordinalChainIndex > 1) {
260
+ return
261
+ }
262
+
246
263
  if (!purposeToUpdate) {
247
264
  console.warn(`${assetName}: Cannot resolve purpose from address ${addressString}`)
248
265
  return
@@ -466,8 +483,8 @@ export class BitcoinMonitorScanner {
466
483
  let utxoCol = UtxoCollection.fromArray(utxos, { currency })
467
484
 
468
485
  const utxosToRemoveCol = UtxoCollection.fromArray(utxosToRemove, { currency })
469
- // if something else can update currentUtxos in the meantime, we probably need to grab it from state again
470
- utxoCol = utxoCol.union(currentUtxos.difference(utxosToRemoveCol))
486
+ // if something else can update currentStoredUtxos in the meantime, we probably need to grab it from state again
487
+ utxoCol = currentStoredUtxos.union(utxoCol.difference(utxosToRemoveCol))
471
488
 
472
489
  for (let tx of Object.values(unconfirmedTxsToCheck)) {
473
490
  existingTxs.push({ ...tx, dropped: true }) // TODO: this will decrease the chain index, it shouldn't be an issue considering the gap limit
@@ -491,7 +508,7 @@ export class BitcoinMonitorScanner {
491
508
  }
492
509
 
493
510
  // no changes, ignore
494
- if (utxoCol.equals(currentUtxos)) {
511
+ if (utxoCol.equals(currentStoredUtxos)) {
495
512
  utxoCol = null
496
513
  }
497
514
 
@@ -501,10 +518,14 @@ export class BitcoinMonitorScanner {
501
518
  return !isEqual(chain, originalChain.chain)
502
519
  })
503
520
 
521
+ const utxosData = utxoCol
522
+ ? partitionUtxos({ allUtxos: utxoCol, ordinalChainIndex: this.#ordinalChainIndex })
523
+ : {}
524
+
504
525
  return {
505
526
  txsToUpdate: existingTxs,
506
527
  txsToAdd: newTxs,
507
- utxos: utxoCol,
528
+ ...utxosData,
508
529
  changedUnusedAddressIndexes,
509
530
  }
510
531
  }
@@ -517,6 +538,8 @@ export class BitcoinMonitorScanner {
517
538
  const accountState = await aci.getAccountState({ assetName, walletAccount })
518
539
 
519
540
  const storedUtxos = getUtxos({ accountState, asset })
541
+ const storedOrdinalsUtxos = getOrdinalsUtxos({ accountState, asset })
542
+ const allStoredUtxos = storedUtxos.union(storedOrdinalsUtxos)
520
543
 
521
544
  const currentTxs = Array.from(await aci.getTxLog({ assetName, walletAccount }))
522
545
 
@@ -551,13 +574,16 @@ export class BitcoinMonitorScanner {
551
574
  })
552
575
  .filter((tx) => Object.keys(tx).length > 1)
553
576
 
554
- // Note about storedUtxos.clone(): updateConfirmations mutates the ordinal utxos, it's not possible to perform the equals below if not cloned
555
- const updatedUtxos = confirmationsList.length
556
- ? storedUtxos.clone().updateConfirmations(confirmationsList)
557
- : null
577
+ const txConfirmedUtxos = allStoredUtxos.updateConfirmations(confirmationsList)
578
+
579
+ const { utxos, ordinalsUtxos } = partitionUtxos({
580
+ allUtxos: txConfirmedUtxos,
581
+ ordinalChainIndex: this.#ordinalChainIndex,
582
+ })
558
583
 
559
584
  return {
560
- utxos: !updatedUtxos || updatedUtxos.equals(storedUtxos) ? null : updatedUtxos,
585
+ utxos: utxos.equals(storedUtxos) ? null : utxos,
586
+ ordinalsUtxos: ordinalsUtxos.equals(storedOrdinalsUtxos) ? null : ordinalsUtxos,
561
587
  txsToUpdate: updatedPropertiesTxs,
562
588
  }
563
589
  }
@@ -1,5 +1,5 @@
1
1
  import assert from 'minimalistic-assert'
2
- import { isEmpty, isEqual } from 'lodash'
2
+ import { isEmpty, isEqual, pickBy } from 'lodash'
3
3
 
4
4
  import { BaseMonitor } from '@exodus/asset-lib'
5
5
  import InsightWSClient from '../insight-api-client/ws'
@@ -142,17 +142,21 @@ export class Monitor extends BaseMonitor {
142
142
  const assetName = asset.name
143
143
  const walletAccounts = await aci.getWalletAccounts({ assetName })
144
144
  for (const walletAccount of walletAccounts) {
145
- const { txsToUpdate, utxos } = await this.#scanner.rescanOnNewBlock({
145
+ const { txsToUpdate, utxos, ordinalsUtxos } = await this.#scanner.rescanOnNewBlock({
146
146
  walletAccount,
147
147
  })
148
148
 
149
- if (utxos) {
149
+ if (utxos || ordinalsUtxos) {
150
150
  await aci.updateAccountState({
151
151
  assetName,
152
152
  walletAccount,
153
- newData: {
154
- utxos,
155
- },
153
+ newData: pickBy(
154
+ {
155
+ utxos,
156
+ ordinalsUtxos,
157
+ },
158
+ Boolean
159
+ ),
156
160
  })
157
161
  }
158
162
 
@@ -211,6 +215,7 @@ export class Monitor extends BaseMonitor {
211
215
  txsToAdd,
212
216
  txsToUpdate,
213
217
  utxos,
218
+ ordinalsUtxos,
214
219
  changedUnusedAddressIndexes,
215
220
  } = await this.#scanner.rescanBlockchainInsight({
216
221
  walletAccount,
@@ -218,7 +223,18 @@ export class Monitor extends BaseMonitor {
218
223
  })
219
224
  const accountState = await aci.getAccountState({ assetName, walletAccount })
220
225
 
221
- if (utxos) await aci.updateAccountState({ assetName, walletAccount, newData: { utxos } })
226
+ if (utxos || ordinalsUtxos)
227
+ await aci.updateAccountState({
228
+ assetName,
229
+ walletAccount,
230
+ newData: pickBy(
231
+ {
232
+ utxos,
233
+ ordinalsUtxos,
234
+ },
235
+ Boolean
236
+ ),
237
+ })
222
238
 
223
239
  if (!isEmpty(changedUnusedAddressIndexes)) {
224
240
  // Only for mobile atm, browser and hydra calculates from the latest txLogs
@@ -1,6 +1,6 @@
1
1
  import assert from 'minimalistic-assert'
2
2
  // Using this notation so it can be mocked by jest
3
- import shuffle from 'lodash/shuffle'
3
+ import doShuffle from 'lodash/shuffle'
4
4
 
5
5
  import { UtxoCollection, Address } from '@exodus/models'
6
6
  import { retry } from '@exodus/simple-retry'
@@ -14,7 +14,7 @@ import {
14
14
  createOutput as dogecoinCreateOutput,
15
15
  } from './dogecoin'
16
16
  import { findUnconfirmedSentRbfTxs } from '../tx-utils'
17
- import { getUsableUtxos, getUtxos } from '../utxos-utils'
17
+ import { getOrdinalsUtxos, getUsableUtxos, getUtxos } from '../utxos-utils'
18
18
 
19
19
  import * as defaultBitcoinjsLib from '@exodus/bitcoinjs-lib'
20
20
 
@@ -142,28 +142,61 @@ export const createAndBroadcastTXFactory = ({
142
142
  isBip70,
143
143
  bumpTxId,
144
144
  isRbfAllowed = true,
145
+ nft,
146
+ brc20,
145
147
  } = options
146
148
 
149
+ const assetName = asset.name
150
+
151
+ const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
152
+
153
+ const inscriptionIds = nft?.tokenId ? [nft?.tokenId] : brc20 ? brc20.inscriptionIds : undefined
154
+
155
+ const shuffle = (list) => {
156
+ return inscriptionIds ? list : doShuffle(list) // don't shuffle when sending ordinal!!!!
157
+ }
158
+
147
159
  assert(assetClientInterface, `assetClientInterface must be supplied in sendTx for ${asset.name}`)
148
160
  assert(
149
161
  address || bumpTxId,
150
162
  'should not be called without either a receiving address or to bump a tx'
151
163
  )
152
164
 
153
- const assetName = asset.name
165
+ if (inscriptionIds) {
166
+ assert(!bumpTxId, 'only inscriptionIds or bumpTxId must be provided')
167
+ assert(address, 'address must be provided when sending ordinals')
168
+ }
154
169
 
155
- const receiveAddresses = await assetClientInterface.getReceiveAddresses({
156
- walletAccount,
157
- assetName,
158
- multiAddressMode: true,
159
- })
170
+ const useCashAddress = asset.address.isCashAddress?.(address)
160
171
 
161
172
  const changeAddress = multipleAddressesEnabled
162
173
  ? await assetClientInterface.getNextChangeAddress({ assetName, walletAccount })
163
174
  : await assetClientInterface.getReceiveAddressObject({ assetName, walletAccount })
164
175
 
165
176
  const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
166
- const accountState = await assetClientInterface.getAccountState({ assetName, walletAccount })
177
+
178
+ const currentOrdinalsUtxos = getOrdinalsUtxos({ accountState, asset })
179
+ const transferOrdinalsUtxos = inscriptionIds
180
+ ? currentOrdinalsUtxos.filter((utxo) => inscriptionIds.includes(utxo.inscriptionId)) /// this could be bulk transfer if multiple inscription ids are provided
181
+ : undefined
182
+
183
+ if (inscriptionIds) {
184
+ assert(
185
+ transferOrdinalsUtxos?.size === inscriptionIds.length,
186
+ `Expected ordinal utxos ${inscriptionIds.length}. Found: ${transferOrdinalsUtxos?.size || 0}`
187
+ )
188
+
189
+ const unconfirmedOrdinalUtxos = transferOrdinalsUtxos
190
+ .toArray()
191
+ .filter((ordinalUtxo) => !(ordinalUtxo.confirmations > 0))
192
+ assert(
193
+ !unconfirmedOrdinalUtxos.length,
194
+ `OrdinalUtxo with inscription ids ${unconfirmedOrdinalUtxos
195
+ .map((utxo) => utxo.inscriptionId)
196
+ .join(', ')} have not confirmed yet`
197
+ )
198
+ }
199
+
167
200
  const insightClient = asset.baseAsset.insightClient
168
201
  const currency = asset.currency
169
202
  const feeData = await assetClientInterface.getFeeConfig({ assetName })
@@ -186,7 +219,7 @@ export const createAndBroadcastTXFactory = ({
186
219
  }
187
220
  }
188
221
 
189
- const rbfEnabled = feeData.rbfEnabled && !isExchange && !isBip70 && isRbfAllowed
222
+ const rbfEnabled = feeData.rbfEnabled && !isExchange && !isBip70 && isRbfAllowed && !nft && !brc20
190
223
 
191
224
  let utxosToBump
192
225
  if (bumpTxId) {
@@ -202,9 +235,10 @@ export const createAndBroadcastTXFactory = ({
202
235
  }
203
236
  }
204
237
 
205
- const sendAmount = bumpTxId ? asset.currency.ZERO : amount.toDefault()
206
- let receiveAddress = bumpTxId ? (replaceableTxs.length > 0 ? null : 'P2WPKH') : address
238
+ const sendAmount = bumpTxId || transferOrdinalsUtxos ? asset.currency.ZERO : amount.toDefault()
239
+ const receiveAddress = bumpTxId ? (replaceableTxs.length > 0 ? null : 'P2WPKH') : address
207
240
  const feeRate = feeData.feePerKB
241
+ const resolvedIsSendAll = (!rbfEnabled && feePerKB) || transferOrdinalsUtxos ? false : isSendAll
208
242
 
209
243
  let { selectedUtxos, fee, replaceTx } = selectUtxos({
210
244
  asset,
@@ -212,11 +246,12 @@ export const createAndBroadcastTXFactory = ({
212
246
  replaceableTxs,
213
247
  amount: sendAmount,
214
248
  feeRate: customFee || feeRate,
215
- receiveAddress,
216
- isSendAll: !rbfEnabled && feePerKB ? false : isSendAll,
249
+ receiveAddress: receiveAddress,
250
+ isSendAll: resolvedIsSendAll,
217
251
  getFeeEstimator: (asset, { feePerKB }) => getFeeEstimator(asset, feePerKB),
218
252
  mustSpendUtxos: utxosToBump,
219
253
  allowUnconfirmedRbfEnabledUtxos,
254
+ inscriptionIds,
220
255
  })
221
256
 
222
257
  if (!selectedUtxos && !replaceTx) throw new Error('Not enough funds.')
@@ -229,7 +264,9 @@ export const createAndBroadcastTXFactory = ({
229
264
  if (bumpTxId && (!selectedUtxos || (replaceTx && replaceTx.txId !== bumpTxId))) {
230
265
  throw new Error(`Unable to bump ${bumpTxId}`)
231
266
  }
232
-
267
+ if (transferOrdinalsUtxos) {
268
+ selectedUtxos = transferOrdinalsUtxos.union(selectedUtxos)
269
+ }
233
270
  if (replaceTx) {
234
271
  replaceTx = replaceTx.clone()
235
272
  replaceTx = replaceTx.update({ data: { ...replaceTx.data } })
@@ -237,6 +274,7 @@ export const createAndBroadcastTXFactory = ({
237
274
  return { ...to, amount: parseCurrency(to.amount, asset.currency) }
238
275
  })
239
276
  selectedUtxos = selectedUtxos.union(
277
+ // how to avoid replace tx inputs when inputs are ordinals? !!!!
240
278
  UtxoCollection.fromJSON(replaceTx.data.inputs, { currency: asset.currency })
241
279
  )
242
280
  }
@@ -253,14 +291,25 @@ export const createAndBroadcastTXFactory = ({
253
291
  outputs = []
254
292
  }
255
293
  if (address) {
256
- outputs.push(createOutput(assetName, address, sendAmount))
294
+ if (transferOrdinalsUtxos) {
295
+ outputs.push(
296
+ ...transferOrdinalsUtxos
297
+ .toArray()
298
+ .map((ordinalUtxo) => createOutput(assetName, address, ordinalUtxo.value))
299
+ )
300
+ } else {
301
+ outputs.push(createOutput(assetName, address, sendAmount))
302
+ }
257
303
  }
258
304
 
259
305
  const totalAmount = replaceTx
260
306
  ? replaceTx.data.sent.reduce((total, { amount }) => total.add(amount), sendAmount)
261
307
  : sendAmount
262
308
 
263
- const change = selectedUtxos.value.sub(totalAmount).sub(fee)
309
+ const change = selectedUtxos.value
310
+ .sub(totalAmount)
311
+ .sub(transferOrdinalsUtxos?.value || currency.ZERO)
312
+ .sub(fee)
264
313
  const dust = getDustValue(asset)
265
314
  let ourAddress = replaceTx?.data?.changeAddress || changeAddress
266
315
  if (asset.address.toLegacyAddress) {
@@ -292,6 +341,7 @@ export const createAndBroadcastTXFactory = ({
292
341
  outputs,
293
342
  },
294
343
  txMeta: {
344
+ useCashAddress, // for trezor to show the receiver cash address
295
345
  addressPathsMap: selectedUtxos.getAddressPathsMap(),
296
346
  blockHeight,
297
347
  },
@@ -374,18 +424,29 @@ export const createAndBroadcastTXFactory = ({
374
424
  remainingUtxos = remainingUtxos.difference(remainingUtxos.getTxIdUtxos(replaceTx.txId))
375
425
  }
376
426
 
427
+ const remainingOrdinalsUtxos = transferOrdinalsUtxos
428
+ ? currentOrdinalsUtxos.difference(transferOrdinalsUtxos)
429
+ : undefined
430
+
377
431
  await assetClientInterface.updateAccountState({
378
432
  assetName,
379
433
  walletAccount,
380
- accountState,
381
- newData: { utxos: remainingUtxos },
434
+ newData: {
435
+ utxos: remainingUtxos,
436
+ ordinalsUtxos: remainingOrdinalsUtxos,
437
+ },
382
438
  })
383
439
 
440
+ const walletAddresses = await assetClientInterface.getReceiveAddresses({
441
+ walletAccount,
442
+ assetName,
443
+ multiAddressMode: true,
444
+ })
384
445
  // There are two cases of bumping, replacing or chaining a self-send.
385
446
  // If we have a bumpTxId, but we aren't replacing, then it is a self-send.
386
447
  const selfSend = bumpTxId
387
448
  ? !replaceTx
388
- : receiveAddresses.some((receiveAddress) => String(receiveAddress) === String(address))
449
+ : walletAddresses.some((receiveAddress) => String(receiveAddress) === String(address))
389
450
 
390
451
  const displayReceiveAddress = asset.address.displayAddress?.(receiveAddress) || receiveAddress
391
452
 
@@ -418,6 +479,8 @@ export const createAndBroadcastTXFactory = ({
418
479
  blocksSeen: 0,
419
480
  inputs: selectedUtxos.toJSON(),
420
481
  replacedTxId: replaceTx ? replaceTx.txId : undefined,
482
+ nftId: nft ? `${assetName}:${nft.tokenId}` : undefined, // it allows BE to load the nft info while the nft is in transit
483
+ inscriptionIds: inscriptionIds,
421
484
  },
422
485
  },
423
486
  ],
@@ -454,6 +517,7 @@ function defaultCreateInputs(utxos, rbfEnabled) {
454
517
  value: parseInt(utxo.value.toBaseString(), 10),
455
518
  script: utxo.script,
456
519
  sequence: getTxSequence(rbfEnabled),
520
+ inscriptionId: utxo.inscriptionId,
457
521
  }))
458
522
  }
459
523
 
@@ -2,6 +2,9 @@
2
2
  import { UtxoCollection } from '@exodus/models'
3
3
  import { findLargeUnconfirmedTxs } from './tx-utils'
4
4
  import assert from 'minimalistic-assert'
5
+ import { isOrdinalAddress } from './address-utils'
6
+
7
+ const MAX_ORDINAL_VALUE_POSTAGE = 10000
5
8
 
6
9
  export function getUtxos({ accountState, asset }) {
7
10
  return (
@@ -12,6 +15,41 @@ export function getUtxos({ accountState, asset }) {
12
15
  )
13
16
  }
14
17
 
18
+ export function getOrdinalsUtxos({ accountState, asset }) {
19
+ return (
20
+ accountState?.ordinalsUtxos ||
21
+ UtxoCollection.createEmpty({
22
+ currency: asset.currency,
23
+ })
24
+ )
25
+ }
26
+
27
+ function isOrdinalUtxo({ utxo, ordinalChainIndex }) {
28
+ if (utxo.inscriptionId) {
29
+ return true
30
+ }
31
+ if (ordinalChainIndex === undefined || ordinalChainIndex < 0) {
32
+ // exclude utxos splitting
33
+ return false
34
+ }
35
+
36
+ const maybeAnOrdinalAddress =
37
+ ordinalChainIndex && isOrdinalAddress(utxo.address, ordinalChainIndex) // if wallet receives utxos to the special address, consider it as ordinal
38
+ if (utxo.confirmations) {
39
+ return maybeAnOrdinalAddress
40
+ }
41
+
42
+ return utxo.value.toBaseNumber() <= MAX_ORDINAL_VALUE_POSTAGE || maybeAnOrdinalAddress // while unconfirmed, put < 10000- sats in the ordinal utxos box just in case
43
+ }
44
+
45
+ export function partitionUtxos({ allUtxos, ordinalChainIndex }) {
46
+ assert(allUtxos, 'allUtxos is required')
47
+ return {
48
+ utxos: allUtxos.filter((utxo) => !isOrdinalUtxo({ utxo, ordinalChainIndex })),
49
+ ordinalsUtxos: allUtxos.filter((utxo) => isOrdinalUtxo({ utxo, ordinalChainIndex })),
50
+ }
51
+ }
52
+
15
53
  export function getConfirmedUtxos({ utxos }) {
16
54
  assert(utxos, 'utxos is required')
17
55
  return utxos.filter(({ confirmations }) => confirmations > 0)