@exodus/ethereum-api 8.41.0-alpha.0 → 8.42.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.
@@ -1,92 +1,230 @@
1
- import { isEthereumLikeToken, normalizeTxId, parseUnsignedTx } from '@exodus/ethereum-lib'
1
+ /* eslint-disable @exodus/export-default/last */
2
+
3
+ import { calculateBumpedGasPrice, isEthereumLikeToken, normalizeTxId } from '@exodus/ethereum-lib'
2
4
  import assert from 'minimalistic-assert'
3
5
 
4
6
  import * as ErrorWrapper from '../error-wrapper.js'
5
- import { transactionExists } from '../eth-like-util.js'
6
- import { ARBITRARY_ADDRESS } from '../gas-estimation.js'
7
+ import { isContractAddressCached, transactionExists } from '../eth-like-util.js'
8
+ import { getNftArguments } from '../nft-utils.js'
9
+ import getFeeInfo from './get-fee-info.js'
7
10
  import { resolveNonce } from './nonce-utils.js'
8
11
 
9
- const txSendFactory = ({ assetClientInterface, createTx }) => {
10
- assert(assetClientInterface, 'assetClientInterface is required')
11
- assert(createTx, 'createTx is required')
12
+ // Exodus enforces a strict invariant that `sendAll` transactions
13
+ // must not leave any dust in the sender's account. Currently, the
14
+ // assets library has the expectation that the client frontend
15
+ // should calculate the precise amount to send, but due to the
16
+ // sheer amount of variables involved when resolving a `gasPrice`,
17
+ // this is a significant undertaking.
18
+ //
19
+ // Therefore, although clients try their very best to calculate
20
+ // the correct amount, in cases this fails we can fall back to
21
+ // the implementation defined here with a warning.
22
+ // eslint-disable-next-line camelcase
23
+ export const HACK_maybeRefineSendAllAmount = async ({
24
+ amount: providedAmount,
25
+ asset,
26
+ assetClientInterface,
27
+ walletAccount,
28
+ gasLimit,
29
+ gasPrice,
30
+ }) => {
31
+ try {
32
+ const { name: assetName, estimateL1DataFee } = asset
12
33
 
13
- async function signTx({ asset, unsignedTx, walletAccount }) {
14
- const { rawTx, txId } = await assetClientInterface.signTransaction({
15
- assetName: asset.baseAsset.name,
16
- unsignedTx,
17
- walletAccount,
18
- })
34
+ // HACK: For the interim, we won't attempt to
35
+ // reconcile transaction dust on L2s due
36
+ // to the nondeterminism about the calldata
37
+ // fee.
38
+ if (typeof estimateL1DataFee === 'function') return null
39
+
40
+ const [txLog, accountState] = await Promise.all([
41
+ assetClientInterface.getTxLog({
42
+ assetName,
43
+ walletAccount,
44
+ }),
45
+ assetClientInterface.getAccountState({
46
+ assetName,
47
+ walletAccount,
48
+ }),
49
+ ])
50
+
51
+ const { spendable } = await asset.api.getBalances({ asset, txLog, accountState })
52
+ const maxGasCost = gasPrice.mul(gasLimit)
53
+
54
+ if (maxGasCost.gt(spendable)) throw new Error('transaction gas cost exceeds spendable balance')
19
55
 
20
- return { rawTx, txId: normalizeTxId(txId) }
56
+ const expectedSendAllAmount = spendable.sub(maxGasCost)
57
+
58
+ // If the client attempted to send the correct
59
+ // amount, good job! You get a cookie!
60
+ if (providedAmount.equals(expectedSendAllAmount)) return null
61
+
62
+ // The client attempted to `sendAll` using the incorrect amount.
63
+ return expectedSendAllAmount
64
+ } catch (e) {
65
+ console.error('failed to refine send all amount', e)
66
+ return null
21
67
  }
68
+ }
69
+
70
+ const txSendFactory = ({ assetClientInterface, createUnsignedTx, useAbsoluteBalanceAndNonce }) => {
71
+ assert(assetClientInterface, 'assetClientInterface is required')
72
+ assert(createUnsignedTx, 'createUnsignedTx is required')
73
+ return async ({ asset, walletAccount, address, amount, feeData: maybeFeeData, options = {} }) => {
74
+ const {
75
+ nft,
76
+ bumpTxId,
77
+ nonce: providedNonce,
78
+ customFee,
79
+ keepTxInput,
80
+ isSendAll,
81
+ isHardware,
82
+ isPrivate,
83
+ } = options
84
+ let { txInput, feeAmount: providedFeeAmount } = options // avoid let!
85
+
86
+ const feeOpts = {
87
+ gasPrice: options.gasPrice,
88
+ tipGasPrice: options.tipGasPrice,
89
+ gasLimit: options.gasLimit,
90
+ }
22
91
 
23
- return async ({ asset, walletAccount, unsignedTx: providedUnsignedTx, ...legacyParams }) => {
24
92
  const assetName = asset.name
25
93
  const baseAsset = asset.baseAsset
26
94
 
27
- const resolveUnsignedTx = async () => {
28
- if (providedUnsignedTx) {
29
- return { unsignedTx: providedUnsignedTx }
30
- }
95
+ const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName: baseAsset.name })
96
+
97
+ const feeData =
98
+ maybeFeeData ||
99
+ (await assetClientInterface.getFeeData({
100
+ assetName: baseAsset.name,
101
+ }))
31
102
 
32
- const feeData =
33
- legacyParams.feeData ??
34
- (await assetClientInterface.getFeeData({
35
- assetName: baseAsset.name,
36
- }))
103
+ const { eip1559Enabled } = feeData
37
104
 
38
- const fromAddress =
39
- legacyParams.fromAddress ??
40
- (await assetClientInterface.getReceiveAddress({
41
- assetName: baseAsset.name,
42
- walletAccount,
43
- }))
105
+ const fromAddress = await assetClientInterface.getReceiveAddress({
106
+ assetName: baseAsset.name,
107
+ walletAccount,
108
+ })
44
109
 
45
- return createTx({
46
- asset,
47
- walletAccount,
48
- feeData,
49
- fromAddress,
50
- toAddress: legacyParams.address,
51
- ...legacyParams,
52
- ...legacyParams.options,
53
- })
110
+ let contractAddress
111
+ if (nft) {
112
+ const nftArguments = await getNftArguments({ asset, nft, fromAddress, toAddress: address })
113
+ contractAddress = nftArguments.contractAddress
114
+ feeOpts.gasLimit = nftArguments.gasLimit
115
+ txInput = nftArguments.txInput
116
+ amount = asset.baseAsset.currency.ZERO
54
117
  }
55
118
 
56
- const { unsignedTx } = await resolveUnsignedTx()
119
+ let bumpNonce
120
+
121
+ const baseAssetTxLog = await assetClientInterface.getTxLog({
122
+ assetName: baseAsset.name,
123
+ walletAccount,
124
+ })
125
+
126
+ // `replacedTx` is always an ETH/ETC transaction (not a token)
127
+ let replacedTx, replacedTokenTx
128
+ if (bumpTxId) {
129
+ replacedTx = baseAssetTxLog.get(bumpTxId)
57
130
 
58
- // this converts an transactionBuffer to values we can use when creating the tx logs
59
- const parsedTx = parseUnsignedTx({ asset, unsignedTx })
131
+ if (!replacedTx || !replacedTx.pending) {
132
+ throw new Error(`Cannot bump transaction ${bumpTxId}: not found or confirmed`)
133
+ }
60
134
 
61
- // the txMeta.fee may include implicit l1 fees
62
- const feeAmount = unsignedTx.txMeta.fee
63
- ? asset.feeAsset.currency.parse(unsignedTx.txMeta.fee)
64
- : parsedTx.fee
135
+ if (replacedTx.tokens.length > 0) {
136
+ const [tokenAssetName] = replacedTx.tokens
137
+ const tokenTxSet = await assetClientInterface.getTxLog({
138
+ assetName: tokenAssetName,
139
+ walletAccount,
140
+ })
141
+ replacedTokenTx = tokenTxSet.get(bumpTxId)
65
142
 
66
- let nonce = parsedTx.nonce
143
+ if (replacedTokenTx) {
144
+ // Attempt to overwrite the asset to reflect the fact that
145
+ // we're performing a token transaction.
146
+ asset = assets[tokenAssetName]
147
+ if (!asset) {
148
+ console.warn(
149
+ `unable to find ${tokenAssetName} during token bump transaction: asset was not available in assetsForNetwork`
150
+ )
151
+ }
152
+ }
153
+
154
+ // TODO: Should we `throw` if we can't find the asset?
155
+ }
67
156
 
68
- const tipGasPrice = parsedTx.tipGasPrice
69
- const gasLimit = parsedTx.gasLimit
70
- const amount = parsedTx.amount
71
- const to = parsedTx.to
72
- const eip1559Enabled = parsedTx.eip1559Enabled
157
+ address = (replacedTokenTx || replacedTx).to
158
+ amount = (replacedTokenTx || replacedTx).coinAmount.negate()
159
+ feeOpts.gasLimit = replacedTx.data.gasLimit
73
160
 
74
- // unknown data from buffer...
75
- const selfSend = unsignedTx.txMeta.fromAddress === to
76
- const replacedTxId = unsignedTx.txMeta.bumpTxId
161
+ const {
162
+ gasPrice: currentGasPrice,
163
+ baseFeePerGas: currentBaseFee,
164
+ tipGasPrice: currentTipGasPrice,
165
+ } = feeData
166
+ const { bumpedGasPrice, bumpedTipGasPrice } = calculateBumpedGasPrice({
167
+ baseAsset,
168
+ tx: replacedTx,
169
+ currentGasPrice,
170
+ currentBaseFee,
171
+ currentTipGasPrice,
172
+ eip1559Enabled,
173
+ })
174
+ feeOpts.gasPrice = bumpedGasPrice
175
+ feeOpts.tipGasPrice = bumpedTipGasPrice
176
+ bumpNonce = replacedTx.data.nonce
177
+ txInput = replacedTokenTx ? null : replacedTx.data.data || '0x'
178
+ if (bumpNonce === undefined) {
179
+ throw new Error(`Cannot bump transaction ${bumpTxId}: data object seems to be corrupted`)
180
+ }
181
+ }
77
182
 
78
- assert(
79
- to.toLowerCase() !== ARBITRARY_ADDRESS,
80
- `The receiving wallet address must not be ${ARBITRARY_ADDRESS}`
183
+ // If we have evaluated a bump transaction and the `providedNonce` differs
184
+ // from the `bumpNonce`, we've encountered a conflict and cannot respect
185
+ // the caller's request.
186
+ if (
187
+ typeof bumpNonce === 'number' &&
188
+ typeof providedNonce === 'number' &&
189
+ bumpNonce !== providedNonce
81
190
  )
191
+ throw new ErrorWrapper.EthLikeError({
192
+ message: new Error('incorrect nonce for replacement transaction'),
193
+ reason: ErrorWrapper.reasons.bumpTxFailed,
194
+ hint: 'providedNonce',
195
+ })
82
196
 
83
- let { txId, rawTx } = await signTx({
197
+ // Choose a nonce that compensates for transctions which are currently
198
+ // in pending; for example, when we send transactions at low gas which
199
+ // will be stored by `geth` for execution at a later point in time.
200
+ //
201
+ // When we are not intentionally bumping a transaction, users are
202
+ // appending a new transaction to the chain - therefore we should
203
+ // be mindful of nonces belonging to us which are currently pending.
204
+ const resolvedNonce =
205
+ providedNonce ??
206
+ bumpNonce ??
207
+ (await asset.baseAsset.getNonce({ asset, fromAddress, walletAccount }))
208
+
209
+ const createTxParams = {
210
+ assetClientInterface,
84
211
  asset,
85
- unsignedTx,
86
212
  walletAccount,
87
- })
213
+ toAddress: contractAddress || address,
214
+ amount,
215
+ nonce: resolvedNonce,
216
+ fromAddress,
217
+ customFee,
218
+ feeOpts,
219
+ txInput,
220
+ keepTxInput,
221
+ isSendAll,
222
+ createUnsignedTx,
223
+ feeData,
224
+ providedFeeAmount,
225
+ }
88
226
 
89
- const isPrivate = Boolean(legacyParams?.options?.isPrivate)
227
+ let { txId, rawTx, nonce, gasLimit, tipGasPrice, feeAmount } = await createTx(createTxParams)
90
228
 
91
229
  if (isPrivate && typeof baseAsset.broadcastPrivateTx !== 'function')
92
230
  throw new Error(
@@ -112,7 +250,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
112
250
  reason: ErrorWrapper.reasons.insufficientFunds,
113
251
  hint: 'broadcastTx',
114
252
  })
115
- } else if (unsignedTx.txMeta.bumpTxId) {
253
+ } else if (bumpTxId) {
116
254
  throw new ErrorWrapper.EthLikeError({
117
255
  message: err.message,
118
256
  reason: ErrorWrapper.reasons.bumpTxFailed,
@@ -124,16 +262,18 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
124
262
  reason: ErrorWrapper.reasons.broadcastTxFailed,
125
263
  hint: 'otherErr:broadcastTx',
126
264
  })
127
- } else if (nonceTooLowErr && !unsignedTx.txMeta.isHardware) {
265
+ } else if (nonceTooLowErr && !isHardware) {
128
266
  console.info('trying to send again...') // inject logger factory from platform
129
267
  // let's try to fix the nonce issue
130
- const newNonce = await resolveNonce({
268
+ nonce = await resolveNonce({
131
269
  asset,
270
+ fromAddress,
271
+ providedNonce,
272
+ txLog: baseAssetTxLog,
132
273
  triedNonce: nonce,
133
274
  forceFromNode: true,
134
275
  })
135
- nonce = newNonce
136
- ;({ txId, rawTx } = await signTx({ asset, unsignedTx, walletAccount }))
276
+ ;({ txId, rawTx } = await createTx({ ...createTxParams, nonce }))
137
277
 
138
278
  try {
139
279
  await baseAsset.api.broadcastTx(rawTx.toString('hex'))
@@ -156,18 +296,7 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
156
296
  }
157
297
  }
158
298
 
159
- const txData = eip1559Enabled
160
- ? {
161
- gasLimit,
162
- replacedTxId,
163
- nonce,
164
- ...(tipGasPrice ? { tipGasPrice: tipGasPrice.toBaseString() } : Object.create(null)),
165
- }
166
- : {
167
- gasLimit,
168
- replacedTxId,
169
- nonce,
170
- }
299
+ const selfSend = fromAddress === address
171
300
 
172
301
  await assetClientInterface.updateTxLogAndNotify({
173
302
  assetName: asset.name,
@@ -181,12 +310,21 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
181
310
  feeAmount,
182
311
  feeCoinName: asset.feeAsset.name,
183
312
  selfSend,
184
- to,
313
+ to: address,
185
314
  currencies: {
186
315
  [assetName]: asset.currency,
187
316
  [asset.feeAsset.name]: asset.feeAsset.currency,
188
317
  },
189
- data: txData,
318
+ data: eip1559Enabled
319
+ ? {
320
+ gasLimit,
321
+ replacedTxId: bumpTxId,
322
+ nonce,
323
+ ...(tipGasPrice
324
+ ? { tipGasPrice: tipGasPrice.toBaseString() }
325
+ : Object.create(null)),
326
+ }
327
+ : { gasLimit, replacedTxId: bumpTxId, nonce },
190
328
  },
191
329
  ],
192
330
  })
@@ -204,13 +342,20 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
204
342
  feeAmount,
205
343
  feeCoinName: baseAsset.name,
206
344
  selfSend,
207
- to,
345
+ to: address,
208
346
  token: asset.name,
209
347
  currencies: {
210
348
  [baseAsset.name]: baseAsset.currency,
211
349
  [asset.feeAsset.name]: asset.feeAsset.currency,
212
350
  },
213
- data: txData,
351
+ data: eip1559Enabled
352
+ ? {
353
+ gasLimit,
354
+ replacedTxId: bumpTxId,
355
+ nonce,
356
+ ...(tipGasPrice ? { tipGasPrice: tipGasPrice.toBaseString() } : {}),
357
+ }
358
+ : { gasLimit, replacedTxId: bumpTxId, nonce },
214
359
  },
215
360
  ],
216
361
  })
@@ -220,4 +365,146 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
220
365
  }
221
366
  }
222
367
 
368
+ const createTx = async ({
369
+ assetClientInterface,
370
+ asset,
371
+ walletAccount,
372
+ toAddress,
373
+ amount,
374
+ nonce,
375
+ txInput,
376
+ keepTxInput = false,
377
+ customFee,
378
+ isSendAll,
379
+ fromAddress,
380
+ feeOpts,
381
+ createUnsignedTx,
382
+ feeData,
383
+ providedFeeAmount,
384
+ }) => {
385
+ assert(
386
+ nonce !== undefined && typeof nonce === 'number',
387
+ 'Nonce must be provided when creating a tx'
388
+ )
389
+ const isToken = isEthereumLikeToken(asset)
390
+
391
+ if (txInput && isToken && !keepTxInput)
392
+ throw new Error(`Additional data for Ethereum Token (${asset.name}) is not allowed`)
393
+
394
+ txInput =
395
+ isToken && !keepTxInput
396
+ ? asset.contract.transfer.build(toAddress.toLowerCase(), amount.toBaseString())
397
+ : txInput
398
+
399
+ let { gasLimit, gasPrice, tipGasPrice, eip1559Enabled } = await getFeeInfo({
400
+ assetClientInterface,
401
+ asset,
402
+ fromAddress,
403
+ toAddress,
404
+ amount,
405
+ txInput,
406
+ feeOpts,
407
+ feeData,
408
+ customFee,
409
+ })
410
+
411
+ const isContractToAddress = await isContractAddressCached({ asset, address: toAddress })
412
+
413
+ // HACK: We cannot ensure the no dust invariant for `isSendAll`
414
+ // transactions to contract addresses, since we may be
415
+ // performing a raw token transaction and the parameter
416
+ // applies to the token and not the native amount.
417
+ //
418
+ // Contracts have nondeterministic gas most of the time
419
+ // versus estimations, anyway.
420
+ const isSendAllBaseAsset = isSendAll && !isToken && !isContractToAddress
421
+
422
+ // For native send all transactions, we have to make sure that
423
+ // the `tipGasPrice` is equal to the `gasPrice`, since this is
424
+ // effectively like saying that the `maxFeePerGas` is equal
425
+ // to the `maxPriorityFeePerGas`. We do this so that for a
426
+ // fixed gas cost transaction, no dust balance should remain,
427
+ // since any deviation in the underlying `baseFeePerGas` will
428
+ // result only affect the tip for the miner - no dust remains.
429
+ if (eip1559Enabled && isSendAllBaseAsset) {
430
+ // force consuming all gas
431
+ tipGasPrice = gasPrice
432
+ }
433
+
434
+ // HACK: If we are handling a send all transaction, we must ensure
435
+ // the send all invariant is maintained before producing the
436
+ // final transaction.
437
+ const maybeOverrideSendAllAmount =
438
+ isSendAllBaseAsset &&
439
+ (await HACK_maybeRefineSendAllAmount({
440
+ amount,
441
+ asset,
442
+ assetClientInterface,
443
+ walletAccount,
444
+ gasLimit,
445
+ gasPrice,
446
+ }))
447
+
448
+ if (maybeOverrideSendAllAmount) {
449
+ console.log(
450
+ `Attempted to execute a sendAll transaction with an amount of ${amount.toDefaultString({ unit: true })}, but this would fail to maintain the no dust invariant! Overriding with ${maybeOverrideSendAllAmount.toDefaultString({ unit: true })}.`
451
+ )
452
+ amount = maybeOverrideSendAllAmount
453
+ }
454
+
455
+ const unsignedTx = await createUnsignedTx({
456
+ asset,
457
+ walletAccount,
458
+ address: toAddress,
459
+ amount,
460
+ nonce,
461
+ txInput,
462
+ gasLimit,
463
+ gasPrice,
464
+ tipGasPrice,
465
+ fromAddress,
466
+ eip1559Enabled,
467
+ })
468
+
469
+ // TODO: move into createUnsignedTx()
470
+ if (keepTxInput && !isToken) {
471
+ unsignedTx.txData.to = toAddress
472
+ }
473
+
474
+ unsignedTx.txMeta.eip1559Enabled = eip1559Enabled
475
+
476
+ const resolveFee = async () => {
477
+ if (providedFeeAmount) {
478
+ return providedFeeAmount
479
+ }
480
+
481
+ const optimismL1DataFee = asset.baseAsset.estimateL1DataFee
482
+ ? await asset.baseAsset.estimateL1DataFee({
483
+ unsignedTx,
484
+ })
485
+ : undefined
486
+
487
+ const l1DataFee = optimismL1DataFee
488
+ ? asset.baseAsset.currency.baseUnit(optimismL1DataFee)
489
+ : asset.baseAsset.currency.ZERO
490
+ return gasPrice.mul(gasLimit).add(l1DataFee)
491
+ }
492
+
493
+ const { txId, rawTx } = await assetClientInterface.signTransaction({
494
+ assetName: asset.baseAsset.name,
495
+ unsignedTx,
496
+ walletAccount,
497
+ })
498
+
499
+ return {
500
+ txId: normalizeTxId(txId),
501
+ rawTx,
502
+ nonce,
503
+ gasLimit,
504
+ gasPrice,
505
+ tipGasPrice,
506
+ feeAmount: await resolveFee(),
507
+ }
508
+ }
509
+
223
510
  export default txSendFactory