@exodus/ethereum-api 8.53.5 → 8.53.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [8.53.6](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.53.5...@exodus/ethereum-api@8.53.6) (2025-10-23)
7
+
8
+
9
+ ### Bug Fixes
10
+
11
+
12
+ * fix: improve transaction address resolution for gas estimation and encapsulate transaction property evaluation (#6636)
13
+
14
+ * fix: increase evm resistance to txLog race conditions (#6676)
15
+
16
+
17
+
6
18
  ## [8.53.5](https://github.com/ExodusMovement/assets/compare/@exodus/ethereum-api@8.53.4...@exodus/ethereum-api@8.53.5) (2025-10-17)
7
19
 
8
20
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exodus/ethereum-api",
3
- "version": "8.53.5",
3
+ "version": "8.53.6",
4
4
  "description": "Transaction monitors, fee monitors, RPC with the blockchain node, and other networking code for Ethereum and EVM-based blockchains",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -29,7 +29,7 @@
29
29
  "@exodus/bip44-constants": "^195.0.0",
30
30
  "@exodus/crypto": "^1.0.0-rc.26",
31
31
  "@exodus/currency": "^6.0.1",
32
- "@exodus/ethereum-lib": "^5.18.1",
32
+ "@exodus/ethereum-lib": "^5.18.2",
33
33
  "@exodus/ethereum-meta": "^2.9.1",
34
34
  "@exodus/ethereumholesky-meta": "^2.0.5",
35
35
  "@exodus/ethereumjs": "^1.8.0",
@@ -67,5 +67,5 @@
67
67
  "type": "git",
68
68
  "url": "git+https://github.com/ExodusMovement/assets.git"
69
69
  },
70
- "gitHead": "48ee2473ddc98a5eb52a69bba220910263d21a45"
70
+ "gitHead": "124fa7a939418684d2ae50d8db93ff73c59c4cdc"
71
71
  }
@@ -2,6 +2,11 @@ import { currency2buffer, isEthereumLikeToken } from '@exodus/ethereum-lib'
2
2
  import { bufferToHex, toBuffer } from '@exodus/ethereumjs/util'
3
3
 
4
4
  import { estimateGas, isContractAddressCached, isForwarderContractCached } from './eth-like-util.js'
5
+ import {
6
+ ARBITRARY_ADDRESS,
7
+ resolveCriticalTxAttributes,
8
+ TX_TYPE_TRANSFER,
9
+ } from './tx-type/index.js'
5
10
 
6
11
  export const DEFAULT_GAS_LIMIT_MULTIPLIER = 1.29
7
12
 
@@ -12,23 +17,6 @@ export const DEFAULT_CONTRACT_GAS_LIMIT = 1e6
12
17
  export const DEFAULT_TOKEN_GAS_LIMIT = 120e3
13
18
  export const DEFAULT_GAS_LIMIT = 21_000
14
19
 
15
- // HACK: If a recipient address is not defined, we usually fall back to
16
- // default address so gas estimation can still complete successfully
17
- // without knowledge of which accounts are involved.
18
- //
19
- // However, we must be careful to select addresses which are unlikely
20
- // to have existing obligations, such as popular dead addresses or the
21
- // reserved addresses of precompiles, since these can influence gas
22
- // estimation.
23
- //
24
- // Here, we use an address which is mostly all `1`s to make sure we can
25
- // exaggerate the worst-case calldata cost (which is priced per high bit)
26
- // whilst being unlikely to have any token balances.
27
- //
28
- // Unfortunately, we can't use `0xffffffffffffffffffffffffffffffffffffffff`,
29
- // since this address is a whale.
30
- export const ARBITRARY_ADDRESS = '0xfffFfFfFfFfFFFFFfeFfFFFffFffFFFFfFFFFFFF'.toLowerCase()
31
-
32
20
  // HACK: RPCs generally provide imprecise estimates
33
21
  // for `gasUsed` (often these are insufficient).
34
22
  export const scaleGasLimitEstimate = ({
@@ -56,6 +44,7 @@ export async function estimateGasLimit({
56
44
  asset,
57
45
  fromAddress,
58
46
  toAddress,
47
+ // TODO: rename to value, as this is `msg.value`.
59
48
  amount = asset.currency.ZERO,
60
49
  data,
61
50
  gasPrice = '0x',
@@ -76,7 +65,13 @@ export async function resolveGasLimitMultiplier({ asset, feeData, toAddress, fro
76
65
  const gasLimitMultiplierWhenUnknownAddress =
77
66
  feeData?.gasLimits?.[asset.name]?.gasLimitMultiplierWhenUnknownAddress
78
67
 
79
- if (gasLimitMultiplierWhenUnknownAddress && (!fromAddress || !toAddress)) {
68
+ // NOTE: If either the `fromAddress` or `toAddress` has fell
69
+ // back to the `ARBITRARY_ADDRESS`, these qualify as
70
+ // "unknown" addresses.
71
+ const fromAddressIsUnknown = fromAddress === ARBITRARY_ADDRESS
72
+ const toAddressIsUnknown = toAddress === ARBITRARY_ADDRESS
73
+
74
+ if (gasLimitMultiplierWhenUnknownAddress && (fromAddressIsUnknown || toAddressIsUnknown)) {
80
75
  return gasLimitMultiplierWhenUnknownAddress
81
76
  }
82
77
 
@@ -103,12 +98,6 @@ export async function resolveGasLimitMultiplier({ asset, feeData, toAddress, fro
103
98
  return DEFAULT_GAS_LIMIT_MULTIPLIER
104
99
  }
105
100
 
106
- export function resolveDefaultTxInput({ asset, toAddress, amount }) {
107
- return isEthereumLikeToken(asset)
108
- ? bufferToHex(asset.contract.transfer.build(toAddress.toLowerCase(), amount?.toBaseString()))
109
- : '0x'
110
- }
111
-
112
101
  export const defaultGasLimit = ({ asset, txInput }) => {
113
102
  const isToken = isEthereumLikeToken(asset)
114
103
  return (
@@ -117,6 +106,7 @@ export const defaultGasLimit = ({ asset, txInput }) => {
117
106
  )
118
107
  }
119
108
 
109
+ // TODO: `gasLimit` needs to be a responsibility of `resolveTxAttributesByTxType`.
120
110
  export async function fetchGasLimit({
121
111
  asset,
122
112
  feeData,
@@ -124,8 +114,9 @@ export async function fetchGasLimit({
124
114
  toAddress: providedToAddress,
125
115
  txInput: providedTxInput,
126
116
  amount: providedAmount,
127
- contractAddress,
117
+ contractAddress: providedTxToAddress,
128
118
  bip70,
119
+ txType = TX_TYPE_TRANSFER,
129
120
  throwOnError = true,
130
121
  }) {
131
122
  if (bip70?.bitpay?.data && bip70?.bitpay?.gasPrice) {
@@ -133,16 +124,31 @@ export async function fetchGasLimit({
133
124
  return asset.name === 'ethereum' ? 65_000 : 130_000
134
125
  }
135
126
 
136
- const amount = providedAmount ?? asset.currency.ZERO
127
+ // TODO: If we can provide the `walletAccount` via `fetchGasLimit` then
128
+ // we can use `resolveTxAttributesByTxType` instead (and remove
129
+ // the concept of critical attributes).
130
+ const { txToAddress, txInput, txValue } = resolveCriticalTxAttributes({
131
+ asset,
132
+ amount: providedAmount,
133
+ toAddress: providedToAddress,
134
+ txToAddress: providedTxToAddress,
135
+ txInput: providedTxInput,
136
+ txType,
137
+ })
138
+
139
+ const isContractTxToAddress =
140
+ Boolean(txToAddress) && (await isContractAddressCached({ asset, address: txToAddress }))
141
+
137
142
  const fromAddress = providedFromAddress ?? ARBITRARY_ADDRESS
138
- const toAddress = providedToAddress ?? ARBITRARY_ADDRESS
139
- const txInput = providedTxInput || resolveDefaultTxInput({ asset, toAddress, amount })
140
143
 
141
144
  const isToken = isEthereumLikeToken(asset)
142
145
 
143
- const isContract = await isContractAddressCached({ asset, address: toAddress })
144
-
145
- if (!isToken && !isContract && !asset.forceGasLimitEstimation) {
146
+ if (
147
+ txType === TX_TYPE_TRANSFER &&
148
+ !isToken &&
149
+ !isContractTxToAddress &&
150
+ !asset.forceGasLimitEstimation
151
+ ) {
146
152
  return defaultGasLimit({ asset, txInput })
147
153
  }
148
154
 
@@ -150,39 +156,47 @@ export async function fetchGasLimit({
150
156
  asset,
151
157
  feeData,
152
158
  fromAddress: providedFromAddress,
153
- toAddress: providedToAddress,
159
+ toAddress: txToAddress,
154
160
  })
155
161
 
156
- const txToAddress = contractAddress ?? (isToken ? asset.contract.address : toAddress)
157
- const txAmount = isToken ? asset.baseAsset.currency.ZERO : amount
158
-
159
162
  try {
163
+ // NOTE: Although we'll return the `fixedGasLimit` for known
164
+ // tokens, we'll still want to execute `estimateGasLimit`
165
+ // to verify their transaction actually succeeds (i.e.
166
+ // they aren't trying to transfer more than their balance).
167
+ //
168
+ // This prevents users from submitting `fixedGasLimit`
169
+ // token transactions which result in a `revert`.
160
170
  const estimatedGasLimit = await estimateGasLimit({
161
171
  asset,
162
172
  fromAddress,
163
173
  toAddress: txToAddress,
164
- amount: txAmount,
174
+ amount: txValue,
165
175
  data: txInput,
166
176
  })
167
177
 
168
- // NOTE: Although we'll return the `fixedGasLimit` for known
169
- // tokens, we'll still want to execute `estimateGasLimit`
170
- // to verify their transaction actually succeeds (i.e.
171
- // they aren't trying to transfer more than their balance).
178
+ const scaledGasLimitEstimate = scaleGasLimitEstimate({ estimatedGasLimit, gasLimitMultiplier })
179
+ if (!isToken) return scaledGasLimitEstimate
180
+
181
+ // NOTE: If we've enabled `fixGasLimit`s for a token,
182
+ // we need to make sure that transaction we're
183
+ // making actually targets that token.
172
184
  //
173
- // This prevents users from submitting `fixedGasLimit`
174
- // token transactions which result in a `revert`.
175
- return (
176
- feeData?.gasLimits?.[asset.name]?.fixedGasLimit ??
177
- scaleGasLimitEstimate({ estimatedGasLimit, gasLimitMultiplier })
178
- )
185
+ // Otherwise, we risk applying the fixed gas limit
186
+ // to transactions with _other_ smart contracts
187
+ // simply because the caller specified the token
188
+ // asset.
189
+ const isTokenTransaction = asset.contract.address.toLowerCase() === txToAddress.toLowerCase()
190
+ if (!isTokenTransaction) return scaledGasLimitEstimate
191
+
192
+ return feeData?.gasLimits?.[asset.name]?.fixedGasLimit ?? scaledGasLimitEstimate
179
193
  } catch (err) {
180
194
  if (throwOnError) throw err
181
195
 
182
196
  console.error('fetchGasLimit error', err)
183
197
 
184
198
  // fallback value for contract case
185
- if (isContract) return DEFAULT_CONTRACT_GAS_LIMIT
199
+ if (isContractTxToAddress) return DEFAULT_CONTRACT_GAS_LIMIT
186
200
 
187
201
  return defaultGasLimit({ asset, txInput })
188
202
  }
package/src/index.js CHANGED
@@ -24,7 +24,6 @@ export {
24
24
  DEFAULT_CONTRACT_GAS_LIMIT,
25
25
  DEFAULT_GAS_LIMIT_MULTIPLIER,
26
26
  estimateGasLimit,
27
- resolveDefaultTxInput,
28
27
  fetchGasLimit,
29
28
  } from './gas-estimation.js'
30
29
 
package/src/nft-utils.js CHANGED
@@ -34,9 +34,8 @@ export const getNftArguments = async ({ asset, nft, fromAddress, toAddress }) =>
34
34
  const gasLimit = await fetchGasLimit({
35
35
  asset,
36
36
  fromAddress,
37
- toAddress: contractAddress,
37
+ contractAddress,
38
38
  txInput,
39
- amount: asset.baseAsset.currency.ZERO,
40
39
  })
41
40
  return { txInput, gasLimit }
42
41
  } catch (e) {
package/src/tx-create.js CHANGED
@@ -2,51 +2,65 @@ import { calculateBumpedGasPrice, currency2buffer, isEthereumLikeToken } from '@
2
2
  import createEthereumJsTx from '@exodus/ethereum-lib/src/unsigned-tx/create-ethereumjs-tx.js'
3
3
  import assert from 'minimalistic-assert'
4
4
 
5
- import * as ErrorWrapper from './error-wrapper.js'
6
- import { isContractAddressCached } from './eth-like-util.js'
7
5
  import { ensureSaneEip1559GasPriceForTipGasPrice } from './fee-utils.js'
8
- import { ARBITRARY_ADDRESS, fetchGasLimit, resolveDefaultTxInput } from './gas-estimation.js'
6
+ import { fetchGasLimit } from './gas-estimation.js'
9
7
  import { getExtraFeeData, getFeeFactoryGasPrices } from './get-fee.js'
10
8
  import { getNftArguments } from './nft-utils.js'
11
9
  import { getHighestIncentivePendingTxByNonce } from './tx-log/index.js'
10
+ import {
11
+ assertTxAttributes,
12
+ isValidTxType,
13
+ resolveTxAttributesByTxType,
14
+ resolveTxFromAddress,
15
+ TX_TYPE_TRANSFER,
16
+ } from './tx-type/index.js'
12
17
 
13
18
  async function createUnsignedTxWithFees({
14
19
  asset,
15
20
  chainId,
16
- to, // the tx to address, it could be the reciver address for native sending, the token contract, the DEX contract, etc
17
- value, // the value of the tx in NU, it can be the value in eth or 0 when calling contracts
18
- data, // the data of the tx in hex string, it can be 0x for native sending or the params when sending to a contract
19
21
  gasLimit,
20
22
  eip1559Enabled,
21
23
  gasPrice, // eip 1559: `maxFeePerGas`
22
24
  tipGasPrice, // eip 1559: `maxPriorityPerGas`
23
- nonce,
24
25
  bumpTxId,
25
- coinAmount, // coinAmount
26
- fromAddress, // user's sending address
27
- toAddress, // user's receiver address
26
+ /* txAttributes */
27
+ amount,
28
+ toAddress,
29
+ txInput,
30
+ txToAddress,
31
+ txType,
32
+ txValue,
33
+ fromAddress,
34
+ nonce,
35
+ isContractTxToAddress,
28
36
  }) {
29
37
  assert(asset, 'asset is required')
30
38
  assert(typeof chainId === 'number', 'chainId is required')
31
- assert(to, 'to is required')
32
- assert(value, 'value is required')
33
- assert(data, 'data is required')
34
39
  assert(gasLimit, 'gasLimit is required')
35
40
  assert(gasPrice, 'gasPrice is required')
36
- assert(coinAmount, 'coinAmount is required')
37
- assert(fromAddress, 'fromAddress is required')
38
- assert(toAddress, 'toAddress is required')
39
41
  assert(typeof eip1559Enabled === 'boolean', 'eip1559Enabled is required')
40
42
 
43
+ assertTxAttributes({
44
+ amount,
45
+ toAddress,
46
+ txInput,
47
+ txToAddress,
48
+ txType,
49
+ txValue,
50
+ fromAddress,
51
+ nonce,
52
+ isContractTxToAddress,
53
+ })
54
+
41
55
  const ethjsTx = createEthereumJsTx({
42
56
  txData: {
43
57
  nonce,
44
58
  gasPrice: currency2buffer(gasPrice),
45
59
  tipGasPrice: tipGasPrice ? currency2buffer(tipGasPrice) : undefined,
46
60
  gasLimit,
47
- to,
48
- value: currency2buffer(value),
49
- data,
61
+ to: txToAddress,
62
+ value: currency2buffer(txValue),
63
+ data: txInput,
50
64
  chainId,
51
65
  },
52
66
  txMeta: {
@@ -67,7 +81,7 @@ async function createUnsignedTxWithFees({
67
81
  : asset.baseAsset.currency.ZERO
68
82
 
69
83
  const fee = baseFee.add(l1DataFee)
70
- const extraFeeData = getExtraFeeData({ asset, amount: coinAmount })
84
+ const extraFeeData = getExtraFeeData({ asset, amount })
71
85
  const unsignedTx = {
72
86
  txData: { transactionBuffer, chainId },
73
87
  txMeta: {
@@ -76,7 +90,7 @@ async function createUnsignedTxWithFees({
76
90
  eip1559Enabled,
77
91
  fromAddress,
78
92
  toAddress,
79
- amount: coinAmount.toDefaultString({ unit: true }),
93
+ amount: amount.toDefaultString({ unit: true }),
80
94
  fee: fee.toDefaultString({ unit: true }),
81
95
  },
82
96
  }
@@ -91,6 +105,35 @@ async function createUnsignedTxWithFees({
91
105
  }
92
106
  }
93
107
 
108
+ const resolveTxFactoryGasPrices = ({
109
+ customFee: providedCustomFee,
110
+ feeData,
111
+ gasPrice: providedGasPrice,
112
+ tipGasPrice: providedTipGasPrice,
113
+ }) => {
114
+ assert(feeData)
115
+
116
+ const {
117
+ gasPrice: maybeGasPrice,
118
+ feeData: { tipGasPrice: maybeTipGasPrice, eip1559Enabled },
119
+ } = getFeeFactoryGasPrices({ customFee: providedCustomFee, feeData })
120
+
121
+ const resolvedGasPrice = providedGasPrice ?? maybeGasPrice
122
+
123
+ if (!eip1559Enabled) return { eip1559Enabled, resolvedGasPrice }
124
+
125
+ const resolvedTipGasPrice = providedTipGasPrice ?? maybeTipGasPrice
126
+
127
+ return {
128
+ eip1559Enabled,
129
+ resolvedGasPrice: ensureSaneEip1559GasPriceForTipGasPrice({
130
+ gasPrice: resolvedGasPrice,
131
+ tipGasPrice: resolvedTipGasPrice,
132
+ }),
133
+ resolvedTipGasPrice,
134
+ }
135
+ }
136
+
94
137
  const createBumpUnsignedTx = async ({
95
138
  fromAddress,
96
139
  chainId,
@@ -100,8 +143,11 @@ const createBumpUnsignedTx = async ({
100
143
  assetClientInterface,
101
144
  walletAccount,
102
145
  feeData,
103
- nonce: providedNonce,
146
+ nonce: maybeProvidedNonce,
147
+ txType,
104
148
  }) => {
149
+ assert(isValidTxType(txType), 'invalid txType')
150
+
105
151
  const baseAsset = asset.baseAsset
106
152
  const replacedTx = baseAssetTxLog.get(bumpTxId)
107
153
  const assets = await assetClientInterface.getAssetsForNetwork({ baseAssetName: baseAsset.name })
@@ -131,13 +177,40 @@ const createBumpUnsignedTx = async ({
131
177
  }
132
178
 
133
179
  const toAddress = (replacedTokenTx || replacedTx).to
134
- const isToken = isEthereumLikeToken(asset)
135
- const txToAddress = isToken ? asset.contract.address : toAddress
136
- const coinAmount = (replacedTokenTx || replacedTx).coinAmount.negate()
137
- const gasLimit = replacedTx.data.gasLimit
138
- const nonce = replacedTx.data.nonce
139
180
 
140
- const value = isToken ? baseAsset.currency.ZERO : coinAmount
181
+ const amount = (replacedTokenTx || replacedTx).coinAmount.negate()
182
+
183
+ const txInput = replacedTokenTx ? null : replacedTx.data.data || '0x'
184
+
185
+ const replacedTxNonce = replacedTx.data.nonce
186
+
187
+ assert(
188
+ Number.isInteger(replacedTxNonce),
189
+ `Cannot bump transaction ${bumpTxId}: data object seems to be corrupted`
190
+ )
191
+
192
+ // If we have evaluated a bump transaction and the `providedNonce` differs
193
+ // from the `bumpNonce`, we've encountered a conflict and cannot respect
194
+ // the caller's request.
195
+ if (maybeProvidedNonce && maybeProvidedNonce !== replacedTxNonce) {
196
+ throw new Error('incorrect nonce for replacement transaction')
197
+ }
198
+
199
+ const nonce = maybeProvidedNonce ?? replacedTxNonce
200
+
201
+ const resolvedTxAttributes = await resolveTxAttributesByTxType({
202
+ asset,
203
+ assetClientInterface,
204
+ fromAddress,
205
+ amount,
206
+ nonce,
207
+ txInput,
208
+ toAddress,
209
+ txType,
210
+ walletAccount,
211
+ })
212
+
213
+ const gasLimit = replacedTx.data.gasLimit
141
214
 
142
215
  const {
143
216
  gasPrice: currentGasPrice,
@@ -149,13 +222,13 @@ const createBumpUnsignedTx = async ({
149
222
  const maybeHighestIncentivePendingTxForNonce = await getHighestIncentivePendingTxByNonce({
150
223
  assetClientInterface,
151
224
  asset,
152
- nonce,
225
+ nonce: resolvedTxAttributes.nonce,
153
226
  walletAccount,
154
227
  })
155
228
 
156
229
  assert(
157
230
  maybeHighestIncentivePendingTxForNonce,
158
- `unable to resolve pending transaction for nonce ${nonce}`
231
+ `unable to resolve pending transaction for nonce ${resolvedTxAttributes.nonce}`
159
232
  )
160
233
 
161
234
  const { bumpedGasPrice, bumpedTipGasPrice } = calculateBumpedGasPrice({
@@ -171,41 +244,22 @@ const createBumpUnsignedTx = async ({
171
244
  currentTipGasPrice,
172
245
  eip1559Enabled,
173
246
  })
174
- const gasPrice = bumpedGasPrice
175
- const tipGasPrice = bumpedTipGasPrice
176
- const data = replacedTokenTx
177
- ? asset.contract.transfer.build(toAddress.toLowerCase(), coinAmount.toBaseString())
178
- : replacedTx.data.data || '0x'
179
-
180
- if (nonce === undefined) {
181
- throw new Error(`Cannot bump transaction ${bumpTxId}: data object seems to be corrupted`)
182
- }
183
247
 
184
- // If we have evaluated a bump transaction and the `providedNonce` differs
185
- // from the `bumpNonce`, we've encountered a conflict and cannot respect
186
- // the caller's request.
187
- if (typeof nonce === 'number' && typeof providedNonce === 'number' && nonce !== providedNonce) {
188
- throw new ErrorWrapper.EthLikeError({
189
- message: new Error('incorrect nonce for replacement transaction'),
190
- reason: ErrorWrapper.reasons.bumpTxFailed,
191
- hint: 'providedNonce',
248
+ const { resolvedGasPrice: gasPrice, resolvedTipGasPrice: tipGasPrice } =
249
+ resolveTxFactoryGasPrices({
250
+ feeData,
251
+ gasPrice: bumpedGasPrice,
252
+ tipGasPrice: bumpedTipGasPrice,
192
253
  })
193
- }
194
254
 
195
255
  return createUnsignedTxWithFees({
256
+ ...resolvedTxAttributes,
196
257
  asset,
197
258
  chainId,
198
- to: txToAddress,
199
- value,
200
- data,
201
259
  gasLimit,
202
260
  gasPrice,
203
261
  tipGasPrice,
204
- nonce,
205
262
  bumpTxId,
206
- coinAmount,
207
- fromAddress,
208
- toAddress,
209
263
  eip1559Enabled,
210
264
  })
211
265
  }
@@ -220,33 +274,35 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
220
274
  fromAddress: providedFromAddress, // wallet from address
221
275
  toAddress: providedToAddress, // user's to address, not the token or the dex contract
222
276
  txInput: providedTxInput, // Provided when swapping via a DEX contract
277
+ txType = TX_TYPE_TRANSFER, // Defines what kind of transaction is being performed.
223
278
  gasLimit: providedGasLimit, // Provided by exchange when known
224
279
  amount: providedAmount, // The NU amount to be sent, to be included in the tx value or tx input
225
280
  nonce: providedNonce,
226
281
  tipGasPrice: providedTipGasPrice,
227
282
  gasPrice: providedGasPrice,
228
283
  bip70,
229
- customFee,
284
+ customFee: providedCustomFee,
230
285
  isSendAll,
231
286
  bumpTxId,
232
287
  }) => {
233
288
  assert(asset, 'asset is required')
234
289
  assert(walletAccount, 'walletAccount is required')
290
+ assert(isValidTxType(txType), 'invalid txType')
235
291
 
236
292
  const feeData = await assetClientInterface.getFeeConfig({ assetName: asset.baseAsset.name })
237
293
 
238
- const fromAddress =
239
- providedFromAddress ??
240
- (await assetClientInterface.getReceiveAddress({
241
- assetName: asset.baseAsset.name,
242
- walletAccount,
243
- }))
244
-
245
294
  const baseAssetTxLog = await assetClientInterface.getTxLog({
246
295
  assetName: asset.baseAsset.name,
247
296
  walletAccount,
248
297
  })
249
298
 
299
+ const fromAddress = await resolveTxFromAddress({
300
+ asset,
301
+ assetClientInterface,
302
+ fromAddress: providedFromAddress,
303
+ walletAccount,
304
+ })
305
+
250
306
  if (bumpTxId) {
251
307
  return createBumpUnsignedTx({
252
308
  chainId,
@@ -258,62 +314,20 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
258
314
  walletAccount,
259
315
  feeData,
260
316
  nonce: providedNonce,
317
+ txType,
261
318
  })
262
319
  }
263
320
 
264
- const toAddress = providedToAddress || ARBITRARY_ADDRESS
265
321
  const {
266
- gasPrice: maybeGasPrice,
267
- feeData: { tipGasPrice: maybeTipGasPrice, eip1559Enabled },
268
- } = getFeeFactoryGasPrices({ customFee, feeData })
269
-
270
- const isToken = isEthereumLikeToken(asset)
271
-
272
- const resolvedGasPrice = providedGasPrice ?? maybeGasPrice
273
-
274
- // When sending a main asset, the transaction (tx) 'to' address is the receiver's address. No tx input is provided.
275
- // When sending a token, the tx 'to' address is the asset.contract?.address. No tx input is provided; it's resolved locally.
276
- // When DEX swapping a main asset, the exchange provides a txInput and a DEX address. Use the DEX address as the tx 'to' address.
277
- // When DEX swapping a token, the exchange provides a txInput and a DEX address. Use the DEX address as the tx 'to' address.
278
- // When CEX swapping a main asset, the exchange may provide a txInput and an address. Use this address as the tx 'to' address.
279
- // When CEX swapping a token, the exchange may provide a txInput but not an address. In this case, the tx 'to' address is the token address.
280
-
281
- const txToAddress =
282
- isToken && !providedTxInput
283
- ? asset.contract.address
284
- : providedToAddress || asset.contract?.address || ARBITRARY_ADDRESS
285
-
286
- const isContractToAddress = await isContractAddressCached({ asset, address: txToAddress })
287
-
288
- // HACK: We cannot ensure the no dust invariant for `isSendAll`
289
- // transactions to contract addresses, since we may be
290
- // performing a raw token transaction and the parameter
291
- // applies to the token and not the native amount.
292
- //
293
- // Contracts have nondeterministic gas most of the time
294
- // versus estimations, anyway.
295
- const isSendAllBaseAsset = isSendAll && !isToken && !isContractToAddress
296
-
297
- // For native send all transactions, we have to make sure that
298
- // the `tipGasPrice` is equal to the `gasPrice`, since this is
299
- // effectively like saying that the `maxFeePerGas` is equal
300
- // to the `maxPriorityFeePerGas`. We do this so that for a
301
- // fixed gas cost transaction, no dust balance should remain,
302
- // since any deviation in the underlying `baseFeePerGas` will
303
- // result only affect the tip for the miner - no dust remains.
304
- const tipGasPrice =
305
- providedTipGasPrice ??
306
- (eip1559Enabled && isSendAllBaseAsset ? resolvedGasPrice : maybeTipGasPrice)
307
-
308
- const gasPrice = eip1559Enabled
309
- ? ensureSaneEip1559GasPriceForTipGasPrice({
310
- gasPrice: resolvedGasPrice,
311
- tipGasPrice,
312
- })
313
- : resolvedGasPrice
314
-
315
- const nonce =
316
- providedNonce ?? (await asset.baseAsset.getNonce({ asset, fromAddress, walletAccount }))
322
+ eip1559Enabled,
323
+ resolvedGasPrice: gasPrice,
324
+ resolvedTipGasPrice: tipGasPrice,
325
+ } = resolveTxFactoryGasPrices({
326
+ customFee: providedCustomFee,
327
+ feeData,
328
+ gasPrice: providedGasPrice,
329
+ tipGasPrice: providedTipGasPrice,
330
+ })
317
331
 
318
332
  if (nft) {
319
333
  const {
@@ -327,55 +341,81 @@ export const createTxFactory = ({ chainId, assetClientInterface, useAbsoluteNonc
327
341
  toAddress: providedToAddress,
328
342
  })
329
343
 
330
- const value = asset.baseAsset.currency.ZERO
344
+ const resolvedTxAttributes = await resolveTxAttributesByTxType({
345
+ asset: asset.baseAsset,
346
+ assetClientInterface,
347
+ fromAddress,
348
+ nonce: providedNonce,
349
+ txInput,
350
+ toAddress: providedToAddress,
351
+ txToAddress,
352
+ txType,
353
+ walletAccount,
354
+ })
331
355
 
332
356
  return createUnsignedTxWithFees({
357
+ ...resolvedTxAttributes,
333
358
  chainId,
334
359
  asset,
335
- to: txToAddress,
336
- value,
337
- data: txInput,
338
360
  gasLimit,
339
361
  gasPrice,
340
362
  tipGasPrice,
341
- nonce,
342
- coinAmount: value,
343
- fromAddress,
344
- toAddress,
345
363
  eip1559Enabled,
346
364
  })
347
365
  }
348
366
 
349
- const amount = providedAmount ?? asset.currency.ZERO
367
+ const resolvedTxAttributes = await resolveTxAttributesByTxType({
368
+ asset,
369
+ assetClientInterface,
370
+ amount: providedAmount,
371
+ fromAddress,
372
+ nonce: providedNonce,
373
+ txInput: providedTxInput,
374
+ toAddress: providedToAddress,
375
+ txType,
376
+ walletAccount,
377
+ })
350
378
 
351
- const value = isToken ? asset.baseAsset.currency.ZERO : amount
352
- const txInput = providedTxInput || resolveDefaultTxInput({ asset, toAddress, amount })
379
+ // TODO: `gasLimit` should become `txGasLimit` and returned
380
+ // by `resolveTxAttributesByTxType`.
353
381
  const gasLimit =
354
382
  providedGasLimit ??
355
383
  (await fetchGasLimit({
356
384
  asset,
357
385
  feeData,
358
386
  fromAddress,
359
- toAddress: providedToAddress,
360
- txInput: providedTxInput,
361
- contractAddress: txToAddress,
387
+ toAddress: resolvedTxAttributes.toAddress,
388
+ txInput: resolvedTxAttributes.txInput,
389
+ contractAddress: resolvedTxAttributes.txToAddress,
362
390
  bip70,
363
- amount,
391
+ amount: resolvedTxAttributes.amount,
392
+ txType,
364
393
  }))
365
394
 
395
+ // HACK: We cannot ensure the no dust invariant for `isSendAll`
396
+ // transactions to contract addresses, since we may be
397
+ // performing a raw token transaction and the parameter
398
+ // applies to the token and not the native amount.
399
+ //
400
+ // Contracts have nondeterministic gas most of the time
401
+ // versus estimations, anyway.
402
+ const isSendAllBaseAsset =
403
+ isSendAll && !isEthereumLikeToken(asset) && !resolvedTxAttributes.isContractTxToAddress
404
+
366
405
  return createUnsignedTxWithFees({
406
+ ...resolvedTxAttributes,
367
407
  asset,
368
408
  chainId,
369
- to: txToAddress,
370
- value,
371
- data: txInput,
372
409
  gasLimit,
373
410
  gasPrice,
374
- tipGasPrice,
375
- nonce,
376
- coinAmount: amount,
377
- fromAddress,
378
- toAddress,
411
+ // For native send all transactions, we have to make sure that
412
+ // the `tipGasPrice` is equal to the `gasPrice`, since this is
413
+ // effectively like saying that the `maxFeePerGas` is equal
414
+ // to the `maxPriorityFeePerGas`. We do this so that for a
415
+ // fixed gas cost transaction, no dust balance should remain,
416
+ // since any deviation in the underlying `baseFeePerGas` will
417
+ // result only affect the tip for the miner - no dust remains.
418
+ tipGasPrice: isSendAllBaseAsset && eip1559Enabled ? gasPrice : tipGasPrice,
379
419
  eip1559Enabled,
380
420
  })
381
421
  }
@@ -1,3 +1,4 @@
1
+ import { SynchronizedTime } from '@exodus/basic-utils'
1
2
  import NumberUnit from '@exodus/currency'
2
3
  import { isEthereumLikeToken, parseUnsignedTx } from '@exodus/ethereum-lib'
3
4
  import { bufferToHex } from '@exodus/ethereumjs/util'
@@ -33,6 +34,7 @@ export const getOptimisticTxLogEffects = async ({
33
34
  asset,
34
35
  assetClientInterface,
35
36
  confirmations = 0,
37
+ date = SynchronizedTime.now(),
36
38
  fromAddress,
37
39
  txId,
38
40
  unsignedTx,
@@ -49,7 +51,7 @@ export const getOptimisticTxLogEffects = async ({
49
51
  // this converts an transactionBuffer to values we can use when creating the tx logs
50
52
  const parsedTx = parseUnsignedTx({ asset, unsignedTx })
51
53
 
52
- const { nonce } = parsedTx
54
+ const { nonce, to } = parsedTx
53
55
  assert(Number.isInteger(nonce), 'expected integer nonce')
54
56
 
55
57
  const amount = parsedTx.amount || asset.currency.ZERO
@@ -66,13 +68,12 @@ export const getOptimisticTxLogEffects = async ({
66
68
  const gasLimit = parsedTx.gasLimit
67
69
  assert(Number.isInteger(gasLimit), 'expected integer gasLimit')
68
70
 
69
- const toAddress = parsedTx.to
70
- assert(typeof toAddress === 'string', 'expected string toAddress')
71
+ if (to) assert(typeof to === 'string', 'expected string toAddress')
71
72
 
72
73
  const data = parsedTx.data
73
74
  const methodId = data ? bufferToHex(data).slice(0, 10) : undefined
74
75
 
75
- const selfSend = fromAddress.toLowerCase() === toAddress.toLowerCase()
76
+ const selfSend = fromAddress.toLowerCase() === to?.toLowerCase()
76
77
 
77
78
  const baseAsset = asset.baseAsset
78
79
 
@@ -92,10 +93,11 @@ export const getOptimisticTxLogEffects = async ({
92
93
 
93
94
  const sharedProps = {
94
95
  confirmations,
96
+ date,
95
97
  feeAmount,
96
98
  feeCoinName: asset.feeAsset.name,
97
99
  selfSend,
98
- to: toAddress,
100
+ to,
99
101
  txId,
100
102
  data: {
101
103
  gasLimit,
@@ -3,8 +3,8 @@ import assert from 'minimalistic-assert'
3
3
 
4
4
  import * as ErrorWrapper from '../error-wrapper.js'
5
5
  import { transactionExists } from '../eth-like-util.js'
6
- import { ARBITRARY_ADDRESS } from '../gas-estimation.js'
7
6
  import { getOptimisticTxLogEffects } from '../tx-log/index.js'
7
+ import { ARBITRARY_ADDRESS } from '../tx-type/index.js'
8
8
 
9
9
  const txSendFactory = ({ assetClientInterface, createTx }) => {
10
10
  assert(assetClientInterface, 'assetClientInterface is required')
@@ -40,13 +40,13 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
40
40
  const { unsignedTx } = await resolveUnsignedTx()
41
41
 
42
42
  const parsedTx = parseUnsignedTx({ asset, unsignedTx })
43
- const toAddress = parsedTx.to
44
43
 
45
44
  // unknown data from buffer...
45
+ const to = parsedTx.to
46
46
  const fromAddress = unsignedTx.txMeta.fromAddress
47
47
 
48
48
  assert(
49
- toAddress.toLowerCase() !== ARBITRARY_ADDRESS,
49
+ to?.toLowerCase() !== ARBITRARY_ADDRESS,
50
50
  `The receiving wallet address must not be ${ARBITRARY_ADDRESS}`
51
51
  )
52
52
 
@@ -140,7 +140,6 @@ const txSendFactory = ({ assetClientInterface, createTx }) => {
140
140
  walletAccount,
141
141
  })
142
142
 
143
- // NOTE: `optimisticTxLogEffects` **must** be written sequentially.
144
143
  for (const optimisticTxLogEffect of optimisticTxLogEffects) {
145
144
  await assetClientInterface.updateTxLogAndNotify(optimisticTxLogEffect)
146
145
  }
@@ -0,0 +1,236 @@
1
+ import NumberUnit from '@exodus/currency'
2
+ import { isEthereumLikeToken } from '@exodus/ethereum-lib'
3
+ import { bufferToHex } from '@exodus/ethereumjs/util'
4
+ import assert from 'minimalistic-assert'
5
+
6
+ import { isContractAddressCached } from '../eth-like-util.js'
7
+
8
+ export const TX_TYPE_TRANSFER = 'transfer'
9
+
10
+ const VALID_TX_TYPES = new Set([TX_TYPE_TRANSFER])
11
+
12
+ export const isValidTxType = (txType) => VALID_TX_TYPES.has(txType)
13
+
14
+ // TODO: Remove this.
15
+ // HACK: If a recipient address is not defined, we usually fall back to
16
+ // default address so gas estimation can still complete successfully
17
+ // without knowledge of which accounts are involved.
18
+ //
19
+ // However, we must be careful to select addresses which are unlikely
20
+ // to have existing obligations, such as popular dead addresses or the
21
+ // reserved addresses of precompiles, since these can influence gas
22
+ // estimation.
23
+ //
24
+ // Here, we use an address which is mostly all `1`s to make sure we can
25
+ // exaggerate the worst-case calldata cost (which is priced per high bit)
26
+ // whilst being unlikely to have any token balances.
27
+ //
28
+ // Unfortunately, we can't use `0xffffffffffffffffffffffffffffffffffffffff`,
29
+ // since this address is a whale.
30
+ export const ARBITRARY_ADDRESS = '0xfffFfFfFfFfFFFFFfeFfFFFffFffFFFFfFFFFFFF'.toLowerCase()
31
+
32
+ export const assertCriticalTxAttributes = (criticalTxAttributes) => {
33
+ const { amount, toAddress, txInput, txToAddress, txType, txValue } = criticalTxAttributes
34
+
35
+ assert(amount instanceof NumberUnit, 'expected NumberUnit amount')
36
+ assert(typeof toAddress === 'string', 'expected string toAddress')
37
+ assert(
38
+ typeof txInput === 'string' && txInput.startsWith('0x', 'expected hexadecimal string txInput')
39
+ )
40
+ assert(typeof txToAddress === 'string', 'expected string txToAddress')
41
+ assert(isValidTxType(txType), 'expected valid txType')
42
+ assert(txValue instanceof NumberUnit, 'expected NumberUnit txValue')
43
+
44
+ return criticalTxAttributes
45
+ }
46
+
47
+ export const assertTxAttributes = (txAttributes) => {
48
+ // TODO: Note that the distinction between "critical" and "non-critical"
49
+ // only exists to satisfy `fetchGasLimit` needing to know some props.
50
+ // Once we encapsulate `gasLimit`, all properties will be critical.
51
+ const {
52
+ /* critical */
53
+ amount,
54
+ toAddress,
55
+ txInput,
56
+ txToAddress,
57
+ txType,
58
+ txValue,
59
+ /* non-critical */
60
+ fromAddress,
61
+ nonce,
62
+ isContractTxToAddress,
63
+ } = txAttributes
64
+
65
+ assertCriticalTxAttributes({ amount, toAddress, txInput, txToAddress, txType, txValue })
66
+
67
+ assert(typeof fromAddress === 'string', 'expected fromAddress')
68
+ assert(Number.isInteger(nonce), 'expected integer nonce')
69
+ assert(typeof isContractTxToAddress === 'boolean', 'expected boolean isContractTxToAddress')
70
+
71
+ return txAttributes
72
+ }
73
+
74
+ const createResolvedTxAttributes = async ({
75
+ amount,
76
+ asset,
77
+ fromAddress,
78
+ nonce,
79
+ toAddress,
80
+ txInput,
81
+ txToAddress,
82
+ txValue,
83
+ txType,
84
+ }) =>
85
+ assertTxAttributes({
86
+ amount,
87
+ fromAddress,
88
+ nonce,
89
+ toAddress,
90
+ txInput,
91
+ txToAddress,
92
+ txValue,
93
+ txType,
94
+ isContractTxToAddress: await isContractAddressCached({ asset, address: txToAddress }),
95
+ })
96
+
97
+ export const resolveTxFromAddress = async ({
98
+ asset,
99
+ assetClientInterface,
100
+ fromAddress: providedFromAddress,
101
+ walletAccount,
102
+ }) => {
103
+ assert(asset, 'expected asset')
104
+ assert(assetClientInterface, 'expected assetClientInterface')
105
+ assert(walletAccount, 'expected walletAccount')
106
+
107
+ if (providedFromAddress) return providedFromAddress
108
+
109
+ return assetClientInterface.getReceiveAddress({
110
+ assetName: asset.baseAsset.name,
111
+ walletAccount,
112
+ })
113
+ }
114
+
115
+ const resolveTxNonce = async ({ asset, fromAddress, nonce: providedNonce, walletAccount }) => {
116
+ assert(asset, 'expected asset')
117
+ assert(typeof fromAddress === 'string', 'expected string fromAddress')
118
+ assert(walletAccount, 'expected walletAccount')
119
+
120
+ if (Number.isInteger(providedNonce)) return providedNonce
121
+
122
+ return asset.baseAsset.getNonce({ asset, fromAddress, walletAccount })
123
+ }
124
+
125
+ const resolveTxInput = ({ asset, toAddress, amount, txInput: providedTxInput }) => {
126
+ assert(asset, 'expected asset')
127
+ assert(typeof toAddress === 'string', 'expected string toAddress')
128
+ assert(amount instanceof NumberUnit, 'expected NumberUnit amount')
129
+
130
+ if (providedTxInput) return bufferToHex(providedTxInput)
131
+
132
+ if (!isEthereumLikeToken(asset)) return '0x'
133
+
134
+ return bufferToHex(asset.contract.transfer.build(toAddress.toLowerCase(), amount?.toBaseString()))
135
+ }
136
+
137
+ export const resolveCriticalTxAttributes = ({
138
+ asset,
139
+ amount: providedAmount,
140
+ toAddress: providedToAddress,
141
+ txToAddress: providedTxToAddress,
142
+ txInput: providedTxInput,
143
+ txType,
144
+ }) => {
145
+ assert(asset, 'expected asset')
146
+ assert(isValidTxType(txType), 'expected valid txType')
147
+
148
+ const amount = providedAmount ?? asset.currency.ZERO
149
+ assert(amount instanceof NumberUnit, 'expected providedAmount')
150
+ assert(txType === TX_TYPE_TRANSFER, 'expected TX_TYPE_TRANSFER')
151
+
152
+ // HACK: If a `toAddress` hasn't been defined, then we
153
+ // fall back to the `ARBITRARY_ADDRESS`. Note that
154
+ // this should only be used to help determine the
155
+ // fee of a transaction where we don't yet know the
156
+ // intended recipient. Attempts to send to this
157
+ // sentinel address will ultimately `throw`.
158
+ const toAddress = providedToAddress || ARBITRARY_ADDRESS
159
+ const txValue = isEthereumLikeToken(asset) ? asset.baseAsset.currency.ZERO : amount
160
+ const txInput = resolveTxInput({ amount, asset, txInput: providedTxInput, toAddress })
161
+
162
+ const baseProps = {
163
+ amount,
164
+ toAddress,
165
+ txInput,
166
+ txType,
167
+ txValue,
168
+ }
169
+
170
+ // When CEX swapping a main asset, the exchange may provide a txInput and an address. Use this address as the tx 'to' address.
171
+ // When DEX swapping a main asset, the exchange provides a txInput and a DEX address. Use the DEX address as the tx 'to' address.
172
+ // When sending a main asset, the transaction (tx) 'to' address is the receiver's address. No tx input is provided.
173
+ if (!isEthereumLikeToken(asset)) {
174
+ return assertCriticalTxAttributes({
175
+ ...baseProps,
176
+ txToAddress: providedTxToAddress || toAddress,
177
+ })
178
+ }
179
+
180
+ if (providedTxInput) {
181
+ // When CEX swapping a token, the exchange may provide a txInput but not an address. In this case, the tx 'to' address is the token address.
182
+ // When DEX swapping a token, the exchange provides a txInput and a DEX address. Use the DEX address as the tx 'to' address.
183
+ const txToAddress = providedTxToAddress || providedToAddress || asset.contract?.address
184
+ return assertCriticalTxAttributes({ ...baseProps, txToAddress })
185
+ }
186
+
187
+ // When sending a token, the tx 'to' address is the asset.contract?.address. No tx input is provided; it's resolved locally.
188
+ const txToAddress = asset.contract?.address
189
+ return assertCriticalTxAttributes({ ...baseProps, txToAddress })
190
+ }
191
+
192
+ // Normalizes the properties of transactions that can
193
+ // vary depending upon the `txType` and `asset` to
194
+ // ensure these evaluate consistently.
195
+ export const resolveTxAttributesByTxType = async ({
196
+ asset,
197
+ assetClientInterface,
198
+ amount: providedAmount,
199
+ fromAddress: providedFromAddress,
200
+ nonce: providedNonce,
201
+ txInput: providedTxInput,
202
+ txToAddress: providedTxToAddress,
203
+ toAddress: providedToAddress,
204
+ txType,
205
+ walletAccount,
206
+ }) => {
207
+ assert(isValidTxType(txType), 'expected valid txType')
208
+
209
+ const fromAddress = await resolveTxFromAddress({
210
+ asset,
211
+ assetClientInterface,
212
+ fromAddress: providedFromAddress,
213
+ walletAccount,
214
+ })
215
+
216
+ const nonce = await resolveTxNonce({
217
+ asset,
218
+ fromAddress,
219
+ nonce: providedNonce,
220
+ walletAccount,
221
+ })
222
+
223
+ return createResolvedTxAttributes({
224
+ asset,
225
+ fromAddress,
226
+ nonce,
227
+ ...resolveCriticalTxAttributes({
228
+ asset,
229
+ amount: providedAmount,
230
+ toAddress: providedToAddress,
231
+ txInput: providedTxInput,
232
+ txToAddress: providedTxToAddress,
233
+ txType,
234
+ }),
235
+ })
236
+ }