@exodus/bitcoin-api 2.3.16 → 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.16",
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": "ea21eb60e35f84cf2086ae0304bf9fa8ce4058b1"
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,30 +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 useCashAddress = asset.address.isCashAddress?.(address)
154
-
155
- 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
+ }
156
169
 
157
- const receiveAddresses = await assetClientInterface.getReceiveAddresses({
158
- walletAccount,
159
- assetName,
160
- multiAddressMode: true,
161
- })
170
+ const useCashAddress = asset.address.isCashAddress?.(address)
162
171
 
163
172
  const changeAddress = multipleAddressesEnabled
164
173
  ? await assetClientInterface.getNextChangeAddress({ assetName, walletAccount })
165
174
  : await assetClientInterface.getReceiveAddressObject({ assetName, walletAccount })
166
175
 
167
176
  const txSet = await assetClientInterface.getTxLog({ assetName, walletAccount })
168
- 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
+
169
200
  const insightClient = asset.baseAsset.insightClient
170
201
  const currency = asset.currency
171
202
  const feeData = await assetClientInterface.getFeeConfig({ assetName })
@@ -188,7 +219,7 @@ export const createAndBroadcastTXFactory = ({
188
219
  }
189
220
  }
190
221
 
191
- const rbfEnabled = feeData.rbfEnabled && !isExchange && !isBip70 && isRbfAllowed
222
+ const rbfEnabled = feeData.rbfEnabled && !isExchange && !isBip70 && isRbfAllowed && !nft && !brc20
192
223
 
193
224
  let utxosToBump
194
225
  if (bumpTxId) {
@@ -204,9 +235,10 @@ export const createAndBroadcastTXFactory = ({
204
235
  }
205
236
  }
206
237
 
207
- const sendAmount = bumpTxId ? asset.currency.ZERO : amount.toDefault()
208
- 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
209
240
  const feeRate = feeData.feePerKB
241
+ const resolvedIsSendAll = (!rbfEnabled && feePerKB) || transferOrdinalsUtxos ? false : isSendAll
210
242
 
211
243
  let { selectedUtxos, fee, replaceTx } = selectUtxos({
212
244
  asset,
@@ -214,11 +246,12 @@ export const createAndBroadcastTXFactory = ({
214
246
  replaceableTxs,
215
247
  amount: sendAmount,
216
248
  feeRate: customFee || feeRate,
217
- receiveAddress,
218
- isSendAll: !rbfEnabled && feePerKB ? false : isSendAll,
249
+ receiveAddress: receiveAddress,
250
+ isSendAll: resolvedIsSendAll,
219
251
  getFeeEstimator: (asset, { feePerKB }) => getFeeEstimator(asset, feePerKB),
220
252
  mustSpendUtxos: utxosToBump,
221
253
  allowUnconfirmedRbfEnabledUtxos,
254
+ inscriptionIds,
222
255
  })
223
256
 
224
257
  if (!selectedUtxos && !replaceTx) throw new Error('Not enough funds.')
@@ -231,7 +264,9 @@ export const createAndBroadcastTXFactory = ({
231
264
  if (bumpTxId && (!selectedUtxos || (replaceTx && replaceTx.txId !== bumpTxId))) {
232
265
  throw new Error(`Unable to bump ${bumpTxId}`)
233
266
  }
234
-
267
+ if (transferOrdinalsUtxos) {
268
+ selectedUtxos = transferOrdinalsUtxos.union(selectedUtxos)
269
+ }
235
270
  if (replaceTx) {
236
271
  replaceTx = replaceTx.clone()
237
272
  replaceTx = replaceTx.update({ data: { ...replaceTx.data } })
@@ -239,6 +274,7 @@ export const createAndBroadcastTXFactory = ({
239
274
  return { ...to, amount: parseCurrency(to.amount, asset.currency) }
240
275
  })
241
276
  selectedUtxos = selectedUtxos.union(
277
+ // how to avoid replace tx inputs when inputs are ordinals? !!!!
242
278
  UtxoCollection.fromJSON(replaceTx.data.inputs, { currency: asset.currency })
243
279
  )
244
280
  }
@@ -255,14 +291,25 @@ export const createAndBroadcastTXFactory = ({
255
291
  outputs = []
256
292
  }
257
293
  if (address) {
258
- 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
+ }
259
303
  }
260
304
 
261
305
  const totalAmount = replaceTx
262
306
  ? replaceTx.data.sent.reduce((total, { amount }) => total.add(amount), sendAmount)
263
307
  : sendAmount
264
308
 
265
- 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)
266
313
  const dust = getDustValue(asset)
267
314
  let ourAddress = replaceTx?.data?.changeAddress || changeAddress
268
315
  if (asset.address.toLegacyAddress) {
@@ -377,18 +424,29 @@ export const createAndBroadcastTXFactory = ({
377
424
  remainingUtxos = remainingUtxos.difference(remainingUtxos.getTxIdUtxos(replaceTx.txId))
378
425
  }
379
426
 
427
+ const remainingOrdinalsUtxos = transferOrdinalsUtxos
428
+ ? currentOrdinalsUtxos.difference(transferOrdinalsUtxos)
429
+ : undefined
430
+
380
431
  await assetClientInterface.updateAccountState({
381
432
  assetName,
382
433
  walletAccount,
383
- accountState,
384
- newData: { utxos: remainingUtxos },
434
+ newData: {
435
+ utxos: remainingUtxos,
436
+ ordinalsUtxos: remainingOrdinalsUtxos,
437
+ },
385
438
  })
386
439
 
440
+ const walletAddresses = await assetClientInterface.getReceiveAddresses({
441
+ walletAccount,
442
+ assetName,
443
+ multiAddressMode: true,
444
+ })
387
445
  // There are two cases of bumping, replacing or chaining a self-send.
388
446
  // If we have a bumpTxId, but we aren't replacing, then it is a self-send.
389
447
  const selfSend = bumpTxId
390
448
  ? !replaceTx
391
- : receiveAddresses.some((receiveAddress) => String(receiveAddress) === String(address))
449
+ : walletAddresses.some((receiveAddress) => String(receiveAddress) === String(address))
392
450
 
393
451
  const displayReceiveAddress = asset.address.displayAddress?.(receiveAddress) || receiveAddress
394
452
 
@@ -421,6 +479,8 @@ export const createAndBroadcastTXFactory = ({
421
479
  blocksSeen: 0,
422
480
  inputs: selectedUtxos.toJSON(),
423
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,
424
484
  },
425
485
  },
426
486
  ],
@@ -457,6 +517,7 @@ function defaultCreateInputs(utxos, rbfEnabled) {
457
517
  value: parseInt(utxo.value.toBaseString(), 10),
458
518
  script: utxo.script,
459
519
  sequence: getTxSequence(rbfEnabled),
520
+ inscriptionId: utxo.inscriptionId,
460
521
  }))
461
522
  }
462
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)